diff --git a/src/compose/application-manager.ts b/src/compose/application-manager.ts index 387166931..28baa135c 100644 --- a/src/compose/application-manager.ts +++ b/src/compose/application-manager.ts @@ -1050,3 +1050,9 @@ export async function removeOrphanedVolumes( await volumeManager.removeOrphanedVolumes(referencedVolumes); } + +export async function getAllServices( + inScope: (id: number) => boolean = () => true, +) { + return (await serviceManager.getAll()).filter((svc) => inScope(svc.appId)); +} diff --git a/src/device-api/actions.ts b/src/device-api/actions.ts index c4300491b..ee1aa7656 100644 --- a/src/device-api/actions.ts +++ b/src/device-api/actions.ts @@ -484,3 +484,37 @@ export const getLogStream = (opts: JournalctlOpts) => { export const getSupervisorVersion = () => { return supervisorVersion; }; + +/** + * Get the containerId(s) associated with a service. + * If no serviceName is provided, get all containerIds. + * Used by: + * - GET /v2/containerId + */ +export const getContainerIds = async ( + serviceName: string = '', + withScope: AuthorizedRequest['auth']['isScoped'] = () => true, +) => { + const services = await applicationManager.getAllServices((id) => + withScope({ apps: [id] }), + ); + + // Return all containerIds if no serviceName is provided + if (!serviceName) { + return services.reduce( + (svcToContainerIdMap, svc) => ({ + [svc.serviceName]: svc.containerId, + ...svcToContainerIdMap, + }), + {}, + ); + } + + // Otherwise, only return containerId of provided serviceNmae + const service = services.find((svc) => svc.serviceName === serviceName); + if (service != null) { + return service.containerId; + } else { + throw new Error(`Could not find service with name '${serviceName}'`); + } +}; diff --git a/src/device-api/v2.ts b/src/device-api/v2.ts index 4d85e1286..200c92e5b 100644 --- a/src/device-api/v2.ts +++ b/src/device-api/v2.ts @@ -389,33 +389,38 @@ router.get('/v2/version', (_req, res, next) => { } }); -router.get('/v2/containerId', async (req: AuthorizedRequest, res) => { - const services = (await serviceManager.getAll()).filter((service) => - req.auth.isScoped({ apps: [service.appId] }), - ); - - if (req.query.serviceName != null || req.query.service != null) { - const serviceName = req.query.serviceName || req.query.service; - const service = _.find(services, (svc) => svc.serviceName === serviceName); - if (service != null) { - res.status(200).json({ +router.get('/v2/containerId', async (req: AuthorizedRequest, res, next) => { + try { + // While technically query parameters support entering a query multiple times + // as in ?service=foo&service=bar, we don't explicitly support this, + // so only pass the serviceName / service if it's a string + let serviceQuery: string = ''; + const { serviceName, service } = req.query; + if (typeof serviceName === 'string' && !!serviceName) { + serviceQuery = serviceName; + } + if (typeof service === 'string' && !!service) { + serviceQuery = service; + } + const result = await actions.getContainerIds( + serviceQuery, + req.auth.isScoped, + ); + // Single containerId + if (typeof result === 'string') { + return res.status(200).json({ status: 'success', - containerId: service.containerId, + containerId: result, }); } else { - res.status(503).json({ - status: 'failed', - message: 'Could not find service with that name', + // Multiple containerIds + return res.status(200).json({ + status: 'success', + services: result, }); } - } else { - res.status(200).json({ - status: 'success', - services: _(services) - .keyBy('serviceName') - .mapValues('containerId') - .value(), - }); + } catch (e: unknown) { + next(e); } }); diff --git a/test/integration/compose/application-manager.spec.ts b/test/integration/compose/application-manager.spec.ts index 7683a0049..a4cfd2db1 100644 --- a/test/integration/compose/application-manager.spec.ts +++ b/test/integration/compose/application-manager.spec.ts @@ -7,6 +7,7 @@ import * as serviceManager from '~/src/compose/service-manager'; import Network from '~/src/compose/network'; import * as networkManager from '~/src/compose/network-manager'; import Volume from '~/src/compose/volume'; +import Service from '~/src/compose/service'; import * as config from '~/src/config'; import { createDockerImage } from '~/test-lib/docker-helper'; import { @@ -1600,4 +1601,52 @@ describe('compose/application-manager', () => { expect(steps).to.have.lengthOf(0); }); }); + + describe('getting all services', () => { + let getAllServices: sinon.SinonStub; + let testServices: Service[]; + before(async () => { + testServices = [ + await createService( + { + serviceName: 'one', + appId: 1, + }, + { state: { containerId: 'abc' } }, + ), + await createService( + { + serviceName: 'two', + appId: 2, + }, + { state: { containerId: 'def' } }, + ), + ]; + getAllServices = sinon + .stub(serviceManager, 'getAll') + .resolves(testServices); + }); + + after(() => { + getAllServices.restore(); + }); + + it('should get all services by default', async () => { + expect(await applicationManager.getAllServices()).to.deep.equal( + testServices, + ); + }); + + it('should get services scoped by appId', async () => { + expect( + await applicationManager.getAllServices((appId) => appId === 1), + ).to.deep.equal([testServices[0]]); + expect( + await applicationManager.getAllServices((appId) => appId === 2), + ).to.deep.equal([testServices[1]]); + expect( + await applicationManager.getAllServices((appId) => appId === 3), + ).to.deep.equal([]); + }); + }); }); diff --git a/test/integration/device-api/actions.spec.ts b/test/integration/device-api/actions.spec.ts index 31cbc9800..f4e0da70f 100644 --- a/test/integration/device-api/actions.spec.ts +++ b/test/integration/device-api/actions.spec.ts @@ -14,6 +14,7 @@ import * as actions from '~/src/device-api/actions'; import * as TargetState from '~/src/device-state/target-state'; import * as applicationManager from '~/src/compose/application-manager'; import { cleanupDocker } from '~/test-lib/docker-helper'; +import { createService } from '~/test-lib/state-helper'; import { exec } from '~/lib/fs-utils'; import * as journald from '~/lib/journald'; @@ -939,3 +940,55 @@ describe('spawns a journal process', () => { expect(spawnJournalctlStub).to.have.been.calledOnceWith(opts); }); }); + +describe('gets service container ids', () => { + // getAllServicesStub is tested in app manager tests + // so we can stub it here + let getAllServicesStub: SinonStub; + before(async () => { + getAllServicesStub = stub(applicationManager, 'getAllServices').resolves([ + await createService( + { + serviceName: 'one', + appId: 1, + }, + { state: { containerId: 'abc' } }, + ), + await createService( + { + serviceName: 'two', + appId: 2, + }, + { state: { containerId: 'def' } }, + ), + ]); + }); + after(() => { + getAllServicesStub.restore(); + }); + + it('gets all containerIds by default', async () => { + expect(await actions.getContainerIds()).to.deep.equal({ + one: 'abc', + two: 'def', + }); + }); + + it('gets a single containerId associated with provided service', async () => { + expect(await actions.getContainerIds('one')).to.deep.equal('abc'); + expect(await actions.getContainerIds('two')).to.deep.equal('def'); + }); + + it('errors if no containerId found associated with provided service', async () => { + try { + await actions.getContainerIds('three'); + expect.fail( + 'getContainerIds should throw for a nonexistent serviceName parameter', + ); + } catch (e: unknown) { + expect((e as Error).message).to.equal( + "Could not find service with name 'three'", + ); + } + }); +}); diff --git a/test/integration/device-api/v2.spec.ts b/test/integration/device-api/v2.spec.ts index 0dff7a3ef..57ab5b80d 100644 --- a/test/integration/device-api/v2.spec.ts +++ b/test/integration/device-api/v2.spec.ts @@ -829,4 +829,71 @@ describe('device-api/v2', () => { .expect(503); }); }); + + describe('GET /v2/containerId', () => { + let getContainerIdStub: SinonStub; + beforeEach(() => { + getContainerIdStub = stub(actions, 'getContainerIds'); + }); + afterEach(() => { + getContainerIdStub.restore(); + }); + + it('accepts query parameters if they are strings', async () => { + getContainerIdStub.resolves('test'); + await request(api) + .get('/v2/containerId?serviceName=one') + .set('Authorization', `Bearer ${await deviceApi.getGlobalApiKey()}`) + .expect(200, { status: 'success', containerId: 'test' }); + expect(getContainerIdStub.firstCall.args[0]).to.equal('one'); + + await request(api) + .get('/v2/containerId?service=two') + .set('Authorization', `Bearer ${await deviceApi.getGlobalApiKey()}`) + .expect(200, { status: 'success', containerId: 'test' }); + expect(getContainerIdStub.secondCall.args[0]).to.equal('two'); + }); + + it('ignores query parameters that are repeated', async () => { + getContainerIdStub.resolves('test'); + await request(api) + .get('/v2/containerId?serviceName=one&serviceName=two') + .set('Authorization', `Bearer ${await deviceApi.getGlobalApiKey()}`) + .expect(200, { status: 'success', containerId: 'test' }); + expect(getContainerIdStub.firstCall.args[0]).to.equal(''); + + await request(api) + .get('/v2/containerId?service=one&service=two') + .set('Authorization', `Bearer ${await deviceApi.getGlobalApiKey()}`) + .expect(200, { status: 'success', containerId: 'test' }); + expect(getContainerIdStub.secondCall.args[0]).to.equal(''); + }); + + it('responds with 200 and single containerId', async () => { + getContainerIdStub.resolves('test'); + await request(api) + .get('/v2/containerId') + .set('Authorization', `Bearer ${await deviceApi.getGlobalApiKey()}`) + .expect(200, { status: 'success', containerId: 'test' }); + }); + + it('responds with 200 and multiple containerIds', async () => { + getContainerIdStub.resolves({ one: 'abc', two: 'def' }); + await request(api) + .get('/v2/containerId') + .set('Authorization', `Bearer ${await deviceApi.getGlobalApiKey()}`) + .expect(200, { + status: 'success', + services: { one: 'abc', two: 'def' }, + }); + }); + + it('responds with 503 if an error occurred', async () => { + getContainerIdStub.throws(new Error()); + await request(api) + .get('/v2/containerId') + .set('Authorization', `Bearer ${await deviceApi.getGlobalApiKey()}`) + .expect(503); + }); + }); });