mirror of
https://github.com/balena-os/balena-supervisor.git
synced 2025-01-19 03:06:27 +00:00
Move reboot/shutdown to actions and related tests to integration
Signed-off-by: Christina Ying Wang <christina@balena.io>
This commit is contained in:
parent
c6cf6a0136
commit
85392f2a85
@ -310,6 +310,22 @@ export const getLegacyService = async (appId: number) => {
|
||||
return (await getCurrentApp(appId, BadRequestError)).services[0];
|
||||
};
|
||||
|
||||
/**
|
||||
* Executes a device state action such as reboot, shutdown, or noop
|
||||
* Used by:
|
||||
* - POST /v1/reboot
|
||||
* - POST /v1/shutdown
|
||||
* - actions.executeServiceAction
|
||||
*/
|
||||
export const executeDeviceAction = async (
|
||||
step: Parameters<typeof deviceState.executeStepAction>[0],
|
||||
force: boolean = false,
|
||||
) => {
|
||||
return await deviceState.executeStepAction(step, {
|
||||
force,
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Executes a composition step action on a service.
|
||||
* isLegacy indicates that the action is being called from a legacy (v1) endpoint,
|
||||
@ -371,12 +387,12 @@ export const executeServiceAction = async ({
|
||||
});
|
||||
|
||||
// Execute action on service
|
||||
return await applicationManager.executeStep(
|
||||
return await executeDeviceAction(
|
||||
generateStep(action, {
|
||||
current: currentService,
|
||||
target: targetService,
|
||||
wait: true,
|
||||
}),
|
||||
{ force },
|
||||
force,
|
||||
);
|
||||
};
|
||||
|
@ -1,5 +1,6 @@
|
||||
import * as express from 'express';
|
||||
import * as _ from 'lodash';
|
||||
import type { Response } from 'express';
|
||||
|
||||
import * as actions from './actions';
|
||||
import { AuthorizedRequest } from './api-keys';
|
||||
@ -87,29 +88,24 @@ const handleLegacyServiceAction = (action: CompositionStepAction) => {
|
||||
router.post('/v1/apps/:appId/stop', handleLegacyServiceAction('stop'));
|
||||
router.post('/v1/apps/:appId/start', handleLegacyServiceAction('start'));
|
||||
|
||||
const rebootOrShutdown = async (
|
||||
req: express.Request,
|
||||
res: express.Response,
|
||||
action: deviceState.DeviceStateStepTarget,
|
||||
) => {
|
||||
const override = await config.get('lockOverride');
|
||||
const force = checkTruthy(req.body.force) || override;
|
||||
try {
|
||||
const response = await deviceState.executeStepAction({ action }, { force });
|
||||
res.status(202).json(response);
|
||||
} catch (e: any) {
|
||||
const status = e instanceof UpdatesLockedError ? 423 : 500;
|
||||
res.status(status).json({
|
||||
Data: '',
|
||||
Error: (e != null ? e.message : undefined) || e || 'Unknown error',
|
||||
});
|
||||
}
|
||||
const handleDeviceAction = (action: deviceState.DeviceStateStepTarget) => {
|
||||
return async (req: AuthorizedRequest, res: Response) => {
|
||||
const force = checkTruthy(req.body.force);
|
||||
try {
|
||||
await actions.executeDeviceAction({ action }, force);
|
||||
return res.status(202).send({ Data: 'OK', Error: null });
|
||||
} catch (e: unknown) {
|
||||
const status = e instanceof UpdatesLockedError ? 423 : 500;
|
||||
return res.status(status).json({
|
||||
Data: '',
|
||||
Error: (e as Error)?.message ?? e ?? 'Unknown error',
|
||||
});
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
router.post('/v1/reboot', (req, res) => rebootOrShutdown(req, res, 'reboot'));
|
||||
router.post('/v1/shutdown', (req, res) =>
|
||||
rebootOrShutdown(req, res, 'shutdown'),
|
||||
);
|
||||
router.post('/v1/reboot', handleDeviceAction('reboot'));
|
||||
router.post('/v1/shutdown', handleDeviceAction('shutdown'));
|
||||
|
||||
router.get('/v1/apps/:appId', async (req: AuthorizedRequest, res, next) => {
|
||||
const appId = checkInt(req.params.appId);
|
||||
|
@ -89,11 +89,7 @@ export type DeviceStateStepTarget = 'reboot' | 'shutdown' | 'noop';
|
||||
|
||||
type PossibleStepTargets = CompositionStepAction | DeviceStateStepTarget;
|
||||
type DeviceStateStep<T extends PossibleStepTargets> =
|
||||
| {
|
||||
action: 'reboot';
|
||||
}
|
||||
| { action: 'shutdown' }
|
||||
| { action: 'noop' }
|
||||
| { action: DeviceStateStepTarget }
|
||||
| CompositionStepT<T extends CompositionStepAction ? T : never>
|
||||
| deviceConfig.ConfigStep;
|
||||
|
||||
@ -564,8 +560,8 @@ export async function shutdown({
|
||||
});
|
||||
}
|
||||
|
||||
export async function executeStepAction<T extends PossibleStepTargets>(
|
||||
step: DeviceStateStep<T>,
|
||||
export async function executeStepAction(
|
||||
step: DeviceStateStep<PossibleStepTargets>,
|
||||
{
|
||||
force,
|
||||
initial,
|
||||
@ -586,19 +582,12 @@ export async function executeStepAction<T extends PossibleStepTargets>(
|
||||
case 'reboot':
|
||||
// There isn't really a way that these methods can fail,
|
||||
// and if they do, we wouldn't know about it until after
|
||||
// the response has been sent back to the API. Just return
|
||||
// "OK" for this and the below action
|
||||
// the response has been sent back to the API.
|
||||
await shutdown({ force, reboot: true });
|
||||
return {
|
||||
Data: 'OK',
|
||||
Error: null,
|
||||
};
|
||||
return;
|
||||
case 'shutdown':
|
||||
await shutdown({ force, reboot: false });
|
||||
return {
|
||||
Data: 'OK',
|
||||
Error: null,
|
||||
};
|
||||
return;
|
||||
case 'noop':
|
||||
return;
|
||||
default:
|
||||
@ -607,8 +596,8 @@ export async function executeStepAction<T extends PossibleStepTargets>(
|
||||
}
|
||||
}
|
||||
|
||||
export async function applyStep<T extends PossibleStepTargets>(
|
||||
step: DeviceStateStep<T>,
|
||||
export async function applyStep(
|
||||
step: DeviceStateStep<PossibleStepTargets>,
|
||||
{
|
||||
force,
|
||||
initial,
|
||||
@ -623,12 +612,12 @@ export async function applyStep<T extends PossibleStepTargets>(
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const stepResult = await executeStepAction(step, {
|
||||
await executeStepAction(step, {
|
||||
force,
|
||||
initial,
|
||||
skipLock,
|
||||
});
|
||||
emitAsync('step-completed', null, step, stepResult || undefined);
|
||||
emitAsync('step-completed', null, step);
|
||||
} catch (e: any) {
|
||||
emitAsync('step-error', e, step);
|
||||
throw e;
|
||||
|
@ -1,5 +1,5 @@
|
||||
import { expect } from 'chai';
|
||||
import { stub, SinonStub } from 'sinon';
|
||||
import { stub, SinonStub, spy, SinonSpy } from 'sinon';
|
||||
import * as Docker from 'dockerode';
|
||||
import * as request from 'supertest';
|
||||
import { setTimeout } from 'timers/promises';
|
||||
@ -7,6 +7,7 @@ import { setTimeout } from 'timers/promises';
|
||||
import * as deviceState from '~/src/device-state';
|
||||
import * as deviceApi from '~/src/device-api';
|
||||
import * as actions from '~/src/device-api/actions';
|
||||
import * as dbus from '~/lib/dbus';
|
||||
import { cleanupDocker } from '~/test-lib/docker-helper';
|
||||
|
||||
describe('regenerates API keys', () => {
|
||||
@ -111,13 +112,13 @@ describe('manages application lifecycle', () => {
|
||||
appId?: number;
|
||||
serviceNames?: string[];
|
||||
}) => {
|
||||
const { name, config } = await getSupervisorTarget();
|
||||
const { name, config: svConfig } = await getSupervisorTarget();
|
||||
return {
|
||||
local: {
|
||||
// We don't want to change name or config as this may result in
|
||||
// unintended reboots. We just want to test state changes in containers.
|
||||
name,
|
||||
config,
|
||||
config: svConfig,
|
||||
apps:
|
||||
serviceCount === 0
|
||||
? {}
|
||||
@ -616,3 +617,25 @@ 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;
|
||||
});
|
||||
|
||||
it('shuts down device', async () => {
|
||||
await actions.executeDeviceAction({ action: 'shutdown' });
|
||||
expect(dbus.shutdown as SinonSpy).to.have.been.called;
|
||||
});
|
||||
});
|
||||
|
@ -496,4 +496,144 @@ describe('device-api/v1', () => {
|
||||
.expect(503);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /v1/reboot', () => {
|
||||
let executeDeviceActionStub: SinonStub;
|
||||
beforeEach(() => {
|
||||
executeDeviceActionStub = stub(actions, 'executeDeviceAction').resolves();
|
||||
});
|
||||
afterEach(async () => executeDeviceActionStub.restore());
|
||||
|
||||
it('validates data from request body', async () => {
|
||||
// Parses force: false
|
||||
await request(api)
|
||||
.post('/v1/reboot')
|
||||
.send({ force: false })
|
||||
.set('Authorization', `Bearer ${await deviceApi.getGlobalApiKey()}`);
|
||||
expect(executeDeviceActionStub).to.have.been.calledWith(
|
||||
{
|
||||
action: 'reboot',
|
||||
},
|
||||
false,
|
||||
);
|
||||
executeDeviceActionStub.resetHistory();
|
||||
|
||||
// Parses force: true
|
||||
await request(api)
|
||||
.post('/v1/reboot')
|
||||
.send({ force: true })
|
||||
.set('Authorization', `Bearer ${await deviceApi.getGlobalApiKey()}`);
|
||||
expect(executeDeviceActionStub).to.have.been.calledWith(
|
||||
{
|
||||
action: 'reboot',
|
||||
},
|
||||
true,
|
||||
);
|
||||
executeDeviceActionStub.resetHistory();
|
||||
|
||||
// Defaults to force: false
|
||||
await request(api)
|
||||
.post('/v1/reboot')
|
||||
.set('Authorization', `Bearer ${await deviceApi.getGlobalApiKey()}`);
|
||||
expect(executeDeviceActionStub).to.have.been.calledWith(
|
||||
{
|
||||
action: 'reboot',
|
||||
},
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it('responds with 202 if request successful', async () => {
|
||||
await request(api)
|
||||
.post('/v1/reboot')
|
||||
.set('Authorization', `Bearer ${await deviceApi.getGlobalApiKey()}`)
|
||||
.expect(202);
|
||||
});
|
||||
|
||||
it('responds with 423 if there are update locks', async () => {
|
||||
executeDeviceActionStub.throws(new UpdatesLockedError());
|
||||
await request(api)
|
||||
.post('/v1/reboot')
|
||||
.set('Authorization', `Bearer ${await deviceApi.getGlobalApiKey()}`)
|
||||
.expect(423);
|
||||
});
|
||||
|
||||
it('responds with 500 for other errors that occur during reboot', async () => {
|
||||
executeDeviceActionStub.throws(new Error());
|
||||
await request(api)
|
||||
.post('/v1/reboot')
|
||||
.set('Authorization', `Bearer ${await deviceApi.getGlobalApiKey()}`)
|
||||
.expect(500);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /v1/shutdown', () => {
|
||||
let executeDeviceActionStub: SinonStub;
|
||||
beforeEach(() => {
|
||||
executeDeviceActionStub = stub(actions, 'executeDeviceAction').resolves();
|
||||
});
|
||||
afterEach(async () => executeDeviceActionStub.restore());
|
||||
|
||||
it('validates data from request body', async () => {
|
||||
// Parses force: false
|
||||
await request(api)
|
||||
.post('/v1/shutdown')
|
||||
.send({ force: false })
|
||||
.set('Authorization', `Bearer ${await deviceApi.getGlobalApiKey()}`);
|
||||
expect(executeDeviceActionStub).to.have.been.calledWith(
|
||||
{
|
||||
action: 'shutdown',
|
||||
},
|
||||
false,
|
||||
);
|
||||
executeDeviceActionStub.resetHistory();
|
||||
|
||||
// Parses force: true
|
||||
await request(api)
|
||||
.post('/v1/shutdown')
|
||||
.send({ force: true })
|
||||
.set('Authorization', `Bearer ${await deviceApi.getGlobalApiKey()}`);
|
||||
expect(executeDeviceActionStub).to.have.been.calledWith(
|
||||
{
|
||||
action: 'shutdown',
|
||||
},
|
||||
true,
|
||||
);
|
||||
executeDeviceActionStub.resetHistory();
|
||||
|
||||
// Defaults to force: false
|
||||
await request(api)
|
||||
.post('/v1/shutdown')
|
||||
.set('Authorization', `Bearer ${await deviceApi.getGlobalApiKey()}`);
|
||||
expect(executeDeviceActionStub).to.have.been.calledWith(
|
||||
{
|
||||
action: 'shutdown',
|
||||
},
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it('responds with 202 if request successful', async () => {
|
||||
await request(api)
|
||||
.post('/v1/shutdown')
|
||||
.set('Authorization', `Bearer ${await deviceApi.getGlobalApiKey()}`)
|
||||
.expect(202);
|
||||
});
|
||||
|
||||
it('responds with 423 if there are update locks', async () => {
|
||||
executeDeviceActionStub.throws(new UpdatesLockedError());
|
||||
await request(api)
|
||||
.post('/v1/shutdown')
|
||||
.set('Authorization', `Bearer ${await deviceApi.getGlobalApiKey()}`)
|
||||
.expect(423);
|
||||
});
|
||||
|
||||
it('responds with 500 for other errors that occur during shutdown', async () => {
|
||||
executeDeviceActionStub.throws(new Error());
|
||||
await request(api)
|
||||
.post('/v1/shutdown')
|
||||
.set('Authorization', `Bearer ${await deviceApi.getGlobalApiKey()}`)
|
||||
.expect(500);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -164,291 +164,6 @@ 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]);
|
||||
|
||||
await mockedDockerode.testWithData(
|
||||
{ containers: [container], images: [image] },
|
||||
async () => {
|
||||
const response = await request
|
||||
.post('/v1/reboot')
|
||||
.set('Accept', 'application/json')
|
||||
.set('Authorization', `Bearer ${await deviceApi.getGlobalApiKey()}`)
|
||||
.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(async (__, opts, fn) => {
|
||||
if (opts.force) {
|
||||
return 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]);
|
||||
|
||||
await mockedDockerode.testWithData(
|
||||
{ containers: [container], images: [image] },
|
||||
async () => {
|
||||
const response = await request
|
||||
.post('/v1/reboot')
|
||||
.set('Accept', 'application/json')
|
||||
.set('Authorization', `Bearer ${await deviceApi.getGlobalApiKey()}`)
|
||||
.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(async (__, opts, fn) => {
|
||||
if (opts.force) {
|
||||
return 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]);
|
||||
|
||||
await mockedDockerode.testWithData(
|
||||
{ containers: [container], images: [image] },
|
||||
async () => {
|
||||
const response = await request
|
||||
.post('/v1/reboot')
|
||||
.send({ force: true })
|
||||
.set('Accept', 'application/json')
|
||||
.set('Authorization', `Bearer ${await deviceApi.getGlobalApiKey()}`)
|
||||
.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]);
|
||||
|
||||
await mockedDockerode.testWithData(
|
||||
{ containers: [container], images: [image] },
|
||||
async () => {
|
||||
const response = await request
|
||||
.post('/v1/shutdown')
|
||||
.set('Accept', 'application/json')
|
||||
.set('Authorization', `Bearer ${await deviceApi.getGlobalApiKey()}`)
|
||||
.expect(202);
|
||||
|
||||
expect(response.body).to.have.property('Data').that.is.not.empty;
|
||||
expect(shutdownMock).to.have.been.calledOnce;
|
||||
},
|
||||
);
|
||||
|
||||
shutdownMock.resetHistory();
|
||||
});
|
||||
|
||||
it('should lock all applications before trying to shutdown', async () => {
|
||||
// Setup 2 applications running
|
||||
const twoContainers = [
|
||||
mockedAPI.mockService({
|
||||
containerId: 'abc123',
|
||||
appId: 1000,
|
||||
releaseId: 55555,
|
||||
}),
|
||||
mockedAPI.mockService({
|
||||
containerId: 'def456',
|
||||
appId: 2000,
|
||||
releaseId: 77777,
|
||||
}),
|
||||
];
|
||||
const twoImages = [
|
||||
mockedAPI.mockImage({
|
||||
appId: 1000,
|
||||
}),
|
||||
mockedAPI.mockImage({
|
||||
appId: 2000,
|
||||
}),
|
||||
];
|
||||
appMock.mockManagers(twoContainers, [], []);
|
||||
appMock.mockImages([], false, twoImages);
|
||||
|
||||
const lockSpy = spy(updateLock, 'lock');
|
||||
await mockedDockerode.testWithData(
|
||||
{ containers: twoContainers, images: twoImages },
|
||||
async () => {
|
||||
const response = await request
|
||||
.post('/v1/shutdown')
|
||||
.set('Accept', 'application/json')
|
||||
.set('Authorization', `Bearer ${await deviceApi.getGlobalApiKey()}`)
|
||||
.expect(202);
|
||||
|
||||
expect(lockSpy.callCount).to.equal(1);
|
||||
// Check that lock was passed both application Ids
|
||||
expect(lockSpy.lastCall.args[0]).to.deep.equal([1000, 2000]);
|
||||
expect(response.body).to.have.property('Data').that.is.not.empty;
|
||||
expect(shutdownMock).to.have.been.calledOnce;
|
||||
},
|
||||
);
|
||||
|
||||
shutdownMock.resetHistory();
|
||||
lockSpy.restore();
|
||||
});
|
||||
|
||||
it('should return 423 and reject the reboot if locks are set', async () => {
|
||||
stub(updateLock, 'lock').callsFake(async (__, opts, fn) => {
|
||||
if (opts.force) {
|
||||
return 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]);
|
||||
|
||||
await mockedDockerode.testWithData(
|
||||
{ containers: [container], images: [image] },
|
||||
async () => {
|
||||
const response = await request
|
||||
.post('/v1/shutdown')
|
||||
.set('Accept', 'application/json')
|
||||
.set('Authorization', `Bearer ${await deviceApi.getGlobalApiKey()}`)
|
||||
.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(async (__, opts, fn) => {
|
||||
if (opts.force) {
|
||||
return 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]);
|
||||
|
||||
await mockedDockerode.testWithData(
|
||||
{ containers: [container], images: [image] },
|
||||
async () => {
|
||||
const response = await request
|
||||
.post('/v1/shutdown')
|
||||
.send({ force: true })
|
||||
.set('Accept', 'application/json')
|
||||
.set('Authorization', `Bearer ${await deviceApi.getGlobalApiKey()}`)
|
||||
.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();
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /v1/update', () => {
|
||||
let configStub: SinonStub;
|
||||
let targetUpdateSpy: SinonSpy;
|
||||
|
Loading…
Reference in New Issue
Block a user