diff --git a/src/features/device-state/routes/state-get-v3.ts b/src/features/device-state/routes/state-get-v3.ts index ba7eef427..da76d6436 100644 --- a/src/features/device-state/routes/state-get-v3.ts +++ b/src/features/device-state/routes/state-get-v3.ts @@ -88,6 +88,7 @@ export function buildAppFromRelease( application: AnyObject, release: AnyObject, config: Dictionary, + defaultLabels?: Dictionary, ): NonNullable { let composition: AnyObject = {}; const services: NonNullable[string]['services'] = @@ -135,7 +136,9 @@ export function buildAppFromRelease( varListInsert(si.device_service_environment_variable, environment); } - const labels: Dictionary = {}; + const labels: Dictionary = { + ...defaultLabels, + }; for (const { label_name, value } of [ ...ipr.image_label, ...svc.service_label, @@ -264,6 +267,16 @@ const deviceExpand: Expand = { }, }, }, + should_be_operated_by__release: { + ...releaseExpand, + $expand: { + ...releaseExpand.$expand, + belongs_to__application: { + $select: ['id', 'uuid', 'app_name', 'is_host', 'is_of__class'], + $expand: appExpand, + }, + }, + }, }; const stateQuery = _.once(() => @@ -293,15 +306,26 @@ const getStateV3 = async (req: Request, uuid: string): Promise => { storedDeviceFields: _.pick(device, getStateEventAdditionalFields), }); - let apps = getUserAppState(device, config); + // We use an empty config for the supervisor & hostApp as we don't want any labels applied to them due to user app config + const svAndHostAppConfig = {}; + const apps = { + ...getAppState(device, 'should_be_managed_by__release', svAndHostAppConfig), + ...getAppState( + device, + 'should_be_operated_by__release', + svAndHostAppConfig, + { + // This label is necessary for older supervisors to properly detect the hostApp + // and ignore it, sinc `is_host: true` wasn't enough. W/o this the device would + // try to install the hostApp container like a normal user app and restart it + // constantly b/c the image doesn't have a CMD specified. + // See: https://github.com/balena-os/balena-supervisor/blob/v15.2.0/src/compose/app.ts#L839 + 'io.balena.image.store': 'root', + }, + ), + ...getAppState(device, 'should_be_running__release', config), + }; - const supervisorRelease = device.should_be_managed_by__release[0]; - if (supervisorRelease) { - apps = { - ...getSupervisorAppState(device), - ...apps, - }; - } const state: StateV3 = { [uuid]: { name: device.device_name, @@ -343,10 +367,26 @@ const getDevice = getStateDelayingEmpty( const getAppState = ( device: AnyObject, - application: AnyObject, - release: AnyObject | undefined, + targetReleaseField: + | 'should_be_running__release' + | 'should_be_managed_by__release' + | 'should_be_operated_by__release', config: Dictionary, -): StateV3[string]['apps'] => { + defaultLabels?: Dictionary, +): StateV3[string]['apps'] | null => { + let application: AnyObject; + let release: AnyObject | undefined; + if (targetReleaseField === 'should_be_running__release') { + application = device.belongs_to__application[0]; + release = getReleaseForDevice(device); + } else { + release = device[targetReleaseField][0]; + if (!release) { + return null; + } + application = release.belongs_to__application[0]; + } + return { [application.uuid]: { id: application.id, @@ -354,26 +394,14 @@ const getAppState = ( is_host: application.is_host, class: application.is_of__class, ...(release != null && { - releases: buildAppFromRelease(device, application, release, config), + releases: buildAppFromRelease( + device, + application, + release, + config, + defaultLabels, + ), }), }, }; }; - -const getUserAppState = ( - device: AnyObject, - config: Dictionary, -): StateV3[string]['apps'] => { - const userApp = device.belongs_to__application[0]; - const userAppRelease = getReleaseForDevice(device); - return getAppState(device, userApp, userAppRelease, config); -}; -const getSupervisorAppState = (device: AnyObject): StateV3[string]['apps'] => { - const supervisorRelease = device.should_be_managed_by__release[0]; - if (!supervisorRelease) { - return {}; - } - const supervisorApp = supervisorRelease.belongs_to__application[0]; - // We use an empty config as we don't want any labels applied to the supervisor due to user app config - return getAppState(device, supervisorApp, supervisorRelease, {}); -}; diff --git a/test/19_apps.ts b/test/19_apps.ts index f78f0aec7..625cc0fe0 100644 --- a/test/19_apps.ts +++ b/test/19_apps.ts @@ -116,5 +116,104 @@ versions.test((version, pineTest) => { ).to.not.have.property(supervisorApp.uuid); }); }); + + describe('HostApps', () => { + let fx: fixtures.Fixtures; + let admin: UserObjectParam; + let pineAdmin: PineTest; + let userApp: Application; + let intelNucHostApp: Application; + let intelNucHostAppRelease1: Release; + let deviceWithHostApp: fakeDevice.Device; + let deviceWithoutHostApp: fakeDevice.Device; + + before(async () => { + mockery.registerMock('../src/lib/config', configMock); + fx = await fixtures.load('19-apps'); + + admin = fx.users.admin; + pineAdmin = pineTest.clone({ + passthrough: { + user: admin, + }, + }); + userApp = fx.applications.app1; + intelNucHostApp = fx.applications['intel-nuc']; + intelNucHostAppRelease1 = fx.releases.intelNucHostAppRelease1; + + deviceWithHostApp = await fakeDevice.provisionDevice( + admin, + userApp.id, + 'balenaOS 2.50.1+rev1', + ); + await expectResourceToMatch(pineAdmin, 'device', deviceWithHostApp.id, { + should_be_operated_by__release: { __id: intelNucHostAppRelease1.id }, + }); + deviceWithoutHostApp = await fakeDevice.provisionDevice( + admin, + userApp.id, + 'balenaOS 2.3.0+rev1', + ); + await expectResourceToMatch( + pineAdmin, + 'device', + deviceWithoutHostApp.id, + { + should_be_operated_by__release: null, + }, + ); + }); + + after(async () => { + await fixtures.clean({ devices: [deviceWithHostApp] }); + await fixtures.clean(fx); + mockery.deregisterMock('../src/lib/config'); + }); + + it('should have a host app if operated by a release', async () => { + const state = await deviceWithHostApp.getStateV3(); + expect( + Object.keys(state[deviceWithHostApp.uuid].apps).sort(), + 'wrong number of apps', + ).to.deep.equal([intelNucHostApp.uuid, userApp.uuid].sort()); + + expect(state[deviceWithHostApp.uuid].apps) + .to.have.property(intelNucHostApp.uuid) + .that.is.an('object'); + const stateGetHostApp = + state[deviceWithHostApp.uuid].apps?.[intelNucHostApp.uuid]; + expect(stateGetHostApp).to.have.property( + 'name', + intelNucHostApp.app_name, + ); + expect(stateGetHostApp).to.have.property('is_host', true); + expect(stateGetHostApp).to.have.property('class', 'app'); + expect(stateGetHostApp) + .to.have.nested.property('releases') + .that.has.property(intelNucHostAppRelease1.commit) + .that.is.an('object'); + expect(stateGetHostApp.releases?.[intelNucHostAppRelease1.commit]) + .to.have.property('services') + .that.has.property('main') + .that.is.an('object') + .and.has.property('labels') + .that.has.property('io.balena.image.store', 'root'); + + expect(stateGetHostApp, 'hostApp is undefined').to.not.be.undefined; + }); + + it('should not have a host app if not operated by a release', async () => { + const state = await deviceWithoutHostApp.getStateV3(); + expect( + Object.keys(state[deviceWithoutHostApp.uuid].apps), + 'wrong number of apps', + ).to.deep.equal([userApp.uuid]); + + expect( + state[deviceWithoutHostApp.uuid].apps, + 'host app should not be included', + ).to.not.have.property(intelNucHostApp.uuid); + }); + }); }); }); diff --git a/test/fixtures/19-apps/applications.json b/test/fixtures/19-apps/applications.json index 2ad73b227..db8884380 100644 --- a/test/fixtures/19-apps/applications.json +++ b/test/fixtures/19-apps/applications.json @@ -5,6 +5,33 @@ "device_type": "intel-nuc", "app_name": "test-app-1" }, + "raspberrypi3": { + "user": "admin", + "is_host": true, + "is_public": true, + "is_of__class": "app", + "device_type": "raspberrypi3", + "app_name": "raspberrypi3", + "organization": "balena_os" + }, + "intel-nuc": { + "user": "admin", + "is_host": true, + "is_public": true, + "is_of__class": "app", + "device_type": "intel-nuc", + "app_name": "intel-nuc", + "organization": "balena_os" + }, + "intel-nuc-esr": { + "user": "admin", + "is_host": true, + "is_public": true, + "is_of__class": "app", + "device_type": "intel-nuc", + "app_name": "intel-nuc-esr", + "organization": "balena_os" + }, "supervisorApp": { "uuid": "031f48d8f47b4062ad2d67b8de933711", "user": "admin", diff --git a/test/fixtures/19-apps/images.json b/test/fixtures/19-apps/images.json index 921b96c16..138d057d8 100644 --- a/test/fixtures/19-apps/images.json +++ b/test/fixtures/19-apps/images.json @@ -43,5 +43,41 @@ "project_type": "project type goes here", "build_log": "This is also the build log", "status": "success" + }, + "rpi3NucHostAppImage0": { + "user": "admin", + "service": "rpi3HostAppService", + "releases": [ "rpi3HostAppRelease0" ], + "image_size": 2048, + "project_type": "project type goes here", + "build_log": "This is also the build log", + "status": "success" + }, + "intelNucHostAppImage0": { + "user": "admin", + "service": "intelNucHostAppService", + "releases": [ "intelNucHostAppRelease0" ], + "image_size": 2048, + "project_type": "project type goes here", + "build_log": "This is also the build log", + "status": "success" + }, + "intelNucHostAppImage1": { + "user": "admin", + "service": "intelNucHostAppService", + "releases": [ "intelNucHostAppRelease1" ], + "image_size": 2048, + "project_type": "project type goes here", + "build_log": "This is also the build log", + "status": "success" + }, + "intelNucHostAppImageUnified1": { + "user": "admin", + "service": "intelNucHostAppService", + "releases": [ "intelNucHostAppUnifiedRelease1" ], + "image_size": 2048, + "project_type": "project type goes here", + "build_log": "This is also the build log", + "status": "success" } } diff --git a/test/fixtures/19-apps/release_tags.json b/test/fixtures/19-apps/release_tags.json index a00e0a626..30d5e97d0 100644 --- a/test/fixtures/19-apps/release_tags.json +++ b/test/fixtures/19-apps/release_tags.json @@ -22,5 +22,71 @@ "release": "supervisorRelease2", "tag_key": "BALENA_REQUIRED_SUPERVISOR_v1.0.1", "value": "v1.0.1" + }, + "rpi3HostAppRelease0tag-variant": { + "user": "admin", + "release": "rpi3HostAppRelease0", + "tag_key": "variant", + "value": "production" + }, + "rpi3HostAppRelease0tag-version": { + "user": "admin", + "release": "rpi3HostAppRelease0", + "tag_key": "version", + "value": "2.50.0+rev1" + }, + "intelNucHostAppRelease0Tag-variant": { + "user": "admin", + "release": "intelNucHostAppRelease0", + "tag_key": "variant", + "value": "production" + }, + "intelNucHostAppRelease0Tag-version": { + "user": "admin", + "release": "intelNucHostAppRelease0", + "tag_key": "version", + "value": "2.50.0+rev1" + }, + "intelNucHostAppRelease1Tag-variant": { + "user": "admin", + "release": "intelNucHostAppRelease1", + "tag_key": "variant", + "value": "production" + }, + "intelNucHostAppRelease1Tag-version": { + "user": "admin", + "release": "intelNucHostAppRelease1", + "tag_key": "version", + "value": "2.50.1+rev1" + }, + "intelNucHostAppReleaseFailedTag-variant": { + "user": "admin", + "release": "intelNucHostAppReleaseFailed", + "tag_key": "variant", + "value": "production" + }, + "intelNucHostAppReleaseFailedTag-version": { + "user": "admin", + "release": "intelNucHostAppReleaseFailed", + "tag_key": "version", + "value": "2.50.1+rev1" + }, + "intelNucHostAppUnifiedRelease1Tag-version": { + "user": "admin", + "release": "intelNucHostAppUnifiedRelease1", + "tag_key": "version", + "value": "2.88.4" + }, + "intelNucEsrHostAppRelease0Tag-variant": { + "user": "admin", + "release": "intelNucEsrHostAppRelease0", + "tag_key": "variant", + "value": "production" + }, + "intelNucEsrHostAppRelease0Tag-version": { + "user": "admin", + "release": "intelNucEsrHostAppRelease0", + "tag_key": "version", + "value": "2021.01.0" } } diff --git a/test/fixtures/19-apps/releases.json b/test/fixtures/19-apps/releases.json index a11efba85..8b2e7d24c 100644 --- a/test/fixtures/19-apps/releases.json +++ b/test/fixtures/19-apps/releases.json @@ -61,5 +61,47 @@ } }, "source": "cloud" + }, + "rpi3HostAppRelease0": { + "application": "raspberrypi3", + "composition": {}, + "source": "cloud", + "status": "success", + "user": "admin" + }, + "intelNucHostAppRelease0": { + "application": "intel-nuc", + "composition": {}, + "source": "cloud", + "status": "success", + "user": "admin" + }, + "intelNucHostAppRelease1": { + "application": "intel-nuc", + "composition": {}, + "source": "cloud", + "status": "success", + "user": "admin" + }, + "intelNucHostAppReleaseFailed": { + "application": "intel-nuc", + "composition": {}, + "source": "cloud", + "status": "failed", + "user": "admin" + }, + "intelNucEsrHostAppRelease0": { + "application": "intel-nuc-esr", + "composition": {}, + "source": "cloud", + "status": "success", + "user": "admin" + }, + "intelNucHostAppUnifiedRelease1": { + "application": "intel-nuc", + "composition": {}, + "source": "cloud", + "status": "success", + "user": "admin" } } diff --git a/test/fixtures/19-apps/services.json b/test/fixtures/19-apps/services.json index a5e13c486..1d4075298 100644 --- a/test/fixtures/19-apps/services.json +++ b/test/fixtures/19-apps/services.json @@ -8,5 +8,15 @@ "user": "admin", "application": "supervisorApp", "service_name": "resin-supervisor" + }, + "rpi3HostAppService": { + "user": "admin", + "service_name": "main", + "application": "raspberrypi3" + }, + "intelNucHostAppService": { + "user": "admin", + "service_name": "main", + "application": "intel-nuc" } } diff --git a/test/test-lib/fake-device.ts b/test/test-lib/fake-device.ts index 11ff69645..7ca529f53 100644 --- a/test/test-lib/fake-device.ts +++ b/test/test-lib/fake-device.ts @@ -80,7 +80,7 @@ export const generateDeviceUuid = () => randomUUID().replaceAll('-', ''); export async function provisionDevice( admin: UserObjectParam, appId: number, - osVersion?: string, + osVersion?: string | { os_version: string; os_variant?: string }, supervisorVersion?: string, supervisorReleaseId?: number, ) { @@ -139,9 +139,18 @@ export async function provisionDevice( }) .expect(200); + if (typeof osVersion === 'string') { + osVersion = { + os_version: osVersion, + }; + } + if (osVersion != null) { + osVersion.os_variant ??= 'prod'; + } + await device.patchStateV2({ local: { - os_version: osVersion, + ...osVersion, supervisor_version: supervisorVersion, }, });