Skip to content

Commit

Permalink
Merge pull request #4376 from FlowFuse/4362-bom-api
Browse files Browse the repository at this point in the history
Software bill of materials API
  • Loading branch information
cstns authored Sep 19, 2024
2 parents eff63b4 + 5ab1429 commit 19d959e
Show file tree
Hide file tree
Showing 28 changed files with 1,647 additions and 55 deletions.
129 changes: 129 additions & 0 deletions forge/db/models/Application.js
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,135 @@ module.exports = {
return await M.Project.count({
where: { ApplicationId: this.id }
})
},
getChildren: async function ({ includeDependencies = false } = {}) {
const application = this
const children = new Map()
const instances = await application.getInstances(includeDependencies ? { include: [M.ProjectStack] } : undefined)
const devices = await application.getDevices()
for (const instance of instances) {
children.set(instance, { model: instance, type: 'instance' })
const instanceDevices = await app.db.models.Device.getAll(undefined, { ProjectId: instance.id })
if (instanceDevices?.devices?.length) {
for (const device of instanceDevices.devices) {
devices.push(device)
children.set(device, { model: device, type: 'device', ownerType: 'instance', ownerId: instance.id })
}
}
}
for (const device of devices) {
if (children.has(device)) {
continue
}
children.set(device, { model: device, type: 'device', ownerType: 'application', ownerId: application.id })
}

if (includeDependencies) {
const storageController = app.db.controllers.StorageSettings
for (const instance of instances) {
const child = children.get(instance)
const deps = {}
deps['node-red'] = {
semver: instance.ProjectStack?.properties?.nodered,
installed: instance.ProjectStack?.properties?.nodered
}
if (instance.ProjectStack?.replacedBy) {
const replacementStack = await app.db.models.ProjectStack.byId(instance.ProjectStack.replacedBy)
deps['node-red'].semver = replacementStack?.properties?.nodered
}
const settings = await instance.getSetting(KEY_SETTINGS)
if (Array.isArray(settings?.palette?.modules)) {
settings.palette.modules.forEach(m => {
deps[m.name] = {
semver: m.version
}
})
}

const projectModules = await storageController.getProjectModules(child.model) || []
projectModules.forEach(m => {
deps[m.name] = deps[m.name] || {}
deps[m.name].installed = m.version
if (!deps[m.name].semver) {
deps[m.name].semver = m.version
}
})
child.dependencies = deps
}
// a helper function to get the semver Node-RED version for a device.
// It takes into account the agent version, any editor settings, and the active snapshot
// This is a workaround due to having no direct access to the package.json of the device
const getDeviceNodeRedVersion = async (dev, snapshotModules) => {
const ssNodeRed = snapshotModules?.find(m => m.name === 'node-red')
if (ssNodeRed) {
return ssNodeRed.version
}
const editor = await dev.getSetting('editor')
if (editor?.nodeRedVersion) {
return editor.nodeRedVersion
}
return dev.getDefaultNodeRedVersion()
}
for (const device of devices) {
const child = children.get(device)
const deps = {}
if (device.ownerType === 'instance') {
// use the instance's dependencies as a starting point
const instance = instances.find(i => i.id === device.ProjectId)
const parent = children.get(instance)
Object.assign(deps, parent.dependencies)
}
const targetSnapshot = device.targetSnapshotId ? await device.getTargetSnapshot() : null
const activeSnapshot = device.activeSnapshotId ? await device.getActiveSnapshot() : null
const targetModulesSemver = Object.entries(targetSnapshot?.settings?.settings?.palette?.modules || {}).map(([name, version]) => ({ name, version }))
const activeModulesSemver = Object.entries(activeSnapshot?.settings?.settings?.palette?.modules || {}).map(([name, version]) => ({ name, version }))
const activeModulesInstalled = Object.entries(activeSnapshot?.settings?.modules || {}).map(([name, version]) => ({ name, version }))
const defaultModules = device.ownerType === device.getDefaultModules()
if (activeModulesInstalled?.length) {
activeModulesInstalled.forEach(m => {
deps[m.name] = deps[m.name] || {}
deps[m.name].installed = m.version
})
}
if (targetModulesSemver?.length) {
targetModulesSemver.forEach(m => {
deps[m.name] = deps[m.name] || {}
deps[m.name].semver = m.version
})
} else if (activeModulesSemver?.length) {
activeModulesSemver.forEach(m => {
deps[m.name] = deps[m.name] || {}
deps[m.name].semver = m.version
})
} else if (device.ownerType === 'application' && !targetSnapshot && !activeSnapshot) {
// if the device has no snapshots, use the default snapshot data
Object.entries(defaultModules).forEach(([name, version]) => {
deps[name] = deps[name] || {}
deps[name].semver = version
})
}

// some devices dont get informed of the @flowfuse/nr-project-nodes or '@flowfuse/nr-assistant' to install due being included
// via nodesdir or other means. In this case, we will use the installed version as the semver
if (deps['@flowfuse/nr-project-nodes'] && deps['@flowfuse/nr-project-nodes'].installed && !deps['@flowfuse/nr-project-nodes'].semver) {
deps['@flowfuse/nr-project-nodes'].semver = deps['@flowfuse/nr-project-nodes'].installed
}
if (deps['@flowfuse/nr-assistant'] && deps['@flowfuse/nr-assistant'].installed && !deps['@flowfuse/nr-assistant'].semver) {
deps['@flowfuse/nr-assistant'].semver = deps['@flowfuse/nr-assistant'].installed
}

const noderedVersionInstalled = await getDeviceNodeRedVersion(device, activeModulesInstalled) || '*'
const noderedVersionSemver = await getDeviceNodeRedVersion(device, targetModulesSemver) || '*'
deps['node-red'] = {
semver: noderedVersionSemver,
installed: noderedVersionInstalled
}

child.dependencies = deps
}
}

return Array.from(children.values())
}
}
}
Expand Down
17 changes: 17 additions & 0 deletions forge/db/models/Device.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
*/
const crypto = require('crypto')

