@flowfuse/flowfuse
Version:
An open source low-code development platform
729 lines (663 loc) • 25.9 kB
JavaScript
const { ValidationError } = require('sequelize')
const { KEY_PROTECTED } = require('../../../db/models/ProjectSettings.js')
const { ControllerError } = require('../../../lib/errors.js')
const { Roles } = require('../../../lib/roles.js')
// Declare getLogger functions to provide type hints / quick code nav / code completion
/** @type {import('../../../../forge/auditLog/team').getLoggers} */
const getTeamLogger = (app) => { return app.auditLog.Team }
module.exports = async function (app) {
const teamLogger = getTeamLogger(app)
app.addHook('preHandler', async (request, reply) => {
if (request.params.pipelineId) {
const pipelineId = request.params.pipelineId
request.pipeline = await app.db.models.Pipeline.byId(pipelineId)
if (!request.pipeline) {
return reply.code(404).send({ code: 'not_found', error: 'Not Found' })
}
}
if (request.params.applicationId || request.body?.applicationId) {
const applicationId = request.params.applicationId || request.body?.applicationId
request.application = await app.db.models.Application.byId(applicationId)
if (!request.application) {
return reply.code(404).send({ code: 'not_found', error: 'Not Found' })
}
if (request.pipeline && request.pipeline.ApplicationId !== request.application.id) {
return reply.code(404).send({ code: 'not_found', error: 'Not Found' })
}
} else if (request.pipeline) {
request.application = await app.db.models.Application.byId(request.pipeline.ApplicationId)
} else {
return reply.code(404).send({ code: 'not_found', error: 'Not Found' })
}
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' })
}
} else {
return reply.code(401).send({ code: 'unauthorized', error: 'Unauthorized' })
}
})
/**
* Create a new Pipeline
* /api/v1/pipelines
*/
app.post('/pipelines', {
preHandler: app.needsPermission('pipeline:create'),
schema: {
summary: 'Create a new pipeline within an application',
tags: ['Pipelines'],
body: {
type: 'object',
properties: {
applicationId: { type: 'string' },
name: { type: 'string' }
}
},
response: {
200: {
$ref: 'Pipeline'
},
'4xx': {
$ref: 'APIError'
},
500: {
$ref: 'APIError'
}
}
}
}, async (request, reply) => {
const team = await request.teamMembership.getTeam()
const name = request.body.name?.trim()
let pipeline
try {
pipeline = await app.db.models.Pipeline.create({
name,
ApplicationId: request.application.id
})
} catch (error) {
if (handleValidationError(error, reply)) {
return
}
app.log.error('Error while creating pipeline:')
app.log.error(error)
return reply.code(500).send({ code: 'unexpected_error', error: error.toString() })
}
await app.auditLog.Team.application.pipeline.created(request.session.User, null, team, request.application, pipeline)
reply.send(await app.db.views.Pipeline.pipeline(pipeline))
})
/**
* Delete a Pipeline
* /api/v1/pipelines/:id
*/
app.delete('/pipelines/:pipelineId', {
preHandler: app.needsPermission('pipeline:delete'),
schema: {
summary: 'Delete a pipeline',
tags: ['Pipelines'],
params: {
type: 'object',
properties: {
pipelineId: { type: 'string' }
}
},
response: {
200: {
$ref: 'APIStatus'
},
'4xx': {
$ref: 'APIError'
}
}
}
}, async (request, reply) => {
const team = await request.teamMembership.getTeam()
const pipeline = request.pipeline
const stages = await pipeline.stages()
if (stages.length > 0) {
// delete stages too
for (let i = 0; i < stages.length; i++) {
await stages[i].destroy()
}
}
await pipeline.destroy()
await app.auditLog.Team.application.pipeline.deleted(request.session.User, null, team, request.application, pipeline)
reply.send({ status: 'okay' })
})
/**
* Update a Pipeline
* /api/v1/pipelines/:id
*/
app.put('/pipelines/:pipelineId', {
preHandler: app.needsPermission('pipeline:edit'),
schema: {
summary: 'Update a pipeline within an application',
tags: ['Pipelines'],
params: {
type: 'object',
properties: {
pipelineId: { type: 'string' }
}
},
body: {
type: 'object',
properties: {
name: { type: 'string' }
}
},
response: {
200: {
$ref: 'Pipeline'
},
'4xx': {
$ref: 'APIError'
},
500: {
$ref: 'APIError'
}
}
}
}, async (request, reply) => {
const updates = new app.auditLog.formatters.UpdatesCollection()
const pipelineId = request.params.pipelineId
const pipeline = await app.db.models.Pipeline.byId(pipelineId)
try {
const reqName = request.body.pipeline.name?.trim()
updates.push('name', pipeline.name, reqName)
pipeline.name = reqName
await pipeline.save()
} catch (error) {
if (error instanceof ValidationError) {
if (error.errors[0]) {
return reply.status(400).type('application/json').send({ code: `invalid_${error.errors[0].path}`, error: error.errors[0].message })
}
return reply.status(400).type('application/json').send({ code: 'invalid_input', error: error.message })
}
app.log.error('Error while updating pipeline:')
app.log.error(error)
return reply.code(500).send({ code: 'unexpected_error', error: error.toString() })
}
const team = request.application.Team
if (team) {
await app.auditLog.Team.application.pipeline.updated(request.session.User, null, team, request.application, pipeline, updates)
}
reply.send(pipeline)
})
/**
* Get details of a single stage within a pipeline
* @name /api/v1/pipelines/:pipelineId/stages/:stageId
* @memberof forge.routes.api.pipeline
*/
app.get('/pipelines/:pipelineId/stages/:stageId', {
preHandler: app.needsPermission('pipeline:read')
}, async (request, reply) => {
const stage = await app.db.models.PipelineStage.byId(request.params.stageId)
if (!stage) {
return reply.code(404).send({ code: 'not_found', error: 'Not Found' })
}
if (stage.PipelineId !== request.pipeline.id) {
return reply.code(404).send({ code: 'not_found', error: 'Not Found' })
}
reply.send(await app.db.views.PipelineStage.stage(stage))
})
/**
* Add a new stage to an existing Pipeline
* @name /api/v1/pipelines/:pipelineId/stages
* @memberof forge.routes.api.pipeline
*/
app.post('/pipelines/:pipelineId/stages', {
preHandler: app.needsPermission('pipeline:edit'),
schema: {
summary: 'Add a new stage to an existing pipeline',
tags: ['Pipelines'],
params: {
type: 'object',
properties: {
pipelineId: { type: 'string' }
}
},
body: {
type: 'object',
properties: {
name: { type: 'string' },
instanceId: { type: 'string' },
deviceId: { type: 'string' },
deviceGroupId: { type: 'string' },
deployToDevices: { type: 'boolean' },
action: { type: 'string', enum: Object.values(app.db.models.PipelineStage.SNAPSHOT_ACTIONS) },
gitTokenId: { type: 'string' },
url: { type: 'string' },
branch: { type: 'string' },
credentialSecret: { type: 'string' },
source: { type: 'string' }
}
},
response: {
200: {
$ref: 'PipelineStage'
},
'4xx': {
$ref: 'APIError'
}
}
}
}, async (request, reply) => {
const team = await request.teamMembership.getTeam()
const name = request.body.name?.trim() // name of the stage
const {
instanceId,
deviceId,
deviceGroupId,
deployToDevices,
action,
// Git Repo options
gitTokenId,
url,
branch,
credentialSecret
} = request.body
let stage
try {
const options = {
name,
instanceId,
deviceId,
deviceGroupId,
deployToDevices,
action,
gitTokenId,
url,
branch,
credentialSecret
}
if (request.body.source) {
options.source = request.body.source
}
stage = await app.db.controllers.Pipeline.addPipelineStage(
request.pipeline,
options
)
} catch (error) {
if (handleValidationError(error, reply)) {
return
}
if (handleControllerError(error, reply)) {
return
}
app.log.error('Error while creating pipeline stage:')
app.log.error(error)
return reply.status(500).send({ code: 'unexpected_error', error: error.toString() })
}
await app.auditLog.Team.application.pipeline.stageAdded(request.session.User, null, team, request.application, request.pipeline, stage)
if (instanceId) {
const instance = await app.db.models.Project.byId(instanceId)
await app.auditLog.Project.project.assignedToPipelineStage(request.session.User, null, instance, request.pipeline, stage)
}
// PipelineStage.byId includes devices and instance objects
const hydratedStage = await app.db.models.PipelineStage.byId(stage.id)
reply.send(await app.db.views.PipelineStage.stage(hydratedStage))
})
/**
* Update details of a single stage within a pipeline
* @name /api/v1/pipelines/:pipelineId/stages/:stageId
* @memberof forge.routes.api.pipeline
*/
app.put('/pipelines/:pipelineId/stages/:stageId', {
preHandler: app.needsPermission('pipeline:edit'),
schema: {
summary: 'Update details of a stage within a pipeline',
tags: ['Pipelines'],
params: {
type: 'object',
properties: {
pipelineId: { type: 'string' },
stageId: { type: 'string' }
}
},
body: {
type: 'object',
properties: {
name: { type: 'string' },
instanceId: { type: 'string' },
deviceId: { type: 'string' },
deviceGroupId: { type: 'string' },
action: { type: 'string', enum: Object.values(app.db.models.PipelineStage.SNAPSHOT_ACTIONS) },
gitTokenId: { type: 'string' },
url: { type: 'string' },
branch: { type: 'string' },
credentialSecret: { type: 'string' },
source: { type: 'string' }
}
},
response: {
200: {
$ref: 'PipelineStage'
},
'4xx': {
$ref: 'APIError'
}
}
}
}, async (request, reply) => {
try {
const options = {
name: request.body.name,
instanceId: request.body.instanceId,
deployToDevices: request.body.deployToDevices,
action: request.body.action,
deviceId: request.body.deviceId,
deviceGroupId: request.body.deviceGroupId,
gitTokenId: request.body.gitTokenId,
url: request.body.url,
branch: request.body.branch,
credentialSecret: request.body.credentialSecret
}
const stage = await app.db.controllers.Pipeline.updatePipelineStage(
request.pipeline,
request.params.stageId,
options
)
reply.send(await app.db.views.PipelineStage.stage(stage))
} catch (error) {
if (error instanceof ValidationError) {
if (error.errors[0]) {
return reply.status(400).type('application/json').send({ code: `invalid_${error.errors[0].path}`, error: error.errors[0].message })
}
return reply.status(400).type('application/json').send({ code: 'invalid_input', error: error.message })
}
if (handleControllerError(error, reply)) {
return
}
app.log.error('Error while updating pipeline stage:')
app.log.error(error)
return reply.code(500).send({ code: 'unexpected_error', error: error.toString() })
}
})
/**
* Delete a pipeline stage
* @name /api/v1/pipelines/:pipelineId/stages/:stageId
* @memberof forge.routes.api.pipeline
*/
app.delete('/pipelines/:pipelineId/stages/:stageId', {
preHandler: app.needsPermission('pipeline:delete'),
schema: {
summary: 'Delete a pipeline stage',
tags: ['Pipelines'],
params: {
type: 'object',
properties: {
pipelineId: { type: 'string' },
stageId: { type: 'string' }
}
},
response: {
200: {
$ref: 'APIStatus'
},
'4xx': {
$ref: 'APIError'
},
500: {
$ref: 'APIError'
}
}
}
}, async (request, reply) => {
try {
const stageId = request.params.stageId
const stage = await app.db.models.PipelineStage.byId(stageId)
if (!stage) {
return reply.code(404).send({ code: 'not_found', error: 'Not Found' })
}
if (stage.PipelineId !== request.pipeline.id) {
return reply.code(404).send({ code: 'not_found', error: 'Not Found' })
}
// Update the previous stage to point to the next stage when this model is deleted
// e.g. A -> B -> C to A -> C when B is deleted
const previousStage = await app.db.models.PipelineStage.byNextStage(stageId)
if (previousStage) {
if (stage.NextStageId) {
previousStage.NextStageId = stage.NextStageId
} else {
previousStage.NextStageId = null
}
await previousStage.save()
}
await stage.destroy()
// TODO - Audit log entry?
reply.send({ status: 'okay' })
} catch (err) {
reply.code(500).send({ code: 'unexpected_error', error: err.toString() })
}
})
/**
* Deploy one stage to another stage
* Approach depends on stage.action
*/
app.put('/pipelines/:pipelineId/stages/:stageId/deploy', {
preHandler: app.needsPermission('pipeline:edit'),
schema: {
summary: 'Triggers a pipeline stage',
tags: ['Pipelines'],
params: {
type: 'object',
properties: {
pipelineId: { type: 'string' },
stageId: { type: 'string' }
}
},
body: {
type: ['object', 'null'],
properties: {
sourceSnapshotId: { type: 'string', description: 'The snapshot to deploy if the stage action is set to "prompt"' }
}
},
response: {
200: {
$ref: 'APIStatus'
},
'4xx': {
$ref: 'APIError'
},
500: {
$ref: 'APIError'
}
}
}
}, async (request, reply) => {
const user = request.session.User
const teamMembership = request.teamMembership
let repliedEarly = false
let sourceDeployed, deployTarget
try {
const sourceStage = await app.db.models.PipelineStage.byId(
request.params.stageId
)
if (sourceStage?.action === app.db.models.PipelineStage.SNAPSHOT_ACTIONS.NONE) {
// Nothing to do
reply.code(200).send({ status: 'okay' })
return
}
const {
sourceInstance,
targetInstance,
sourceDevice,
targetDevice,
sourceDeviceGroup,
targetDeviceGroup,
targetGitRepo,
targetStage
} = await app.db.controllers.Pipeline.validateSourceStageForDeploy(
request.pipeline,
sourceStage
)
// Only Owners can trigger a pipeline to a protected instance
// Test before source snapshot is taken
if (targetInstance) {
const protectedInstance = await targetInstance.getSetting(KEY_PROTECTED)
if (protectedInstance?.enabled && teamMembership.role !== Roles.Owner) {
// reject
reply.code(403).send({ code: 'protected_instance', error: 'Only Owner can Deploy to target instance' })
repliedEarly = true
return
}
}
let sourceSnapshot
if (sourceInstance) {
sourceSnapshot = await app.db.controllers.Pipeline.getOrCreateSnapshotForSourceInstance(
sourceStage,
sourceInstance,
request.body?.sourceSnapshotId,
{
pipeline: request.pipeline,
user,
targetStage
}
)
sourceDeployed = sourceInstance
} else if (sourceDevice) {
sourceSnapshot = await app.db.controllers.Pipeline.getOrCreateSnapshotForSourceDevice(
sourceStage,
sourceDevice,
request.body?.sourceSnapshotId
)
sourceDeployed = sourceDevice
} else if (sourceDeviceGroup) {
sourceSnapshot = await app.db.controllers.Pipeline.getSnapshotForSourceDeviceGroup(sourceDeviceGroup)
sourceDeployed = sourceDeviceGroup
} else {
throw new Error('No source device or instance found.')
}
if (targetInstance) {
const deployPromise = app.db.controllers.Pipeline.deploySnapshotToInstance(
sourceSnapshot,
targetInstance,
targetStage.deployToDevices ?? false,
{
pipeline: request.pipeline,
sourceStage,
sourceInstance,
sourceDevice,
targetStage,
user
}
)
reply.code(200).send({ status: 'importing' })
repliedEarly = true
deployTarget = targetInstance
await deployPromise
} else if (targetDevice) {
const deployPromise = app.db.controllers.Pipeline.deploySnapshotToDevice(
sourceSnapshot,
targetDevice,
request.pipeline,
{
user
})
reply.code(200).send({ status: 'importing' })
repliedEarly = true
deployTarget = targetDevice
await deployPromise
} else if (targetDeviceGroup) {
const deployPromise = app.db.controllers.Pipeline.deploySnapshotToDeviceGroup(
sourceSnapshot,
targetDeviceGroup,
{
user
})
reply.code(200).send({ status: 'importing' })
repliedEarly = true
deployTarget = targetDeviceGroup
await deployPromise
} else if (targetGitRepo) {
const options = {
sourceObject: sourceDeployed,
user: request.session.User,
pipeline: request.pipeline
}
await targetGitRepo.deploy(sourceSnapshot, options)
} else {
throw new Error('No target device or instance found.')
}
await teamLogger.application.pipeline.stageDeployed(request.session.User, null, request.application.Team, request.application, request.pipeline, sourceDeployed, deployTarget)
} catch (err) {
if (repliedEarly) {
console.warn('Deploy failed, but response already sent', err)
} else {
if (err instanceof ControllerError) {
return reply
.code(err.statusCode || 400)
.send({
code: err.code || 'unexpected_error',
error: err.error || err.message
})
}
return reply.code(err.statusCode || 500).send({
code: err.code || 'unexpected_error',
error: err.error || err.message
})
}
}
})
/**
* List all pipelines within an Application
* /api/v1/applications/:id/pipelines
*/
app.get('/applications/:applicationId/pipelines', {
preHandler: app.needsPermission('application:pipeline:list'),
schema: {
summary: 'List all pipelines within an application',
tags: ['Pipelines'],
params: {
type: 'object',
properties: {
applicationId: { type: 'string' }
}
},
response: {
200: {
type: 'object',
properties: {
count: { type: 'number' },
pipelines: { $ref: 'PipelineList' }
}
},
'4xx': {
$ref: 'APIError'
}
}
}
}, async (request, reply) => {
const pipelines = await app.db.models.Pipeline.byApplicationId(request.application.hashid)
if (pipelines) {
reply.send({
count: pipelines.length,
pipelines: await app.db.views.Pipeline.pipelineList(pipelines)
})
} else {
reply.code(404).send({ code: 'not_found', error: 'Not Found' })
}
})
function handleValidationError (error, reply) {
if (error instanceof ValidationError) {
if (error.errors[0]) {
return reply.status(400).type('application/json').send({ code: `invalid_${error.errors[0].path}`, error: error.errors[0].message })
}
reply.status(400).type('application/json').send({ code: 'invalid_input', error: error.message })
return true // handled
}
return false // not handled
}
function handleControllerError (error, reply) {
if (error instanceof ControllerError) {
reply
.code(error.statusCode || 400)
.send({
code: error.code || 'unexpected_error',
error: error.error || error.message
})
return true // handled
}
return false // not handled
}
}