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

Auto-create Application/Instance when first joining FlowForge #2553

Merged
merged 12 commits into from
Aug 2, 2023
6 changes: 6 additions & 0 deletions forge/auditLog/user.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,12 @@ module.exports = {
async autoCreateTeam (actionedBy, error, team) {
await log('account.verify.auto-create-team', actionedBy, generateBody({ error, team }))
},
async autoCreateApplication (actionedBy, error, application) {
await log('account.verify.auto-create-application', actionedBy, generateBody({ error, application }))
},
async autoCreateInstance (actionedBy, error, instance) {
await log('account.verify.auto-create-instance', actionedBy, generateBody({ error, instance }))
},
async requestToken (actionedBy, error) {
await log('account.verify.request-token', actionedBy, generateBody({ error }))
},
Expand Down
236 changes: 236 additions & 0 deletions forge/db/controllers/Project.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,27 @@ const inflightProjectState = { }

const inflightDeploys = new Set()

class ControllerError extends Error {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The long term intension would be to have a standard set of FlowForge error objects that we use throughout the app at various logical levels.

/**
* ControllerError
* @param {string} code
* @param {string} message
* @param {number} statusCode
*/
constructor (code, message, statusCode = null, options = null) {
super(message, options)

this.name = 'ControllerError'

this.code = code
this.error = message

if (statusCode) {
this.statusCode = statusCode
}
}
}

module.exports = {
/**
* Get the in-flight state of a project
Expand Down Expand Up @@ -270,6 +291,221 @@ module.exports = {
},

/**
*
* @param {*} app
* @param {Team} team
* @param {Application} application
* @param {User} user
* @param {ProjectType} type
* @param {ProjectStack} stack
* @param {ProjectTemplate} template
* @param {{name: string, ha: {}, sourceProject: Project, sourceProjectOptions: {}}} properties Props of the project to create
* @returns
*/
create: async function (
app,
team,
application,
user,
type,
stack,
template,
{
name = '',
ha = null,
sourceProject = null,
sourceProjectOptions = {}
} = {}
) {
if (!user) {
throw new ControllerError('invalid_user', 'Invalid user')
}

if (!team) {
throw new ControllerError('invalid_team', 'Invalid team')
}

if (!application) {
throw new ControllerError('invalid_application', 'Invalid application')
}

if (!type) {
throw new ControllerError('invalid_project_type', 'Invalid project type')
}

// This will perform all checks needed to ensure this instance type can be created for this team.
// Throws an exception if not allowed
await team.checkInstanceTypeCreateAllowed(type)

if (sourceProject) {
if (sourceProject.Team.id !== team.id) {
throw new ControllerError('invalid_source_project', 'Source Project Not in Same Team', 403)
} else if (sourceProject && sourceProject.Application.id !== application.id) {
throw new ControllerError('invalid_source_project', 'Source Project Not in Same Application', 403)
}
}

if (!stack || stack.ProjectTypeId !== type.id) {
throw new ControllerError('invalid_stack', 'Invalid stack')
}

if (!template) {
throw new ControllerError('invalid_template', 'Invalid template')
}

name = name.trim()
const safeName = name?.toLowerCase()
if (app.db.models.Project.BANNED_NAME_LIST.includes(safeName)) {
throw new ControllerError('invalid_project_name', 'name not allowed', 409)
}

if (/^[a-zA-Z][a-zA-Z0-9-]*$/.test(safeName) === false) {
throw new ControllerError('invalid_project_name', 'name not allowed', 409)
}

if (await app.db.models.Project.isNameUsed(safeName)) {
throw new ControllerError('invalid_project_name', 'name in use', 409)
}

if (app.license.active() && app.ha) {
if (ha && !await app.ha.isHAAllowed(team, type, ha)) {
throw new ControllerError('invalid_ha', 'Invalid HA configuration')
}
}

let instance
try {
instance = await app.db.models.Project.create({
name,
ApplicationId: application.id,
type: '',
url: ''
})
} catch (err) {
throw new ControllerError('unexpected_error', err.message, null, { cause: err })
}

await team.addProject(instance)
await instance.setProjectStack(stack)
await instance.setProjectTemplate(template)
await instance.setProjectType(type)

if (app.license.active() && app.ha && ha) {
await instance.updateHASettings(ha)
}

await instance.reload({
include: [
{ model: app.db.models.Team },
{ model: app.db.models.ProjectType },
{ model: app.db.models.ProjectStack },
{ model: app.db.models.ProjectTemplate },
{ model: app.db.models.ProjectSettings }
]
})

if (sourceProject) {
await app.db.controllers.Project.importFromInstance(instance, sourceProject, sourceProjectOptions)
} else {
const newProjectSettings = { header: { title: instance.name } }
// Copy the palette modules from the template (if any)
// This is an instance creation time only operation to avoid the complexities of
// merging the palette modules from the template with the instance palette modules.
if (template.settings.palette?.modules?.length > 0) {
newProjectSettings.palette = { modules: [...template.settings.palette.modules] }
}
await instance.updateSetting(KEY_SETTINGS, newProjectSettings)
await instance.updateSetting('credentialSecret', app.db.models.Project.generateCredentialSecret())
}

await app.containers.start(instance)
await app.auditLog.Project.project.created(user, null, team, instance)

if (sourceProject) {
await app.auditLog.Team.project.duplicated(user, null, team, sourceProject, instance)
} else {
await app.auditLog.Team.project.created(user, null, team, instance)
}

return instance
},

/**
* This method imports from an existing instance, whereas importProject imports from a representation of an instance
* Long term, these two method should be combined.
*
* @param {*} app
* @param {Project} targetInstance
* @param {Project} sourceInstance
* @param {{flows: boolean, credentials: boolean, envVars: boolean}} options
*/
importFromInstance: async function (app, targetInstance, sourceInstance, options = {}) {
// need to copy values over
const settingsString = (await app.db.models.StorageSettings.byProject(sourceInstance.id))?.settings ?? '{}'
const newSettings = {
users: {}
}
const sourceSettings = JSON.parse(settingsString)
if (settingsString) {
newSettings.nodes = sourceSettings.nodes
}
const newCredentialSecret = app.db.models.Project.generateCredentialSecret()
if (options.flows) {
const sourceFlows = await app.db.models.StorageFlow.byProject(sourceInstance.id)
if (sourceFlows) {
const newFlow = await app.db.models.StorageFlow.create({
flow: sourceFlows.flow,
ProjectId: targetInstance.id
})
await newFlow.save()
}

if (options.credentials) {
// To copy over the credentials, we have to:
// - get the existing credentials + credentialSecret
// - generate a new credentialSecret for the new project
// (this is normally left to NR to do itself)
// - re-encrypt the credentials using the new key
const origCredentialsModel = await app.db.models.StorageCredentials.byProject(sourceInstance.id)
if (origCredentialsModel) {
const origCredentials = JSON.parse(origCredentialsModel.credentials) // .credentials is stored as text in the DB
const origCredentialSecret = await sourceInstance.getSetting('credentialSecret') || sourceSettings._credentialSecret // Legacy
const newCredentials = await app.db.controllers.Project.reEncryptCredentials(origCredentials, origCredentialSecret, newCredentialSecret)
await app.db.models.StorageCredentials.create({
credentials: JSON.stringify(newCredentials),
ProjectId: targetInstance.id
})
}
}
}
await targetInstance.updateSetting('credentialSecret', newCredentialSecret)
const settings = await app.db.models.StorageSettings.create({
settings: JSON.stringify(newSettings),
ProjectId: targetInstance.id
})
await settings.save()

const sourceProjectSettings = await sourceInstance.getSetting(KEY_SETTINGS) || { env: [] }
const sourceProjectEnvVars = sourceProjectSettings.env || []
const newProjectSettings = { ...sourceProjectSettings }
newProjectSettings.env = []

if (options.envVars) {
sourceProjectEnvVars.forEach(envVar => {
newProjectSettings.env.push({
name: envVar.name,
value: options.envVars === 'keys' ? '' : envVar.value
})
})
}
newProjectSettings.header = { title: targetInstance.name }
await targetInstance.updateSetting(KEY_SETTINGS, newProjectSettings)

return targetInstance
},

/**
* Imports settings, flows and credentials from a project export object
*
* @param {*} app
* @param {*} project
Expand Down
2 changes: 1 addition & 1 deletion forge/db/models/Application.js
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ module.exports = {
]
})
},
byTeam: async (teamIdOrHash, { includeInstances = false }) => {
byTeam: async (teamIdOrHash, { includeInstances = false } = {}) => {
let id = teamIdOrHash
if (typeof teamIdOrHash === 'string') {
id = M.Team.decodeHashid(teamIdOrHash)
Expand Down
32 changes: 17 additions & 15 deletions forge/db/models/Project.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,21 @@ const Controllers = require('../controllers')

const { KEY_HOSTNAME, KEY_SETTINGS, KEY_HA } = require('./ProjectSettings')

const BANNED_NAME_LIST = [
'www',
'node-red',
'nodered',
'forge',
'support',
'help',
'accounts',
'account',
'status',
'billing',
'mqtt',
'broker'
]

/** @type {FFModel} */
module.exports = {
name: 'Project',
Expand Down Expand Up @@ -276,6 +291,7 @@ module.exports = {
}
},
static: {
BANNED_NAME_LIST,
isNameUsed: async (name) => {
const safeName = name?.toLowerCase()
const count = await this.count({
Expand All @@ -287,27 +303,13 @@ module.exports = {
return this.findAll({
include: {
model: M.Team,
attributes: ['hashid', 'id', 'name', 'slug', 'links', 'TeamTypeId'],
include: [
{
model: M.Application,
attributes: ['hashid', 'id', 'name', 'links', 'TeamTypeId']
},
{
model: M.TeamMember,
where: {
UserId: user.id
}
},
{
model: M.ProjectType,
attributes: ['hashid', 'id', 'name']
},
{
model: M.ProjectStack
},
{
model: M.ProjectTemplate,
attributes: ['hashid', 'id', 'name', 'links']
}
],
required: true
Expand Down
2 changes: 1 addition & 1 deletion forge/db/models/TeamType.js
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,7 @@ module.exports = {
if (parts.length > 0) {
props = props[k]
} else {
return props[k]
return props[k] ?? defaultValue
}
} else {
return defaultValue
Expand Down
Loading