diff --git a/forge/auditLog/application.js b/forge/auditLog/application.js
index 4464512cdf..4c67bb1b5c 100644
--- a/forge/auditLog/application.js
+++ b/forge/auditLog/application.js
@@ -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 }))
+ }
}
}
}
diff --git a/forge/db/migrations/20241016-01-add-settings-to-devicegroup.js b/forge/db/migrations/20241016-01-add-settings-to-devicegroup.js
new file mode 100644
index 0000000000..30bbc2b3db
--- /dev/null
+++ b/forge/db/migrations/20241016-01-add-settings-to-devicegroup.js
@@ -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) => {
+ }
+}
diff --git a/forge/db/models/Device.js b/forge/db/models/Device.js
index 444efccef7..76b6915a12 100644
--- a/forge/db/models/Device.js
+++ b/forge/db/models/Device.js
@@ -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
}
diff --git a/forge/db/models/DeviceGroup.js b/forge/db/models/DeviceGroup.js
index b1546b8896..5a2951944a 100644
--- a/forge/db/models/DeviceGroup.js
+++ b/forge/db/models/DeviceGroup.js
@@ -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
+ }
+ })
+ }
+ }
+ }
+ }
+}
diff --git a/forge/db/views/DeviceGroup.js b/forge/db/views/DeviceGroup.js
index 9f678109eb..f88ae3b624 100644
--- a/forge/db/views/DeviceGroup.js
+++ b/forge/db/views/DeviceGroup.js
@@ -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
diff --git a/forge/ee/db/controllers/DeviceGroup.js b/forge/ee/db/controllers/DeviceGroup.js
index 9cab468f61..be9f9aae68 100644
--- a/forge/ee/db/controllers/DeviceGroup.js
+++ b/forge/ee/db/controllers/DeviceGroup.js
@@ -1,6 +1,9 @@
const { Op, ValidationError } = require('sequelize')
const { ControllerError } = require('../../../lib/errors')
+
+const hasProperty = (obj, key) => obj && Object.prototype.hasOwnProperty.call(obj, key)
+
class DeviceGroupMembershipValidationError extends ControllerError {
/**
* @param {string} code
@@ -50,7 +53,7 @@ module.exports = {
* @param {string} [options.description] - The new description of the Device Group. Exclude to keep the current description.
* @param {number} [options.targetSnapshotId] - The new target snapshot id of the Device Group. Exclude to keep the current snapshot. Send null to clear the current target snapshot.
*/
- updateDeviceGroup: async function (app, deviceGroup, { name = undefined, description = undefined, targetSnapshotId = undefined } = {}) {
+ updateDeviceGroup: async function (app, deviceGroup, { name = undefined, description = undefined, targetSnapshotId = undefined, settings = undefined } = {}) {
// * deviceGroup is required.
// * name, description, color are optional
if (!deviceGroup) {
@@ -67,6 +70,32 @@ module.exports = {
changed = true
}
+ if (typeof settings !== 'undefined' && hasProperty(settings, 'env')) {
+ // NOTE: For now, device group settings only support environment variables
+
+ // validate settings
+ if (!Array.isArray(settings.env)) {
+ throw new ValidationError('Invalid settings')
+ }
+ settings.env.forEach((envVar) => {
+ if (!envVar?.name?.match(/^[a-zA-Z_]+[a-zA-Z0-9_]*$/)) {
+ throw new ValidationError(`Invalid Env Var name '${envVar.name}'`)
+ }
+ })
+ // find duplicates
+ const seen = new Set()
+ const duplicates = settings.env.some(item => { return seen.size === seen.add(item.name).size })
+ if (duplicates) {
+ throw new ValidationError('Duplicate Env Var names provided')
+ }
+
+ deviceGroup.settings = {
+ ...deviceGroup.settings,
+ env: settings.env
+ }
+ changed = true
+ }
+
if (typeof targetSnapshotId !== 'undefined') {
let snapshotId = targetSnapshotId
// ensure the snapshot exists (if targetSnapshotId is not null)
@@ -90,21 +119,21 @@ module.exports = {
}
await transaction.commit()
saved = true
+ changed = true
} catch (err) {
await transaction.rollback()
throw err
}
-
- // inform the devices an update is required
- if (devices?.length) {
- await this.sendUpdateCommand(app, deviceGroup)
- }
}
if (changed && !saved) {
await deviceGroup.save()
}
await deviceGroup.reload()
+
+ if (changed) {
+ await this.sendUpdateCommand(app, deviceGroup)
+ }
return deviceGroup
},
@@ -178,8 +207,8 @@ module.exports = {
const remainingDevices = await deviceGroup.deviceCount()
if (remainingDevices === 0) {
deviceGroup.targetSnapshotId = null
- await deviceGroup.save()
}
+ await deviceGroup.save()
// finally, inform the devices an update may be required
await this.sendUpdateCommand(app, deviceGroup, actualRemoveDevices)
}
diff --git a/forge/ee/routes/applicationDeviceGroups/index.js b/forge/ee/routes/applicationDeviceGroups/index.js
index c8c80a6644..87f45a0147 100644
--- a/forge/ee/routes/applicationDeviceGroups/index.js
+++ b/forge/ee/routes/applicationDeviceGroups/index.js
@@ -10,6 +10,7 @@
const { ValidationError } = require('sequelize')
const { UpdatesCollection } = require('../../../auditLog/formatters.js')
+const { Roles } = require('../../../lib/roles.js')
const { DeviceGroupMembershipValidationError } = require('../../db/controllers/DeviceGroup.js')
// Declare getLogger function to provide type hints / quick code nav / code completion
@@ -371,6 +372,80 @@ module.exports = async function (app) {
reply.send({})
})
+ /**
+ * Update Device Group Settings (Environment Variables)
+ * @method PUT
+ * @name /api/v1/applications/:applicationId/device-groups/:groupId/settings
+ * @memberof forge.routes.api.application
+ */
+ app.put('/:groupId/settings', {
+ preHandler: app.needsPermission('application:device-group:update'), // re-use update permission (owner only)
+ schema: {
+ summary: 'Update a Device Group Settings',
+ tags: ['Application Device Groups'],
+ body: {
+ type: 'object',
+ properties: {
+ env: { type: 'array', items: { type: 'object', additionalProperties: true } }
+ }
+ },
+ params: {
+ type: 'object',
+ properties: {
+ applicationId: { type: 'string' },
+ groupId: { type: 'string' }
+ }
+ },
+ response: {
+ 200: {
+ $ref: 'APIStatus'
+ },
+ '4xx': {
+ $ref: 'APIError'
+ }
+ }
+ }
+ }, async (request, reply) => {
+ /** @type {Model} */
+ const deviceGroup = request.deviceGroup
+ const isMember = request.teamMembership.role === Roles.Member
+ /** @type {import('../../db/controllers/DeviceGroup.js')} */
+ const deviceGroupController = app.db.controllers.DeviceGroup
+ try {
+ let bodySettings
+ if (isMember) {
+ bodySettings = {
+ env: request.body.env
+ }
+ } else {
+ bodySettings = request.body
+ }
+
+ // for audit log
+ const updates = new app.auditLog.formatters.UpdatesCollection()
+ // transform the env arrays to a map for better logging format
+ const currentEnv = (deviceGroup.settings?.env || []).reduce((acc, e) => {
+ acc[e.name] = e.value
+ return acc
+ }, {})
+ const newEnv = bodySettings.env.reduce((acc, e) => {
+ acc[e.name] = e.value
+ return acc
+ }, {})
+ updates.pushDifferences({ env: currentEnv }, { env: newEnv })
+
+ // perform update & log
+ await deviceGroupController.updateDeviceGroup(deviceGroup, { settings: bodySettings })
+ if (updates.length > 0) {
+ await deviceGroupLogger.settings.updated(request.session.User, null, request.application, deviceGroup, updates)
+ }
+
+ reply.send({})
+ } catch (err) {
+ return handleError(err, reply)
+ }
+ })
+
function handleError (err, reply) {
let statusCode = 500
let code = 'unexpected_error'
diff --git a/forge/routes/api/deviceLive.js b/forge/routes/api/deviceLive.js
index b4239ff85b..b031b2b4cd 100644
--- a/forge/routes/api/deviceLive.js
+++ b/forge/routes/api/deviceLive.js
@@ -217,7 +217,9 @@ module.exports = async function (app) {
hash: request.device.settingsHash,
env: {}
}
- const settings = await request.device.getAllSettings()
+ const settings = await request.device.getAllSettings({
+ mergeDeviceGroupSettings: true
+ })
Object.keys(settings).forEach(key => {
if (key === 'env') {
settings.env.forEach(envVar => {
diff --git a/frontend/src/api/application.js b/frontend/src/api/application.js
index 22919a28b5..159021160f 100644
--- a/frontend/src/api/application.js
+++ b/frontend/src/api/application.js
@@ -342,6 +342,17 @@ const updateDeviceGroupMembership = async (applicationId, groupId, { add, remove
return client.patch(`/api/v1/applications/${applicationId}/device-groups/${groupId}`, { add, remove, set })
}
+/**
+ * Update the settings of a device group
+ * NOTE: Only ENV VARS are supported at the moment
+ * @param {string} applicationId - The ID of application
+ * @param {string} groupId - The ID of the group
+ * @param {{ env: [{name: String, value: String}]}} settings - The new settings for the group
+ */
+const updateDeviceGroupSettings = async (applicationId, groupId, settings) => {
+ return client.put(`/api/v1/applications/${applicationId}/device-groups/${groupId}/settings`, settings)
+}
+
/**
* Get a list of Dependencies / Bill of Materials
* @param applicationId
@@ -373,5 +384,6 @@ export default {
deleteDeviceGroup,
updateDeviceGroup,
updateDeviceGroupMembership,
+ updateDeviceGroupSettings,
getDependencies
}
diff --git a/frontend/src/components/audit-log/AuditEntryIcon.vue b/frontend/src/components/audit-log/AuditEntryIcon.vue
index 465430c6e2..af22649118 100644
--- a/frontend/src/components/audit-log/AuditEntryIcon.vue
+++ b/frontend/src/components/audit-log/AuditEntryIcon.vue
@@ -208,7 +208,8 @@ const iconMap = {
'application.deviceGroup.created',
'application.deviceGroup.updated',
'application.deviceGroup.deleted',
- 'application.deviceGroup.members.changed'
+ 'application.deviceGroup.members.changed',
+ 'application.deviceGroup.settings.updated'
],
beaker: [
'team.device.developer-mode.enabled',
diff --git a/frontend/src/components/audit-log/AuditEntryVerbose.vue b/frontend/src/components/audit-log/AuditEntryVerbose.vue
index b5bb65c53f..ae3b32e15f 100644
--- a/frontend/src/components/audit-log/AuditEntryVerbose.vue
+++ b/frontend/src/components/audit-log/AuditEntryVerbose.vue
@@ -441,7 +441,12 @@
- Device Group '{{ entry.body.deviceGroup?.name }}' members in Application '{{ entry.body.application?.name }} updated: {{ entry.body?.info?.info ?? 'No changes' }}.
+ Device Group '{{ entry.body.deviceGroup?.name }}' members in Application '{{ entry.body.application?.name }}' updated: {{ entry.body?.info?.info ?? 'No changes' }}.
+ Device Group data not found in audit entry.
+
+
+
+ Device Group '{{ entry.body.deviceGroup?.name }}' settings in Application '{{ entry.body.application?.name }}' updated.
Device Group data not found in audit entry.
diff --git a/frontend/src/data/audit-events.json b/frontend/src/data/audit-events.json
index da43468996..347fa6348d 100644
--- a/frontend/src/data/audit-events.json
+++ b/frontend/src/data/audit-events.json
@@ -55,7 +55,8 @@
"application.deviceGroup.created": "Device Group Created",
"application.deviceGroup.updated": "Device Group Updated",
"application.deviceGroup.deleted": "Device Group Deleted",
- "application.deviceGroup.members.changed": "Device Group Members Changed"
+ "application.deviceGroup.members.changed": "Device Group Members Changed",
+ "application.deviceGroup.settings.updated": "Device Group Settings Updated"
},
"project": {
"project.created": "Instance Created",
diff --git a/frontend/src/pages/admin/Template/sections/Environment.vue b/frontend/src/pages/admin/Template/sections/Environment.vue
index 2878cc287d..5239df8ddb 100644
--- a/frontend/src/pages/admin/Template/sections/Environment.vue
+++ b/frontend/src/pages/admin/Template/sections/Environment.vue
@@ -3,9 +3,21 @@