From 9e276cecca204d6f76ff32eece268c85a586cb32 Mon Sep 17 00:00:00 2001 From: Nick O'Leary Date: Wed, 24 May 2023 15:12:02 +0100 Subject: [PATCH 1/9] Add ha feature flag --- forge/ee/lib/ha/index.js | 6 ++++++ forge/ee/lib/index.js | 1 + 2 files changed, 7 insertions(+) create mode 100644 forge/ee/lib/ha/index.js diff --git a/forge/ee/lib/ha/index.js b/forge/ee/lib/ha/index.js new file mode 100644 index 0000000000..d52f1af8c6 --- /dev/null +++ b/forge/ee/lib/ha/index.js @@ -0,0 +1,6 @@ +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) + } +} diff --git a/forge/ee/lib/index.js b/forge/ee/lib/index.js index 11d4c6f790..bf62373beb 100644 --- a/forge/ee/lib/index.js +++ b/forge/ee/lib/index.js @@ -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)) From 789e52162058794fddcae0f34cd9921b68eb90c0 Mon Sep 17 00:00:00 2001 From: Nick O'Leary Date: Fri, 26 May 2023 13:22:36 +0100 Subject: [PATCH 2/9] Add ha option to project create endpoint and view --- forge/db/models/Project.js | 5 +- forge/db/models/ProjectSettings.js | 2 + forge/db/views/Project.js | 6 +- forge/ee/lib/ha/index.js | 32 +++++++++ forge/routes/api/project.js | 16 ++++- test/unit/forge/routes/api/project_spec.js | 82 ++++++++++++++++++++-- 6 files changed, 134 insertions(+), 9 deletions(-) diff --git a/forge/db/models/Project.js b/forge/db/models/Project.js index 2c467598db..2d0a218f7c 100644 --- a/forge/db/models/Project.js +++ b/forge/db/models/Project.js @@ -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 = { @@ -335,7 +335,8 @@ module.exports = { where: { [Op.or]: [ { key: KEY_SETTINGS }, - { key: KEY_HOSTNAME } + { key: KEY_HOSTNAME }, + { key: KEY_HA } ] }, required: false diff --git a/forge/db/models/ProjectSettings.js b/forge/db/models/ProjectSettings.js index 01308a881a..07d2d5eb0a 100644 --- a/forge/db/models/ProjectSettings.js +++ b/forge/db/models/ProjectSettings.js @@ -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, @@ -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' }, diff --git a/forge/db/views/Project.js b/forge/db/views/Project.js index 6224790468..f185472a1a 100644 --- a/forge/db/views/Project.js +++ b/forge/db/views/Project.js @@ -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 } = {}) { @@ -35,6 +35,10 @@ module.exports = { const settingsHostnameRow = proj.ProjectSettings?.find((projectSettingsRow) => projectSettingsRow.key === KEY_HOSTNAME) result.hostname = settingsHostnameRow?.value || '' } + const settingsHARow = proj.ProjectSettings?.find(row => row.key === KEY_HA) + if (settingsHARow) { + result.ha = settingsHARow.value + } if (proj.Application) { result.application = app.db.views.Application.applicationSummary(proj.Application) diff --git a/forge/ee/lib/ha/index.js b/forge/ee/lib/ha/index.js index d52f1af8c6..99377f1ef2 100644 --- a/forge/ee/lib/ha/index.js +++ b/forge/ee/lib/ha/index.js @@ -1,6 +1,38 @@ +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?.replicas > 0 && haConfig?.replicas < 3) { + await this.updateSetting(KEY_HA, { + replicas: haConfig.replicas + }) + } + } + + app.decorate('ha', { + isHAAllowed + }) } } diff --git a/forge/routes/api/project.js b/forge/routes/api/project.js index 40be75dbb4..f5c50f10c7 100644 --- a/forge/routes/api/project.js +++ b/forge/routes/api/project.js @@ -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({ @@ -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) } diff --git a/test/unit/forge/routes/api/project_spec.js b/test/unit/forge/routes/api/project_spec.js index f00fe0e79b..a4b2893525 100644 --- a/test/unit/forge/routes/api/project_spec.js +++ b/test/unit/forge/routes/api/project_spec.js @@ -28,10 +28,12 @@ describe('Project API', function () { const generateProjectName = () => 'test-project' + (projectInstanceCount++) const TestObjects = {} - before(async function () { - // Allow individual tests to provide custom settings via 'setup' property - // set on the test case itself - app = await setup({ domain: 'flowforge.dev' }) + async function setupApp (license) { + const setupConfig = { domain: 'flowforge.dev' } + if (license) { + setupConfig.license = license + } + app = await setup(setupConfig) TestObjects.project1 = app.project @@ -74,6 +76,10 @@ describe('Project API', function () { TestObjects.projectType1 = app.projectType TestObjects.template1 = app.template TestObjects.stack1 = app.stack + } + + before(async function () { + await setupApp() }) after(async function () { @@ -778,6 +784,74 @@ describe('Project API', function () { response.statusCode.should.equal(400) }) }) + + describe('HA Options', function () { + before(async function () { + const license = 'eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJGbG93Rm9yZ2UgSW5jLiIsInN1YiI6IkZsb3dGb3JnZSBJbmMuIERldmVsb3BtZW50IiwibmJmIjoxNjYyNTk1MjAwLCJleHAiOjc5ODcwNzUxOTksIm5vdGUiOiJEZXZlbG9wbWVudC1tb2RlIE9ubHkuIE5vdCBmb3IgcHJvZHVjdGlvbiIsInVzZXJzIjoxNTAsInRlYW1zIjo1MCwicHJvamVjdHMiOjUwLCJkZXZpY2VzIjoyLCJkZXYiOnRydWUsImlhdCI6MTY2MjY1MzkyMX0.Tj4fnuDuxi_o5JYltmVi1Xj-BRn0aEjwRPa_fL2MYa9MzSwnvJEd-8bsRM38BQpChjLt-wN-2J21U7oSq2Fp5A' + await app.close() + await setupApp(license) + }) + after(async function () { + // After this set of tests, close the app and recreate (ie remove the license) + await app.close() + await setupApp() + }) + it('Fails for invalid ha settings', async function () { + const response = await app.inject({ + method: 'POST', + url: '/api/v1/projects', + payload: { + name: generateProjectName(), + applicationId: TestObjects.ApplicationA.hashid, + projectType: TestObjects.projectType1.hashid, + template: TestObjects.template1.hashid, + stack: TestObjects.stack1.hashid, + ha: { replicas: 0 } + }, + cookies: { sid: TestObjects.tokens.alice } + }) + response.statusCode.should.equal(400) + const result = response.json() + result.should.have.property('code', 'invalid_ha') + + const response2 = await app.inject({ + method: 'POST', + url: '/api/v1/projects', + payload: { + name: generateProjectName(), + applicationId: TestObjects.ApplicationA.hashid, + projectType: TestObjects.projectType1.hashid, + template: TestObjects.template1.hashid, + stack: TestObjects.stack1.hashid, + ha: { replicas: 3 } + }, + cookies: { sid: TestObjects.tokens.alice } + }) + response2.statusCode.should.equal(400) + const result2 = response2.json() + result2.should.have.property('code', 'invalid_ha') + }) + + it('Creates project with ha settings applied', async function () { + const response = await app.inject({ + method: 'POST', + url: '/api/v1/projects', + payload: { + name: generateProjectName(), + applicationId: TestObjects.ApplicationA.hashid, + projectType: TestObjects.projectType1.hashid, + template: TestObjects.template1.hashid, + stack: TestObjects.stack1.hashid, + ha: { replicas: 2 } + }, + cookies: { sid: TestObjects.tokens.alice } + }) + response.statusCode.should.equal(200) + const result = response.json() + result.should.have.property('ha') + result.ha.should.have.property('replicas', 2) + }) + }) }) describe('Update Project', function () { From d8d9dc7a72b1141063be1770fcdfb4e81ff137f7 Mon Sep 17 00:00:00 2001 From: Nick O'Leary Date: Wed, 31 May 2023 17:39:25 +0100 Subject: [PATCH 3/9] Add endpoint for setting ha configuration on an instance --- forge/db/models/Application.js | 11 +- forge/db/models/Project.js | 13 +- forge/ee/lib/ha/index.js | 5 +- forge/ee/routes/ha/index.js | 107 ++++++++++++++++ forge/ee/routes/index.js | 2 + forge/routes/api/project.js | 6 + test/unit/forge/ee/routes/ha/index.js | 171 ++++++++++++++++++++++++++ 7 files changed, 308 insertions(+), 7 deletions(-) create mode 100644 forge/ee/routes/ha/index.js create mode 100644 test/unit/forge/ee/routes/ha/index.js diff --git a/forge/db/models/Application.js b/forge/db/models/Application.js index 6f1a430020..7755046962 100644 --- a/forge/db/models/Application.js +++ b/forge/db/models/Application.js @@ -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', @@ -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 } ] diff --git a/forge/db/models/Project.js b/forge/db/models/Project.js index 2d0a218f7c..17fe2fe756 100644 --- a/forge/db/models/Project.js +++ b/forge/db/models/Project.js @@ -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 } }) @@ -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 @@ -372,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 }) } diff --git a/forge/ee/lib/ha/index.js b/forge/ee/lib/ha/index.js index 99377f1ef2..fba33b75ac 100644 --- a/forge/ee/lib/ha/index.js +++ b/forge/ee/lib/ha/index.js @@ -24,8 +24,11 @@ module.exports.init = function (app) { 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) { - await this.updateSetting(KEY_HA, { + return this.updateSetting(KEY_HA, { replicas: haConfig.replicas }) } diff --git a/forge/ee/routes/ha/index.js b/forge/ee/routes/ha/index.js new file mode 100644 index 0000000000..78b562e511 --- /dev/null +++ b/forge/ee/routes/ha/index.js @@ -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() || {}) + } + } +} diff --git a/forge/ee/routes/index.js b/forge/ee/routes/index.js index 985f22289a..06109e679e 100644 --- a/forge/ee/routes/index.js +++ b/forge/ee/routes/index.js @@ -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 }) } diff --git a/forge/routes/api/project.js b/forge/routes/api/project.js index f5c50f10c7..896f5966e9 100644 --- a/forge/routes/api/project.js +++ b/forge/routes/api/project.js @@ -927,4 +927,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()) + // }) } diff --git a/test/unit/forge/ee/routes/ha/index.js b/test/unit/forge/ee/routes/ha/index.js new file mode 100644 index 0000000000..1f410ea9c2 --- /dev/null +++ b/test/unit/forge/ee/routes/ha/index.js @@ -0,0 +1,171 @@ +const sleep = require('util').promisify(setTimeout) + +const should = require('should') // eslint-disable-line + +const setup = require('../../setup') + +const FF_UTIL = require('flowforge-test-utils') +const { START_DELAY, STOP_DELAY } = FF_UTIL.require('forge/containers/stub/index.js') + +describe('HA Instance API', function () { + let app + const TestObjects = { tokens: {} } + + before(async function () { + app = await setup({ billing: null }) + await login('alice', 'aaPassword') + + const response = await app.inject({ + method: 'POST', + url: '/api/v1/projects', + payload: { + name: 'test-ha-project-1', + applicationId: app.application.hashid, + projectType: app.projectType.hashid, + template: app.template.hashid, + stack: app.stack.hashid + }, + cookies: { sid: TestObjects.tokens.alice } + }) + TestObjects.project = await app.db.models.Project.byId(JSON.parse(response.body).id) + // Ensure the project is started + await sleep(START_DELAY) + }) + + after(async function () { + await app.close() + }) + + async function login (username, password) { + const response = await app.inject({ + method: 'POST', + url: '/account/login', + payload: { username, password, remember: false } + }) + response.cookies.should.have.length(1) + response.cookies[0].should.have.property('name', 'sid') + TestObjects.tokens[username] = response.cookies[0].value + } + + it('get ha options for instance', async function () { + const response = await app.inject({ + method: 'GET', + url: `/api/v1/projects/${TestObjects.project.id}/ha`, + cookies: { sid: TestObjects.tokens.alice } + }) + response.statusCode.should.equal(200) + const result = JSON.parse(response.body) + result.should.be.empty() + }) + + it('rejects invalid ha options', async function () { + const testReplicaCount = async n => { + const response = await app.inject({ + method: 'PUT', + url: `/api/v1/projects/${TestObjects.project.id}/ha`, + payload: { + replicas: n + }, + cookies: { sid: TestObjects.tokens.alice } + }) + response.statusCode.should.equal(409) + const result = JSON.parse(response.body) + result.should.have.property('code', 'invalid_ha_configuration') + } + await testReplicaCount(0) + await testReplicaCount(1) + await testReplicaCount(3) + }) + + it('applies valid ha option', async function () { + const response = await app.inject({ + method: 'PUT', + url: `/api/v1/projects/${TestObjects.project.id}/ha`, + payload: { + replicas: 2 + }, + cookies: { sid: TestObjects.tokens.alice } + }) + response.statusCode.should.equal(200) + const result = JSON.parse(response.body) + result.should.have.property('replicas', 2) + + await sleep(STOP_DELAY) + await TestObjects.project.reload() + + // Project has been stopped but is presented as "starting" + TestObjects.project.state.should.equal('suspended') + app.db.controllers.Project.getInflightState(TestObjects.project).should.equal('starting') + + // Wait for at least start delay as set in stub driver + await sleep(START_DELAY + 100) + + await TestObjects.project.reload({ + include: [ + { model: app.db.models.ProjectType }, + { model: app.db.models.ProjectStack } + ] + }) + + // Project is re-running + TestObjects.project.state.should.equal('running') + should(app.db.controllers.Project.getInflightState(TestObjects.project)).equal(undefined) + + const haSetting = await TestObjects.project.getHASettings() + haSetting.should.have.property('replicas', 2) + }) + + it('removes ha option', async function () { + // NOTE: this continues using the objects from the previous test. + const response = await app.inject({ + method: 'DELETE', + url: `/api/v1/projects/${TestObjects.project.id}/ha`, + cookies: { sid: TestObjects.tokens.alice } + }) + response.statusCode.should.equal(200) + const result = JSON.parse(response.body) + result.should.not.have.property('replicas') + + await sleep(STOP_DELAY) + await TestObjects.project.reload() + + // Project has been stopped but is presented as "starting" + TestObjects.project.state.should.equal('suspended') + app.db.controllers.Project.getInflightState(TestObjects.project).should.equal('starting') + + // Wait for at least start delay as set in stub driver + await sleep(START_DELAY + 100) + + await TestObjects.project.reload({ + include: [ + { model: app.db.models.ProjectType }, + { model: app.db.models.ProjectStack } + ] + }) + + // Project is re-running + TestObjects.project.state.should.equal('running') + should(app.db.controllers.Project.getInflightState(TestObjects.project)).equal(undefined) + + const haSetting = await TestObjects.project.getHASettings() + should.not.exist(haSetting) + }) + + it('removing non-existent ha option no-ops', async function () { + // NOTE: this continues using the objects from the previous test. + const response = await app.inject({ + method: 'DELETE', + url: `/api/v1/projects/${TestObjects.project.id}/ha`, + cookies: { sid: TestObjects.tokens.alice } + }) + response.statusCode.should.equal(200) + const result = JSON.parse(response.body) + result.should.not.have.property('replicas') + + await sleep(STOP_DELAY) + await TestObjects.project.reload() + + TestObjects.project.state.should.equal('running') + should(app.db.controllers.Project.getInflightState(TestObjects.project)).equal(undefined) + }) +}) From 6ad0da71bf819f0e40d958c13acd2e55a5563505 Mon Sep 17 00:00:00 2001 From: Nick O'Leary Date: Thu, 1 Jun 2023 14:20:52 +0100 Subject: [PATCH 4/9] Initial UI support for HA mode --- forge/db/views/Project.js | 6 +- frontend/src/api/instances.js | 13 +- frontend/src/components/StatusBadge.vue | 2 +- frontend/src/pages/application/Overview.vue | 4 +- .../src/pages/application/createInstance.vue | 4 + frontend/src/pages/instance/Overview.vue | 16 ++- .../src/pages/instance/Settings/General.vue | 44 +++++-- .../instance/Settings/HighAvailability.vue | 113 ++++++++++++++++++ .../src/pages/instance/Settings/index.vue | 5 +- .../src/pages/instance/Settings/routes.js | 2 + .../components/cells/InstanceEditorLink.vue | 16 ++- frontend/src/pages/instance/index.vue | 8 +- frontend/src/pages/team/Applications.vue | 20 ++-- 13 files changed, 218 insertions(+), 35 deletions(-) create mode 100644 frontend/src/pages/instance/Settings/HighAvailability.vue diff --git a/forge/db/views/Project.js b/forge/db/views/Project.js index f185472a1a..35d03b6249 100644 --- a/forge/db/views/Project.js +++ b/forge/db/views/Project.js @@ -35,9 +35,9 @@ module.exports = { const settingsHostnameRow = proj.ProjectSettings?.find((projectSettingsRow) => projectSettingsRow.key === KEY_HOSTNAME) result.hostname = settingsHostnameRow?.value || '' } - const settingsHARow = proj.ProjectSettings?.find(row => row.key === KEY_HA) - if (settingsHARow) { - result.ha = settingsHARow.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) { diff --git a/frontend/src/api/instances.js b/frontend/src/api/instances.js index 0c4b160fca..b9c4542438 100644 --- a/frontend/src/api/instances.js +++ b/frontend/src/api/instances.js @@ -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, @@ -151,5 +160,7 @@ export default { getInstanceDevices, getInstanceDeviceSettings, updateInstanceDeviceSettings, - rollbackInstance + rollbackInstance, + enableHAMode, + disableHAMode } diff --git a/frontend/src/components/StatusBadge.vue b/frontend/src/components/StatusBadge.vue index 03b7f89526..e8de886335 100644 --- a/frontend/src/components/StatusBadge.vue +++ b/frontend/src/components/StatusBadge.vue @@ -34,7 +34,7 @@ import { } from '@heroicons/vue/outline' export default { - name: 'InstanceStatusBadge', + name: 'StatusBadge', components: { CloudDownloadIcon, CloudUploadIcon, diff --git a/frontend/src/pages/application/Overview.vue b/frontend/src/pages/application/Overview.vue index 54ccfb8914..f0484122ca 100644 --- a/frontend/src/pages/application/Overview.vue +++ b/frontend/src/pages/application/Overview.vue @@ -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 }) diff --git a/frontend/src/pages/application/createInstance.vue b/frontend/src/pages/application/createInstance.vue index 92f5a0be2c..e44eda608b 100644 --- a/frontend/src/pages/application/createInstance.vue +++ b/frontend/src/pages/application/createInstance.vue @@ -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) } diff --git a/frontend/src/pages/instance/Overview.vue b/frontend/src/pages/instance/Overview.vue index b32562e787..bc788280b0 100644 --- a/frontend/src/pages/instance/Overview.vue +++ b/frontend/src/pages/instance/Overview.vue @@ -17,7 +17,14 @@ -
Unavailable
+
+ + + + +
@@ -84,6 +91,7 @@ import { mapState } from 'vuex' import InstanceApi from '../../api/instances.js' import FormHeading from '../../components/FormHeading.vue' +import StatusBadge from '../../components/StatusBadge.vue' import AuditLog from '../../components/audit-log/AuditLog.vue' import permissionsMixin from '../../mixins/Permissions.js' @@ -96,6 +104,7 @@ export default { ExternalLinkIcon, FormHeading, InstanceStatusBadge, + StatusBadge, TemplateIcon, TrendingUpIcon }, @@ -122,7 +131,10 @@ export default { return this.instance?.meta?.state === 'running' }, editorAvailable () { - return this.instanceRunning + return !this.isHA && this.instanceRunning + }, + isHA () { + return this.instance?.ha.replicas !== undefined } }, watch: { diff --git a/frontend/src/pages/instance/Settings/General.vue b/frontend/src/pages/instance/Settings/General.vue index 69f574e70a..efbc79b180 100644 --- a/frontend/src/pages/instance/Settings/General.vue +++ b/frontend/src/pages/instance/Settings/General.vue @@ -13,6 +13,14 @@ Instance Type + + + + Stack @@ -20,7 +28,7 @@ Template @@ -28,6 +36,8 @@ diff --git a/frontend/src/pages/instance/Settings/index.vue b/frontend/src/pages/instance/Settings/index.vue index a3d89c2a96..bf5b55d57c 100644 --- a/frontend/src/pages/instance/Settings/index.vue +++ b/frontend/src/pages/instance/Settings/index.vue @@ -44,7 +44,7 @@ export default { } }, computed: { - ...mapState('account', ['team', 'teamMembership']) + ...mapState('account', ['team', 'teamMembership', 'features']) }, watch: { teamMembership: 'checkAccess' @@ -60,6 +60,9 @@ export default { ] if (this.hasPermission('project:edit')) { this.sideNavigation.push({ name: 'DevOps', path: './devops' }) + if (this.features.ha) { + this.sideNavigation.push({ name: 'High Availability', path: './ha' }) + } this.sideNavigation.push({ name: 'Editor', path: './editor' }) this.sideNavigation.push({ name: 'Security', path: './security' }) this.sideNavigation.push({ name: 'Palette', path: './palette' }) diff --git a/frontend/src/pages/instance/Settings/routes.js b/frontend/src/pages/instance/Settings/routes.js index 2643241949..2946d8b423 100644 --- a/frontend/src/pages/instance/Settings/routes.js +++ b/frontend/src/pages/instance/Settings/routes.js @@ -4,6 +4,7 @@ import InstanceSettingsDevOps from './DevOps.vue' import InstanceSettingsEditor from './Editor.vue' import InstanceSettingsEnvVar from './Environment.vue' import InstanceSettingsGeneral from './General.vue' +import InstanceSettingsHA from './HighAvailability.vue' import InstanceSettingsPalette from './Palette.vue' import InstanceSettingsSecurity from './Security.vue' @@ -15,6 +16,7 @@ export default [ { path: 'security', component: InstanceSettingsSecurity }, { path: 'palette', component: InstanceSettingsPalette }, { path: 'danger', component: InstanceSettingsDanger }, + { path: 'ha', name: 'InstanceSettingsHA', component: InstanceSettingsHA }, { name: 'ChangeInstanceType', path: 'change-type', diff --git a/frontend/src/pages/instance/components/cells/InstanceEditorLink.vue b/frontend/src/pages/instance/components/cells/InstanceEditorLink.vue index 2493a95ca1..4e244a7962 100644 --- a/frontend/src/pages/instance/components/cells/InstanceEditorLink.vue +++ b/frontend/src/pages/instance/components/cells/InstanceEditorLink.vue @@ -1,17 +1,25 @@