const SemVer = require('semver')

const { DataTypes, Op } = require('sequelize')

const Controllers = require('../controllers')
Expand Down Expand Up @@ -205,6 +207,21 @@ module.exports = {
limit: 1
})
return snapshots[0]
},
getDefaultNodeRedVersion () {
let nodeRedVersion = '3.0.2' // default to older Node-RED
if (SemVer.satisfies(SemVer.coerce(this.agentVersion), '>=1.11.2')) {
// 1.11.2 includes fix for ESM loading of GOT, so lets use 'latest' as before
nodeRedVersion = 'latest'
}
return nodeRedVersion
},
getDefaultModules () {
return {
'node-red': this.getDefaultNodeRedVersion(),
'@flowfuse/nr-project-nodes': '>0.5.0', // TODO: get this from the "settings" (future)
'@flowfuse/nr-assistant': '>=0.1.0'
}
}
},
static: {
Expand Down
11 changes: 11 additions & 0 deletions forge/db/views/Application.js
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,17 @@ module.exports = function (app) {
}))
}

app.addSchema({
$id: 'ApplicationBom',
type: 'object',
properties: {
id: { type: 'string' },
name: { type: 'string' },
children: { type: 'array', items: { $ref: 'dependant' } } // dependant is defined in BOM.js
},
additionalProperties: true
})

