Merge pull request #1529 from balena-io/1523-start-lock

Improve supervisor API behavior when locks are set
This commit is contained in:
bulldozer-balena[bot] 2020-12-14 14:31:51 +00:00 committed by GitHub
commit d5f996d217
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 641 additions and 100 deletions

View File

@ -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 })

View File

@ -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) =>

View File

@ -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 },

View File

@ -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),
});

View File

@ -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
});

View File

@ -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
});

View File

@ -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;