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

HA: multiple instance replica support #2180

Merged
merged 13 commits into from
Jun 2, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 8 additions & 3 deletions forge/db/models/Application.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@
* An application definition
* @namespace forge.db.models.Application
*/
const { DataTypes } = require('sequelize')
const { DataTypes, Op } = require('sequelize')

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

module.exports = {
name: 'Application',
Expand Down Expand Up @@ -60,7 +60,12 @@ module.exports = {
attributes: ['hashid', 'id', 'name', 'links', 'settings', 'policy']
}, {
model: M.ProjectSettings,
where: { key: KEY_SETTINGS },
where: {
[Op.or]: [
{ key: KEY_SETTINGS },
{ key: KEY_HA }
]
},
required: false
}
]
Expand Down
18 changes: 13 additions & 5 deletions forge/db/models/Project.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
const { DataTypes, Op } = require('sequelize')
const Controllers = require('../controllers')

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

/** @type {FFModel} */
module.exports = {
Expand Down Expand Up @@ -207,7 +207,7 @@ module.exports = {
if (key === 'settings' && value && Array.isArray(value.env)) {
value.env = Controllers.Project.removePlatformSpecificEnvVars(value.env) // remove platform specific values
}
return await M.ProjectSettings.upsert({ ProjectId: this.id, key, value }, options)
return M.ProjectSettings.upsert({ ProjectId: this.id, key, value }, options)
},
async getSetting (key) {
const result = await M.ProjectSettings.findOne({ where: { ProjectId: this.id, key } })
Expand All @@ -220,7 +220,9 @@ module.exports = {
}
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
Expand Down Expand Up @@ -335,7 +337,8 @@ module.exports = {
where: {
[Op.or]: [
{ key: KEY_SETTINGS },
{ key: KEY_HOSTNAME }
{ key: KEY_HOSTNAME },
{ key: KEY_HA }
]
},
required: false
Expand Down Expand Up @@ -371,7 +374,12 @@ module.exports = {

include.push({
model: M.ProjectSettings,
where: { key: KEY_SETTINGS },
where: {
[Op.or]: [
{ key: KEY_SETTINGS },
{ key: KEY_HA }
]
},
required: false
})
}
Expand Down
2 changes: 2 additions & 0 deletions forge/db/models/ProjectSettings.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ const SettingTypes = {
const KEY_SETTINGS = 'settings'
const KEY_HOSTNAME = 'hostname'
const KEY_BILLING_STATE = 'billingState'
const KEY_HA = 'ha'

const BILLING_STATES = {
UNKNOWN: undefined,
Expand All @@ -25,6 +26,7 @@ module.exports = {
KEY_SETTINGS,
KEY_HOSTNAME,
KEY_BILLING_STATE,
KEY_HA,
name: 'ProjectSettings',
schema: {
ProjectId: { type: DataTypes.UUID, unique: 'pk_settings' },
Expand Down
6 changes: 5 additions & 1 deletion forge/db/views/Project.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
const { KEY_HOSTNAME, KEY_SETTINGS } = require('../models/ProjectSettings')
const { KEY_HOSTNAME, KEY_SETTINGS, KEY_HA } = require('../models/ProjectSettings')

module.exports = {
project: async function (app, project, { includeSettings = true } = {}) {
Expand Down Expand Up @@ -35,6 +35,10 @@ module.exports = {
const settingsHostnameRow = proj.ProjectSettings?.find((projectSettingsRow) => projectSettingsRow.key === KEY_HOSTNAME)
result.hostname = settingsHostnameRow?.value || ''
}
if (app.config.features.enabled('ha')) {
const settingsHARow = proj.ProjectSettings?.find(row => row.key === KEY_HA)
result.ha = settingsHARow?.value || { disabled: true }
}

if (proj.Application) {
result.application = app.db.views.Application.applicationSummary(proj.Application)
Expand Down
41 changes: 41 additions & 0 deletions forge/ee/lib/ha/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
const { KEY_HA } = require('../../../db/models/ProjectSettings')

module.exports.init = function (app) {
if (app.config.driver.type === 'k8s' || app.config.driver.type === 'stub') {
// Register ha flag as a private flag - no requirement to expose it in public settings
app.config.features.register('ha', true)

/**
* Check if HA is allowed for this given team/projectType/haConfig combination
* @param {*} team
* @param {*} projectType
* @param {*} haConfig
* @returns true/false
*/
async function isHAAllowed (team, projectType, haConfig) {
// For initial beta release, we will support 1-2 replicas.
// 1 replica is equivalent to no HA
// In the future this will need to take into account the team type
return (haConfig.replicas > 0 && haConfig.replicas < 3)
}

// Add ha functions to the Project model
app.db.models.Project.prototype.getHASettings = async function () {
return this.getSetting(KEY_HA)
}
app.db.models.Project.prototype.updateHASettings = async function (haConfig) {
if (!haConfig) {
return this.removeSetting(KEY_HA)
}
if (haConfig?.replicas > 0 && haConfig?.replicas < 3) {
return this.updateSetting(KEY_HA, {
replicas: haConfig.replicas
})
}
}

app.decorate('ha', {
isHAAllowed
})
}
}
1 change: 1 addition & 0 deletions forge/ee/lib/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ module.exports = fp(async function (app, opts, done) {
}
require('./projectComms').init(app)
require('./deviceEditor').init(app)
require('./ha').init(app)

app.decorate('sso', await require('./sso').init(app))

Expand Down
107 changes: 107 additions & 0 deletions forge/ee/routes/ha/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
module.exports = async function (app) {
app.addHook('preHandler', app.verifySession)
app.addHook('preHandler', async (request, reply) => {
if (!app.config.features.enabled('ha')) {
reply.code(404).send({ code: 'not_found', error: 'Not Found' })
}
})
app.addHook('preHandler', async (request, reply) => {
if (request.params.projectId !== undefined) {
if (request.params.projectId) {
try {
request.project = await app.db.models.Project.byId(request.params.projectId)
if (!request.project) {
reply.code(404).send({ code: 'not_found', error: 'Not Found' })
return
}
if (request.session.User) {
request.teamMembership = await request.session.User.getTeamMembership(request.project.Team.id)
if (!request.teamMembership && !request.session.User.admin) {
reply.code(404).send({ code: 'not_found', error: 'Not Found' })
return
}
} else if (request.session.ownerId !== request.params.projectId) {
// AccesToken being used - but not owned by this project
reply.code(404).send({ code: 'not_found', error: 'Not Found' })
return
}
} catch (err) {
reply.code(404).send({ code: 'not_found', error: 'Not Found' })
}
} else {
reply.code(404).send({ code: 'not_found', error: 'Not Found' })
}
}
})

app.get('/', {
preHandler: app.needsPermission('project:read')
}, async (request, reply) => {
reply.send(await request.project.getHASettings() || {})
})

app.put('/', {
preHandler: app.needsPermission('project:edit')
}, async (request, reply) => {
// For 1.8, only support setting replica count to 2
if (request.body.replicas !== 2) {
reply.code(409).send({ code: 'invalid_ha_configuration', error: 'Invalid HA configuration -only 2 replicas are allowed' })
return
}
const existingHA = await request.project.getHASettings() || {}
if (existingHA.replicas !== request.body.replicas) {
// This is a change in replica count.
await request.project.updateHASettings({ replicas: request.body.replicas })
await applyUpdatedInstanceSettings(reply, request.project, request.session.User)
} else {
reply.send(await request.project.getHASettings() || {})
}
})

app.delete('/', {
preHandler: app.needsPermission('project:edit')
}, async (request, reply) => {
const existingHA = await request.project.getHASettings() || {}
if (existingHA.replicas) {
// This instance already has ha configured - clear the setting
// and apply
await request.project.updateHASettings(undefined)
await applyUpdatedInstanceSettings(reply, request.project, request.session.User)
} else {
reply.send({})
}
})

async function applyUpdatedInstanceSettings (reply, project, user) {
if (project.state !== 'suspended') {
// This code is copy/paste with slight changes from projects.js
// We also have projectActions.js that does suspend logic.
// TODO: refactor into a Model function to suspend a project
app.db.controllers.Project.setInflightState(project, 'starting') // TODO: better inflight state needed
reply.send(await project.getHASettings() || {})

const targetState = project.state
app.log.info(`Stopping project ${project.id}`)
await app.containers.stop(project, {
skipBilling: true
})
await app.auditLog.Project.project.suspended(user, null, project)
app.log.info(`Restarting project ${project.id}`)
project.state = targetState
await project.save()
await project.reload()
const startResult = await app.containers.start(project)
startResult.started.then(async () => {
await app.auditLog.Project.project.started(user, null, project)
app.db.controllers.Project.clearInflightState(project)
return true
}).catch(_ => {
app.db.controllers.Project.clearInflightState(project)
})
} else {
// A suspended project doesn't need to do anything more
// The settings will get applied when it is next resumed
reply.send(await project.getHASettings() || {})
}
}
}
2 changes: 2 additions & 0 deletions forge/ee/routes/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ module.exports = async function (app) {
await app.register(require('./pipeline'), { prefix: '/api/v1', logLevel: app.config.logging.http })
await app.register(require('./deviceEditor'), { prefix: '/api/v1/devices/:deviceId/editor', logLevel: app.config.logging.http })

await app.register(require('./ha'), { prefix: '/api/v1/projects/:projectId/ha', logLevel: app.config.logging.http })

// Important: keep SSO last to avoid its error handling polluting other routes.
await app.register(require('./sso'), { logLevel: app.config.logging.http })
}
30 changes: 28 additions & 2 deletions forge/routes/api/project.js
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,13 @@ module.exports = async function (app) {
return
}

if (app.license.active() && app.ha) {
if (request.body.ha && !await app.ha.isHAAllowed(team, projectType, request.body.ha)) {
reply.code(400).send({ code: 'invalid_ha', error: 'Invalid HA configuration' })
return
}
}

let project
try {
project = await app.db.models.Project.create({
Expand All @@ -212,15 +219,20 @@ module.exports = async function (app) {
await project.setProjectStack(stack)
await project.setProjectTemplate(template)
await project.setProjectType(projectType)

if (app.license.active() && app.ha && request.body.ha) {
await project.updateHASettings(request.body.ha)
}

await project.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.ProjectTemplate },
{ model: app.db.models.ProjectSettings }
]
})

if (app.license.active() && app.billing) {
await app.billing.initialiseProjectBillingState(team, project)
}
Expand Down Expand Up @@ -786,6 +798,14 @@ module.exports = async function (app) {
settings.env = Object.assign({}, settings.settings.env, settings.env)
delete settings.settings.env
}

if (app.config.features.enabled('ha')) {
const ha = await request.project.getHASettings()
if (ha && ha.replicas > 1) {
settings.ha = ha
}
}

reply.send(settings)
})

Expand Down Expand Up @@ -915,4 +935,10 @@ module.exports = async function (app) {
function generateCredentialSecret () {
return crypto.randomBytes(32).toString('hex')
}

// app.get('/:projectId/ha', {
// preHandler: app.needsPermission('project:read')
// }, async (request, reply) => {
// reply.send(await request.project.getHASettings())
// })
}
13 changes: 12 additions & 1 deletion frontend/src/api/instances.js
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,15 @@ const rollbackInstance = async (instanceId, snapshotId) => {
}
return client.post(`/api/v1/projects/${instanceId}/actions/rollback`, data).then(res => res.data)
}

const enableHAMode = async (instanceId) => {
const haConfig = { replicas: 2 }
return client.put(`/api/v1/projects/${instanceId}/ha`, haConfig)
}
const disableHAMode = async (instanceId) => {
return client.delete(`/api/v1/projects/${instanceId}/ha`)
}

export default {
create,
getInstance,
Expand All @@ -151,5 +160,7 @@ export default {
getInstanceDevices,
getInstanceDeviceSettings,
updateInstanceDeviceSettings,
rollbackInstance
rollbackInstance,
enableHAMode,
disableHAMode
}
2 changes: 1 addition & 1 deletion frontend/src/components/StatusBadge.vue
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ import {
} from '@heroicons/vue/outline'

export default {
name: 'InstanceStatusBadge',
name: 'StatusBadge',
components: {
CloudDownloadIcon,
CloudUploadIcon,
Expand Down
4 changes: 2 additions & 2 deletions frontend/src/pages/application/Overview.vue
Original file line number Diff line number Diff line change
Expand Up @@ -141,8 +141,8 @@ export default {
return this.instances.map((instance) => {
instance.running = instance.meta?.state === 'running'
instance.notSuspended = instance.meta?.state !== 'suspended'

instance.disabled = !instance.running || this.isVisitingAdmin
instance.isHA = instance.ha?.replicas !== undefined
instance.disabled = !instance.running || this.isVisitingAdmin || instance.isHA

return instance
})
Expand Down
4 changes: 4 additions & 0 deletions frontend/src/pages/application/createInstance.vue
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,10 @@ export default {
options: { ...copyParts }
}
}
if (this.features.ha && createPayload.isHA) {
createPayload.ha = { replicas: 2 }
}
delete createPayload.isHA

return instanceApi.create(createPayload)
}
Expand Down
Loading
Loading