return {
application,
applicationAssociationsStatusList,
Expand Down
72 changes: 72 additions & 0 deletions forge/db/views/BOM.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
let app

module.exports = {
init: (appInstance) => {
app = appInstance
app.addSchema({
$id: 'dependency',
type: 'object',
properties: {
name: { type: 'string' },
version: {
type: 'object',
properties: {
semver: { type: 'string' },
installed: { type: 'string', nullable: true }
}
}
}
})
app.addSchema({
$id: 'dependant',
type: 'object',
properties: {
id: { type: 'string' },
name: { type: 'string' },
type: {
type: 'string',
enum: ['instance', 'device']
},
ownerType: {
type: 'string',
enum: ['instance', 'application'],
nullable: true
},
ownerId: { type: 'string', nullable: true },
dependencies: { type: 'array', items: { $ref: 'dependency' } }
}
})
},

dependency (name, semverVersion, installedVersion = null) {
const result = {
name,
version: {
semver: semverVersion,
installed: installedVersion || null
}
}
return result
},

dependant (model, dependencies) {
const type = model instanceof app.db.models.Project ? 'instance' : model instanceof app.db.models.Device ? 'device' : null
if (type !== null) {
const dependenciesArray = Object.entries(dependencies || {}).map(([name, version]) => app.db.views.BOM.dependency(name, version?.semver, version?.installed))
if (type === 'device') {
const { hashid, name, ownerType } = model
let ownerId = null
if (ownerType === 'instance') {
ownerId = model.ProjectId
} else if (ownerType === 'application') {
ownerId = model.Application ? model.Application.id : app.db.models.Application.encodeHashid(model.ApplicationId)
}
return { id: hashid, name, type, ownerType, ownerId, dependencies: dependenciesArray }
} else if (type === 'instance') {
const { id, name } = model
return { id, name, type, dependencies: dependenciesArray }
}
}
return null
}
}
1 change: 1 addition & 0 deletions forge/db/views/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ const modelTypes = [
'AccessToken',
'Application',
'AuditLog',
'BOM',
'Device',
'DeviceGroup',
'Invitation',
Expand Down
2 changes: 2 additions & 0 deletions forge/ee/lib/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ module.exports = fp(async function (app, opts) {
app.config.features.register('mfa', true, true)
// Set the Device Groups Feature Flag
app.config.features.register('deviceGroups', true, true)
// Set the Bill of Materials Feature Flag
app.config.features.register('bom', true, true)
}

// Set the Team Library Feature Flag
Expand Down
47 changes: 47 additions & 0 deletions forge/ee/routes/bom/application.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
const applicationShared = require('../../../routes/api/shared/application.js')

module.exports = async function (app) {
app.addHook('preHandler', applicationShared.defaultPreHandler.bind(null, app))

/**
* Get the application BOM
* @name /api/v1/application/:applicationId/bom
* @memberof forge.routes.api.application
*/
app.get('/:applicationId/bom', {
preHandler: app.needsPermission('application:bom'),
schema: {
summary: 'Get application BOM',
tags: ['Applications'],
params: {
type: 'object',
properties: {
applicationId: { type: 'string' }
}
},
response: {
200: {
type: 'object',
$ref: 'ApplicationBom'
},
'4xx': {
$ref: 'APIError'
}
}
}
}, async (request, reply) => {
const teamType = await request.application.Team.getTeamType()
if (!teamType.getFeatureProperty('bom', false)) {
return reply.code(404).send({ code: 'unexpected_error', error: 'Feature not enabled.' })
}

const dependants = await request.application.getChildren({ includeDependencies: true })
const childrenView = dependants.map(child => app.db.views.BOM.dependant(child.model, child.dependencies))
const result = {
id: request.application.hashid,
name: request.application.name,
children: childrenView
}
reply.send(result)
})
}
1 change: 1 addition & 0 deletions forge/ee/routes/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ module.exports = async function (app) {
await app.register(require('./sharedLibrary'), { logLevel: app.config.logging.http })
await app.register(require('./pipeline'), { prefix: '/api/v1', logLevel: app.config.logging.http })
await app.register(require('./deviceEditor'), { prefix: '/api/v1/devices/:deviceId/editor', logLevel: app.config.logging.http })
await app.register(require('./bom/application.js'), { prefix: '/api/v1/applications', logLevel: app.config.logging.http })

await app.register(require('./flowBlueprints'), { prefix: '/api/v1/flow-blueprints', logLevel: app.config.logging.http })

Expand Down
3 changes: 3 additions & 0 deletions forge/lib/permissions.js
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,9 @@ const Permissions = {
/**
* EE Permissions
*/
// Application
'application:bom': { description: 'Get the Bill of Materials', role: Roles.Owner },

// Device Groups
'application:device-group:create': { description: 'Create a device group', role: Roles.Owner },
'application:device-group:list': { description: 'List device groups', role: Roles.Member },
Expand Down
29 changes: 3 additions & 26 deletions forge/routes/api/application.js
Original file line number Diff line number Diff line change
@@ -1,30 +1,7 @@
module.exports = async function (app) {
app.addHook('preHandler', async (request, reply) => {
const applicationId = request.params.applicationId
if (applicationId === undefined) {
return
}

if (!applicationId) {
return reply.code(404).send({ code: 'not_found', error: 'Not Found' })
}

try {
request.application = await app.db.models.Application.byId(applicationId)
if (!request.application) {
return reply.code(404).send({ code: 'not_found', error: 'Not Found' })
}
const applicationShared = require('./shared/application.js')

if (request.session.User) {
request.teamMembership = await request.session.User.getTeamMembership(request.application.Team.id)
if (!request.teamMembership && !request.session.User.admin) {
return reply.code(404).send({ code: 'not_found', error: 'Not Found' })
}
}
} catch (err) {
return reply.code(500).send({ code: 'unexpected_error', error: err.toString() })
}
})
module.exports = async function (app) {
app.addHook('preHandler', applicationShared.defaultPreHandler.bind(null, app))

/**
* Create an application
Expand Down
Loading

0 comments on commit 19d959e

Please sign in to comment.