Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add Device Group env vars API support #4659

Open
wants to merge 10 commits into
base: main
Choose a base branch
from
5 changes: 5 additions & 0 deletions forge/auditLog/application.js
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,11 @@ module.exports = {
},
async membersChanged (actionedBy, error, application, deviceGroup, updates, info) {
await log('application.deviceGroup.members.changed', actionedBy, application?.id, generateBody({ error, application, deviceGroup, updates, info }))
},
settings: {
async updated (actionedBy, error, application, deviceGroup, updates) {
await log('application.deviceGroup.settings.updated', actionedBy, application?.id, generateBody({ error, application, deviceGroup, updates }))
}
}
}
}
Expand Down
19 changes: 19 additions & 0 deletions forge/db/migrations/20241016-01-add-settings-to-devicegroup.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
/**
* Add settings to DeviceGroups table
*/
const { DataTypes } = require('sequelize')

module.exports = {
/**
* upgrade database
* @param {QueryInterface} context Sequelize.QueryInterface
*/
up: async (context) => {
await context.addColumn('DeviceGroups', 'settings', {
type: DataTypes.TEXT,
defaultValue: null
})
},
down: async (context) => {
}
}
23 changes: 21 additions & 2 deletions forge/db/models/Device.js
Original file line number Diff line number Diff line change
Expand Up @@ -148,17 +148,36 @@ module.exports = {
})
},
async updateSettingsHash (settings) {
const _settings = settings || await this.getAllSettings()
const _settings = settings || await this.getAllSettings({ mergeDeviceGroupSettings: true })
delete _settings.autoSnapshot // autoSnapshot is not part of the settings hash
this.settingsHash = hashSettings(_settings)
},
async getAllSettings () {
async getAllSettings (options = { mergeDeviceGroupSettings: false }) {
const mergeDeviceGroupSettings = options.mergeDeviceGroupSettings || false
const result = {}
const settings = await this.getDeviceSettings()
settings.forEach(setting => {
result[setting.key] = setting.value
})
result.env = Controllers.Device.insertPlatformSpecificEnvVars(this, result.env) // add platform specific device env vars
// if the device is a group member, we need to merge the group settings
if (mergeDeviceGroupSettings && this.DeviceGroupId) {
const group = this.DeviceGroup || await M.DeviceGroup.byId(this.DeviceGroupId)
if (group) {
const groupEnv = await group.settings.env || []
// Merge rule: If the device has an env var AND it has a value, it remains unchanged.
// Otherwise, the value is taken from the group.
// This is to allow the device to override a (global) group env setting.
groupEnv.forEach(env => {
const existing = result.env.find(e => e.name === env.name)
if (!existing) {
result.env.push(env)
} else if (existing && !existing.value) {
existing.value = env.value
}
})
}
}
if (!Object.prototype.hasOwnProperty.call(result, 'autoSnapshot')) {
result.autoSnapshot = DEFAULT_SETTINGS.autoSnapshot
}
Expand Down
303 changes: 172 additions & 131 deletions forge/db/models/DeviceGroup.js
Original file line number Diff line number Diff line change
@@ -1,131 +1,172 @@
/**
* A DeviceGroup.
* A logical grouping of devices for the primary intent of group deployments in the pipeline stages.
* @namespace forge.db.models.DeviceGroup
*/

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

const { buildPaginationSearchClause } = require('../utils')
const nameValidator = { msg: 'Device Group name cannot be empty' }

module.exports = {
name: 'DeviceGroup',
schema: {
name: {
type: DataTypes.STRING,
allowNull: false,
validate: {
notEmpty: nameValidator,
notNull: nameValidator
}
},
description: { type: DataTypes.TEXT },
targetSnapshotId: { type: DataTypes.INTEGER, allowNull: true }
},
associations: function (M) {
this.belongsTo(M.Application, { onDelete: 'CASCADE' })
this.belongsTo(M.ProjectSnapshot, { as: 'targetSnapshot' })
this.hasMany(M.Device)
},
finders: function (M) {
const self = this
const deviceCountLiteral = literal(`(
SELECT COUNT(*)
FROM "Devices" AS "device"
WHERE
"device"."DeviceGroupId" = "DeviceGroup"."id"
AND
"device"."ApplicationId" = "DeviceGroup"."ApplicationId"
)`)
return {
static: {
byId: async function (id) {
if (typeof id === 'string') {
id = M.DeviceGroup.decodeHashid(id)
}
// find one, include application, devices and device count
return self.findOne({
where: { id },
include: [
{ model: M.Application, attributes: ['hashid', 'id', 'name', 'TeamId'] },
{
model: M.Device,
attributes: ['hashid', 'id', 'name', 'type', 'TeamId', 'ApplicationId', 'ProjectId', 'ownerType'],
where: {
ApplicationId: literal('"Devices"."ApplicationId" = "Application"."id"')
},
required: false
},
{
model: M.ProjectSnapshot,
as: 'targetSnapshot',
attributes: ['hashid', 'id', 'name']
}
],
attributes: {
include: [
[
deviceCountLiteral,
'deviceCount'
]
]
}
})
},
getAll: async (pagination = {}, where = {}) => {
const limit = parseInt(pagination.limit) || 1000
if (pagination.cursor) {
pagination.cursor = M.DeviceGroup.decodeHashid(pagination.cursor)
}
if (where.ApplicationId && typeof where.ApplicationId === 'string') {
where.ApplicationId = M.Application.decodeHashid(where.ApplicationId)
}
const [rows, count] = await Promise.all([
this.findAll({
where: buildPaginationSearchClause(pagination, where, ['DeviceGroup.name', 'DeviceGroup.description']),
include: [
{
model: M.ProjectSnapshot,
as: 'targetSnapshot',
attributes: ['hashid', 'id', 'name']
}
],
attributes: {
include: [
[
deviceCountLiteral,
'deviceCount'
]
]
},
order: [['id', 'ASC']],
limit
}),
this.count({ where })
])
return {
meta: {
next_cursor: rows.length === limit ? rows[rows.length - 1].hashid : undefined
},
count,
groups: rows
}
}
},
instance: {
deviceCount: async function () {
return await M.Device.count({ where: { DeviceGroupId: this.id, ApplicationId: this.ApplicationId } })
},
getDevices: async function () {
return await M.Device.findAll({
where: {
DeviceGroupId: this.id,
ApplicationId: this.ApplicationId
}
})
}
}
}
}
}
/**
* A DeviceGroup.
* A logical grouping of devices for the primary intent of group deployments in the pipeline stages.
* @namespace forge.db.models.DeviceGroup
*/

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

const { buildPaginationSearchClause } = require('../utils')
const nameValidator = { msg: 'Device Group name cannot be empty' }

module.exports = {
name: 'DeviceGroup',
schema: {
name: {
type: DataTypes.STRING,
allowNull: false,
validate: {
notEmpty: nameValidator,
notNull: nameValidator
}
},
description: { type: DataTypes.TEXT },
targetSnapshotId: { type: DataTypes.INTEGER, allowNull: true },
settings: {
type: DataTypes.TEXT,
set (value) {
this.setDataValue('settings', JSON.stringify(value))
},
get () {
const rawValue = this.getDataValue('settings') || '{}'
return JSON.parse(rawValue)
}
}
},
associations: function (M) {
this.belongsTo(M.Application, { onDelete: 'CASCADE' })
this.belongsTo(M.ProjectSnapshot, { as: 'targetSnapshot' })
this.hasMany(M.Device)
},
hooks: function (M, app) {
return {
afterUpdate: async (deviceGroup, options) => {
// if `settings` is updated, we need to update the settings hash
// of any member devices
if (deviceGroup.changed('settings')) {
const include = [
{ model: M.ProjectSnapshot, as: 'targetSnapshot', attributes: ['id', 'hashid', 'name'] },
{
model: M.DeviceGroup,
attributes: ['hashid', 'id', 'ApplicationId', 'settings']
},
{ model: M.Application, attributes: ['hashid', 'id', 'name', 'links'] }
]
const where = {
DeviceGroupId: deviceGroup.id,
ApplicationId: deviceGroup.ApplicationId
}
const devices = await M.Device.findAll({ where, include })
const updateAndSave = async (device) => {
await device.updateSettingsHash()
await device.save({
hooks: false, // skip the afterSave hook for device model (we have only updated the settings hash)
transaction: options?.transaction // pass the transaction (if any)
})
}
await Promise.all(devices.map(updateAndSave))
}
}
}
},
finders: function (M) {
const self = this
const deviceCountLiteral = literal(`(
SELECT COUNT(*)
FROM "Devices" AS "device"
WHERE
"device"."DeviceGroupId" = "DeviceGroup"."id"
AND
"device"."ApplicationId" = "DeviceGroup"."ApplicationId"
)`)
return {
static: {
byId: async function (id) {
if (typeof id === 'string') {
id = M.DeviceGroup.decodeHashid(id)
}
// find one, include application, devices and device count
return self.findOne({
where: { id },
include: [
{ model: M.Application, attributes: ['hashid', 'id', 'name', 'TeamId'] },
{
model: M.Device,
attributes: ['hashid', 'id', 'name', 'type', 'TeamId', 'ApplicationId', 'ProjectId', 'ownerType'],
where: {
ApplicationId: literal('"Devices"."ApplicationId" = "Application"."id"')
},
required: false
},
{
model: M.ProjectSnapshot,
as: 'targetSnapshot',
attributes: ['hashid', 'id', 'name']
}
],
attributes: {
include: [
[
deviceCountLiteral,
'deviceCount'
]
]
}
})
},
getAll: async (pagination = {}, where = {}) => {
const limit = parseInt(pagination.limit) || 1000
if (pagination.cursor) {
pagination.cursor = M.DeviceGroup.decodeHashid(pagination.cursor)
}
if (where.ApplicationId && typeof where.ApplicationId === 'string') {
where.ApplicationId = M.Application.decodeHashid(where.ApplicationId)
}
const [rows, count] = await Promise.all([
this.findAll({
where: buildPaginationSearchClause(pagination, where, ['DeviceGroup.name', 'DeviceGroup.description']),
include: [
{
model: M.ProjectSnapshot,
as: 'targetSnapshot',
attributes: ['hashid', 'id', 'name']
}
],
attributes: {
include: [
[
deviceCountLiteral,
'deviceCount'
]
]
},
order: [['id', 'ASC']],
limit
}),
this.count({ where })
])
return {
meta: {
next_cursor: rows.length === limit ? rows[rows.length - 1].hashid : undefined
},
count,
groups: rows
}
}
},
instance: {
deviceCount: async function () {
return await M.Device.count({ where: { DeviceGroupId: this.id, ApplicationId: this.ApplicationId } })
},
getDevices: async function () {
return await M.Device.findAll({
where: {
DeviceGroupId: this.id,
ApplicationId: this.ApplicationId
}
})
}
}
}
}
}
1 change: 1 addition & 0 deletions forge/db/views/DeviceGroup.js
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@ module.exports = function (app) {
application: item.Application ? app.db.views.Application.applicationSummary(item.Application) : null,
deviceCount: item.deviceCount || 0,
devices: item.Devices ? item.Devices.map(app.db.views.Device.device) : [],
settings: item.settings,
targetSnapshot: app.db.views.ProjectSnapshot.snapshotSummary(item.targetSnapshot)
}
return filtered
Expand Down
Loading
Loading