UNPKG

@flowfuse/flowfuse

Version:

An open source low-code development platform

585 lines (560 loc) • 24.6 kB
/** * A Project * @namespace forge.db.models.Project * // type helpers for design time help and error checking * @typedef {import('sequelize').Model} Model * @typedef {import('sequelize').ModelAttributes} ModelAttributes * @typedef {import('sequelize').SchemaOptions} SchemaOptions * @typedef {import('sequelize').ModelIndexesOptions} ModelIndexesOptions * @typedef {import('sequelize').InitOptions} InitOptions * @typedef {import('sequelize').ModelScopeOptions} ModelScopeOptions * @typedef {{name: string, schema: ModelAttributes, model: Model, indexes?: ModelIndexesOptions[], scopes?: ModelScopeOptions, options?: InitOptions}} FFModel */ const crypto = require('crypto') const { col, fn, DataTypes, Op, where } = require('sequelize') const Controllers = require('../controllers') const { KEY_HOSTNAME, KEY_SETTINGS, KEY_HA, KEY_PROTECTED, KEY_HEALTH_CHECK_INTERVAL, KEY_CUSTOM_HOSTNAME, KEY_DISABLE_AUTO_SAFE_MODE } = require('./ProjectSettings') const BANNED_NAME_LIST = [ 'app', 'www', 'node-red', 'nodered', 'forge', 'support', 'help', 'accounts', 'account', 'status', 'billing', 'mqtt', 'broker', 'egress', 'npm', 'registry' ] /** @type {FFModel} */ module.exports = { name: 'Project', schema: { id: { type: DataTypes.UUID, primaryKey: true, defaultValue: DataTypes.UUIDV4 }, name: { type: DataTypes.STRING, allowNull: false, set (value) { this.setDataValue('safeName', value?.toLowerCase()) this.setDataValue('name', value) } }, type: { type: DataTypes.STRING, allowNull: false }, url: { type: DataTypes.STRING, allowNull: false, get () { const originalUrl = this.getDataValue('url')?.replace(/\/$/, '') || '' const projectSettingsRow = this.ProjectSettings?.find((projectSetting) => projectSetting.key === KEY_SETTINGS) let httpAdminRoot = projectSettingsRow?.value.httpAdminRoot if (httpAdminRoot === undefined) { httpAdminRoot = this.ProjectTemplate?.settings?.httpAdminRoot } if (httpAdminRoot) { if (httpAdminRoot[0] !== '/') { httpAdminRoot = `/${httpAdminRoot}` } return originalUrl + httpAdminRoot } else { return originalUrl } } }, slug: { type: DataTypes.VIRTUAL, get () { return this.id } }, state: { type: DataTypes.STRING, allowNull: false, defaultValue: 'running' }, links: { type: DataTypes.VIRTUAL, get () { return { self: process.env.FLOWFORGE_BASE_URL + '/api/v1/projects/' + this.id } } }, storageURL: { type: DataTypes.VIRTUAL, get () { return process.env.FLOWFORGE_API_URL + '/storage' } }, auditURL: { type: DataTypes.VIRTUAL, get () { return process.env.FLOWFORGE_API_URL + '/logging' } }, safeName: { type: DataTypes.STRING, unique: true, allowNull: false, get () { return this.getDataValue('safeName') || this.getDataValue('name')?.toLowerCase() } }, versions: { type: DataTypes.TEXT, get () { const rawValue = this.getDataValue('versions') return rawValue ? JSON.parse(rawValue) : {} }, set (value) { if (Object.keys(value).length === 0) { this.setDataValue('versions', null) return } this.setDataValue('versions', JSON.stringify(value)) } } }, indexes: [ { name: 'projects_safe_name_unique', fields: ['safeName'], unique: true } ], associations: function (M) { this.belongsTo(M.Application, { foreignKey: { allowNull: false } }) this.belongsTo(M.Team) // TODO redundant now there's an application link instead this.hasOne(M.AuthClient, { foreignKey: 'ownerId', constraints: false, scope: { ownerType: 'project' } }) this.hasOne(M.AccessToken, { foreignKey: 'ownerId', constraints: false, scope: { ownerType: 'project' } }) this.hasMany(M.ProjectSettings) this.belongsTo(M.ProjectType) this.belongsTo(M.ProjectStack) this.belongsTo(M.ProjectTemplate) this.hasMany(M.ProjectSnapshot) this.hasOne(M.StorageFlow) }, hooks: function (M, app) { return { beforeCreate: async (project, opts) => { // if the product is licensed, we permit overage const isLicensed = app.license.active() if (isLicensed !== true) { const { instances } = await app.license.usage('instances') if (instances.count >= instances.limit) { throw new Error('license limit reached') } } }, afterCreate: async (project, opts) => { const { instances } = await app.license.usage('instances') if (instances.count > instances.limit) { await app.auditLog.Platform.platform.license.overage('system', null, instances) } }, beforeUpdate: async (project, opts) => { if (project.changed('name')) { if (project.state !== 'suspended') { throw new Error('Name can only be changed when suspended') } } }, afterDestroy: async (project, opts) => { await M.AccessToken.destroy({ where: { ownerType: 'project', ownerId: project.id } }) await M.AccessToken.destroy({ where: { ownerType: 'npm', ownerId: { [Op.like]: `p-${project.id}@%` } } }) await M.AuthClient.destroy({ where: { ownerType: 'project', ownerId: project.id } }) await M.BrokerClient.destroy({ where: { ownerType: 'project', ownerId: project.id } }) await M.ProjectSettings.destroy({ where: { ProjectId: project.id } }) await M.StorageLibrary.destroy({ where: { ProjectId: project.id } }) await M.StorageSettings.destroy({ where: { ProjectId: project.id } }) await M.StorageSession.destroy({ where: { ProjectId: project.id } }) await M.StorageCredentials.destroy({ where: { ProjectId: project.id } }) await M.StorageFlow.destroy({ where: { ProjectId: project.id } }) await M.Notification.destroy({ where: { type: { [Op.in]: ['instance-crashed', 'instance-safe-mode', 'instance-resource-cpu', 'instance-resource-memory'] }, reference: { [Op.in]: [`instance-crashed:${project.id}`, `instance-safe-mode:${project.id}`, `instance-resource-cpu:${project.id}`, `instance-resource-memory:${project.id}`] } } }) } } }, finders: function (M, app) { return { instance: { async refreshAuthTokens () { const authClient = await Controllers.AuthClient.createClientForProject(this) const projectToken = await Controllers.AccessToken.createTokenForProject(this, null) const projectBrokerCredentials = await Controllers.BrokerClient.createClientForProject(this) return { token: projectToken.token, ...authClient, broker: projectBrokerCredentials } }, async getAllSettings () { const result = {} const settings = await this.getProjectSettings() settings.forEach(setting => { result[setting.key] = setting.value }) result.settings = result.settings || {} result.settings.env = Controllers.Project.insertPlatformSpecificEnvVars(this, result.settings.env) // add platform specific device env vars return result }, async updateSettings (obj, options) { const updates = [] for (const [key, value] of Object.entries(obj)) { if (key === 'settings' && value && Array.isArray(value.env)) { value.env = Controllers.Project.removePlatformSpecificEnvVars(value.env) // remove platform specific values } updates.push({ ProjectId: this.id, key, value }) } options = options || {} options.updateOnDuplicate = ['value'] await M.ProjectSettings.bulkCreate(updates, options) }, async updateSetting (key, value, options) { if (key === 'settings' && value && Array.isArray(value.env)) { value.env = Controllers.Project.removePlatformSpecificEnvVars(value.env) // remove platform specific values } return M.ProjectSettings.upsert({ ProjectId: this.id, key, value }, options) }, async getSetting (key) { const result = await M.ProjectSettings.findOne({ where: { ProjectId: this.id, key } }) if (result) { if (key === 'settings') { result.value = result.value || {} result.value.env = Controllers.Project.insertPlatformSpecificEnvVars(this, result.value.env) } return result.value } return undefined }, async removeSetting (key, options) { return M.ProjectSettings.destroy({ where: { ProjectId: this.id, key } }, options) }, async getCredentialSecret () { // If this project was created at 0.6+ but then started with a <0.6 launcher // (for example, in k8s with an old stack) then the project will have both // StorageSettings._credentialSecret *AND* ProjectSettings.credentialSecret // If both are present, we *must* use _credentialSecret as that is what // the runtime is using let credentialSecret = await this.getSetting('credentialSecret') if (!credentialSecret) { // Older project - check the StorageSettings to see if // the runtime has generated one const storageSettings = await M.StorageSettings.byProject(this.id) if (storageSettings && storageSettings.settings) { try { const projectSettings = JSON.parse(storageSettings.settings) credentialSecret = projectSettings._credentialSecret } catch (err) { } } } return credentialSecret }, async liveState () { let storageFlow = this.StorageFlow if (storageFlow === undefined) { app.log.warn(`N+1 warning - Requested live state for instance ${this.id} with no storage flow loaded`) storageFlow = await M.StorageFlow.byProject(this.id) } const inflightState = Controllers.Project.getInflightState(this) const isDeploying = Controllers.Project.isDeploying(this) const result = { flowLastUpdatedAt: storageFlow?.updatedAt // prop not set if storageFlow not found } if (inflightState) { result.meta = { state: inflightState } } else if (this.state === 'suspended') { result.meta = { state: 'suspended' } } else { result.meta = await app.containers.details(this) || { state: 'unknown' } if (result.meta.versions) { const currentVersionInfo = { ...this.versions } let changed = false for (const [key, value] of Object.entries(result.meta.versions)) { currentVersionInfo[key] = currentVersionInfo[key] || {} if (currentVersionInfo[key].current !== value) { currentVersionInfo[key].current = value changed = true } } if (changed) { this.versions = currentVersionInfo await this.save() } } } result.meta.isDeploying = isDeploying return result }, async getLatestSnapshot () { const snapshots = await this.getProjectSnapshots({ order: [['createdAt', 'DESC']], limit: 1 }) return snapshots[0] } }, static: { BANNED_NAME_LIST, isNameUsed: async (name) => { const safeName = name?.toLowerCase() const count = await this.count({ where: { safeName } }) return count !== 0 }, byUser: async (user) => { return this.findAll({ include: { model: M.Team, attributes: ['hashid', 'id', 'name', 'slug', 'links', 'TeamTypeId', 'suspended'], include: [ { model: M.TeamMember, where: { UserId: user.id } } ], required: true } }) }, byId: async (id, { includeStorageFlows = false } = {}) => { const include = [ { model: M.Team, attributes: ['hashid', 'id', 'name', 'slug', 'links', 'TeamTypeId', 'suspended'] }, { model: M.Application, attributes: ['hashid', 'id', 'name', 'links'] }, { model: M.ProjectType, attributes: ['hashid', 'id', 'name'] }, { model: M.ProjectStack, attributes: ['hashid', 'id', 'name', 'label', 'links', 'properties', 'replacedBy', 'ProjectTypeId'] }, { model: M.ProjectTemplate, attributes: ['hashid', 'id', 'name', 'links', 'settings', 'policy'] }, { model: M.ProjectSettings, where: { [Op.or]: [ { key: KEY_SETTINGS }, { key: KEY_HOSTNAME }, { key: KEY_HA }, { key: KEY_PROTECTED }, { key: KEY_CUSTOM_HOSTNAME }, { key: KEY_HEALTH_CHECK_INTERVAL }, { key: KEY_DISABLE_AUTO_SAFE_MODE } ] }, required: false } ] // Used for instance status if (includeStorageFlows) { include.push({ model: M.StorageFlow, attributes: ['id', 'updatedAt'] }) } return this.findOne({ where: { id }, include }) }, byApplication: async (applicationHashId, { includeSettings = false, includeStorageFlows = false } = {}) => { const applicationId = M.Application.decodeHashid(applicationHashId) const include = [ { model: M.Team, attributes: ['hashid', 'id', 'name', 'slug', 'links', 'TeamTypeId', 'suspended'] }, { model: M.Application, where: { id: applicationId }, attributes: ['hashid', 'id', 'name', 'links'] }, { model: M.ProjectType, attributes: ['hashid', 'id', 'name'] } ] // Needed for project.url (stored in ProjectSettings) if (includeSettings) { include.push({ model: M.ProjectTemplate, attributes: ['hashid', 'id', 'name', 'links', 'settings', 'policy'] }) include.push({ model: M.ProjectSettings, where: { [Op.or]: [ { key: KEY_SETTINGS }, { key: KEY_HA }, { key: KEY_PROTECTED } ] }, required: false }) } // Used for instance status if (includeStorageFlows) { include.push({ model: M.StorageFlow, attributes: ['id', 'updatedAt'] }) } return this.findAll({ include }) }, byTeam: async (teamIdOrHash, { query = null, instanceId = null, includeAssociations = true, includeSettings = false } = {}) => { let teamId = teamIdOrHash if (typeof teamId === 'string') { teamId = M.Team.decodeHashid(teamId) } const include = [ { model: M.Team, where: { id: teamId }, attributes: ['hashid', 'id', 'name', 'slug', 'links', 'TeamTypeId'] } ] if (includeAssociations) { include.push({ model: M.Application, attributes: ['hashid', 'id', 'name', 'links'] }, { model: M.ProjectType, attributes: ['hashid', 'id', 'name'] }, { model: M.ProjectStack }, { model: M.ProjectTemplate, attributes: ['hashid', 'id', 'name', 'links'] } ) } if (includeSettings) { include.push({ model: M.ProjectSettings, attributes: ['id', 'key', 'value', 'ProjectId'], where: { key: 'settings' } }) } const queryObject = { include } if (instanceId) { queryObject.where = { id: instanceId } } else if (query) { queryObject.where = where( fn('lower', col('Project.name')), { [Op.like]: `%${query.toLowerCase()}%` } ) } return this.findAll(queryObject) }, getProjectTeamId: async (id) => { const project = await this.findOne({ where: { id }, attributes: [ 'TeamId' ] }) if (project) { return project.TeamId } }, generateCredentialSecret () { return crypto.randomBytes(32).toString('hex') }, byTeamForDashboard: async (teamHashId) => { const teamId = M.Team.decodeHashid(teamHashId) return this.findAll({ include: [ { model: M.Team, where: { id: teamId }, attributes: ['hashid', 'id', 'name', 'slug', 'links', 'TeamTypeId', 'suspended'] }, { model: M.Application, attributes: ['hashid', 'id', 'name', 'links'] }, { model: M.ProjectSettings, attributes: ['id', 'key', 'value', 'ProjectId'], where: { key: 'settings' } } ] }) } } } } }