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

Support disabling instance launcher "auto safe mode" #4922

Merged
merged 12 commits into from
Dec 19, 2024
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
5 changes: 3 additions & 2 deletions forge/db/models/Project.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ 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 } = require('./ProjectSettings')
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',
Expand Down Expand Up @@ -408,7 +408,8 @@ module.exports = {
{ key: KEY_HA },
{ key: KEY_PROTECTED },
{ key: KEY_CUSTOM_HOSTNAME },
{ key: KEY_HEALTH_CHECK_INTERVAL }
{ key: KEY_HEALTH_CHECK_INTERVAL },
{ key: KEY_DISABLE_AUTO_SAFE_MODE }
]
},
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 @@ -16,6 +16,7 @@ const KEY_PROTECTED = 'protected'
const KEY_HEALTH_CHECK_INTERVAL = 'healthCheckInterval'
const KEY_CUSTOM_HOSTNAME = 'customHostname'
const KEY_SHARED_ASSETS = 'sharedAssets'
const KEY_DISABLE_AUTO_SAFE_MODE = 'disableAutoSafeMode'

module.exports = {
KEY_SETTINGS,
Expand All @@ -25,6 +26,7 @@ module.exports = {
KEY_HEALTH_CHECK_INTERVAL,
KEY_CUSTOM_HOSTNAME,
KEY_SHARED_ASSETS,
KEY_DISABLE_AUTO_SAFE_MODE,
name: 'ProjectSettings',
schema: {
ProjectId: { type: DataTypes.UUID, unique: 'pk_settings' },
Expand Down
10 changes: 8 additions & 2 deletions forge/db/views/Project.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
const { KEY_HOSTNAME, KEY_SETTINGS, KEY_HA, KEY_PROTECTED, KEY_HEALTH_CHECK_INTERVAL, KEY_CUSTOM_HOSTNAME } = require('../models/ProjectSettings')
const { KEY_HOSTNAME, KEY_SETTINGS, KEY_HA, KEY_PROTECTED, KEY_HEALTH_CHECK_INTERVAL, KEY_CUSTOM_HOSTNAME, KEY_DISABLE_AUTO_SAFE_MODE } = require('../models/ProjectSettings')

module.exports = function (app) {
app.addSchema({
Expand Down Expand Up @@ -37,7 +37,8 @@ module.exports = function (app) {
launcherSettings: {
type: 'object',
properties: {
healthCheckInterval: { type: 'number' }
healthCheckInterval: { type: 'number' },
disableAutoSafeMode: { type: 'boolean' }
},
additionalProperties: false
}
Expand Down Expand Up @@ -75,6 +76,11 @@ module.exports = function (app) {
result.launcherSettings = {}
result.launcherSettings.healthCheckInterval = heathCheckIntervalRow?.value
}
const disableAutoSafeMode = proj.ProjectSettings?.find((projectSettingsRow) => projectSettingsRow.key === KEY_DISABLE_AUTO_SAFE_MODE)
if (typeof disableAutoSafeMode?.value === 'boolean') {
result.launcherSettings = result.launcherSettings || {}
result.launcherSettings.disableAutoSafeMode = disableAutoSafeMode.value
}
// Environment
result.settings.env = app.db.controllers.Project.insertPlatformSpecificEnvVars(proj, result.settings.env)
if (!result.settings.palette?.modules) {
Expand Down
32 changes: 23 additions & 9 deletions forge/routes/api/project.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
const { KEY_SETTINGS, KEY_HEALTH_CHECK_INTERVAL, KEY_SHARED_ASSETS } = require('../../db/models/ProjectSettings')
const { KEY_SETTINGS, KEY_HEALTH_CHECK_INTERVAL, KEY_DISABLE_AUTO_SAFE_MODE, KEY_SHARED_ASSETS } = require('../../db/models/ProjectSettings')
const { Roles } = require('../../lib/roles')

const ProjectActions = require('./projectActions')
Expand Down Expand Up @@ -456,15 +456,24 @@ module.exports = async function (app) {
}

// Launcher settings
if (request.body?.launcherSettings?.healthCheckInterval) {
const oldInterval = await request.project.getSetting(KEY_HEALTH_CHECK_INTERVAL)
const newInterval = parseInt(request.body.launcherSettings.healthCheckInterval, 10)
if (isNaN(newInterval) || newInterval < 5000) {
reply.code(400).send({ code: 'invalid_heathCheckInterval', error: 'Invalid heath check interval' })
return
if (request.body?.launcherSettings) {
if (request.body.launcherSettings.healthCheckInterval) {
const oldInterval = await request.project.getSetting(KEY_HEALTH_CHECK_INTERVAL)
const newInterval = parseInt(request.body.launcherSettings.healthCheckInterval, 10)
if (isNaN(newInterval) || newInterval < 5000) {
reply.code(400).send({ code: 'invalid_heathCheckInterval', error: 'Invalid heath check interval' })
return
}
if (oldInterval !== newInterval) {
changesToPersist.healthCheckInterval = { from: oldInterval, to: newInterval }
}
}
if (oldInterval !== newInterval) {
changesToPersist.healthCheckInterval = { from: oldInterval, to: newInterval }
if (typeof request.body.launcherSettings.disableAutoSafeMode === 'boolean') {
const oldInterval = await request.project.getSetting(KEY_DISABLE_AUTO_SAFE_MODE)
const newInterval = request.body.launcherSettings.disableAutoSafeMode
if (oldInterval !== newInterval) {
changesToPersist.disableAutoSafeMode = { from: oldInterval, to: newInterval }
}
}
}

Expand Down Expand Up @@ -529,6 +538,10 @@ module.exports = async function (app) {
await request.project.updateSetting(KEY_HEALTH_CHECK_INTERVAL, changesToPersist.healthCheckInterval.to, { transaction })
updates.pushDifferences({ healthCheckInterval: changesToPersist.healthCheckInterval.from }, { healthCheckInterval: changesToPersist.healthCheckInterval.to })
}
if (changesToPersist.disableAutoSafeMode) {
await request.project.updateSetting(KEY_DISABLE_AUTO_SAFE_MODE, changesToPersist.disableAutoSafeMode.to, { transaction })
updates.pushDifferences({ disableAutoSafeMode: changesToPersist.disableAutoSafeMode.from }, { disableAutoSafeMode: changesToPersist.disableAutoSafeMode.to })
}

await transaction.commit() // all good, commit the transaction

Expand Down Expand Up @@ -802,6 +815,7 @@ module.exports = async function (app) {
settings.state = request.project.state
settings.stack = request.project.ProjectStack?.properties || {}
settings.healthCheckInterval = await request.project.getSetting(KEY_HEALTH_CHECK_INTERVAL)
settings.disableAutoSafeMode = await request.project.getSetting(KEY_DISABLE_AUTO_SAFE_MODE)
settings.settings = await app.db.controllers.Project.getRuntimeSettings(request.project)
if (settings.settings.env) {
settings.env = Object.assign({}, settings.settings.env, settings.env)
Expand Down
57 changes: 49 additions & 8 deletions frontend/src/pages/instance/Settings/LauncherSettings.vue
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,18 @@
Flows that perform CPU intensive work may need to increase this from the default of 7500ms.
</template>
</FormRow>
<FormRow v-if="launcherSupportsAutoSafeMode" v-model="input.disableAutoSafeMode" type="checkbox">
Disable Auto Safe Mode
<template #description>
Prevent Node-RED from automatically entering safe mode when a crash loop is detected.
WARNING: Disabling Auto Safe Mode is not recommended. A problem that causes Node-RED to crash multiple successive times may result in a contineous bootloop that will need to be manually resolved.
</template>
</FormRow>
hardillb marked this conversation as resolved.
Show resolved Hide resolved
<div v-else class="flex flex-col sm:flex-row">
<div class="text-gray-800 block text-sm font-medium">
Some settings are not available until you upgrade your stack. <ff-button size="small" to="general">Upgrade</ff-button>
</div>
</div>

<div class="space-x-4 whitespace-nowrap">
<ff-button size="small" :disabled="!unsavedChanges || !validateFormInputs()" data-action="save-settings" @click="saveSettings()">Save settings</ff-button>
Expand All @@ -17,6 +29,7 @@

<script>

import SemVer from 'semver'
import { useRouter } from 'vue-router'

import { mapState } from 'vuex'
Expand Down Expand Up @@ -46,10 +59,12 @@ export default {
return {
mounted: false,
original: {
healthCheckInterval: null
healthCheckInterval: null,
disableAutoSafeMode: null
},
input: {
healthCheckInterval: null
healthCheckInterval: null,
disableAutoSafeMode: null
},
errors: {
healthCheckInterval: ''
Expand All @@ -60,7 +75,17 @@ export default {
computed: {
...mapState('account', ['team', 'teamMembership']),
unsavedChanges: function () {
return this.original.healthCheckInterval !== this.input.healthCheckInterval
return +this.original.healthCheckInterval !== +this.input.healthCheckInterval ||
this.original.disableAutoSafeMode !== this.input.disableAutoSafeMode
},
launcherSupportsAutoSafeMode: function () {
const launcherVersion = this.project?.meta?.versions?.launcher
if (!launcherVersion) {
// We won't have this for a suspended project - so err on the side
// of permissive
return true
}
return SemVer.satisfies(SemVer.coerce(launcherVersion), '>=2.12.0')
}
},
watch: {
Expand All @@ -69,6 +94,11 @@ export default {
if (this.mounted) {
this.validateFormInputs()
}
},
'input.disableAutoSafeMode': function (value) {
if (this.mounted) {
this.validateFormInputs()
}
}
},
mounted () {
Expand All @@ -86,7 +116,7 @@ export default {
if (!this.unsavedChanges) {
this.errors.healthCheckInterval = ''
} else {
const hci = parseInt(this.input.healthCheckInterval)
const hci = +this.input.healthCheckInterval
if (isNaN(hci) || hci < 5000) {
this.errors.healthCheckInterval = 'Health check interval must be 5000 or greater'
} else {
Expand All @@ -96,12 +126,23 @@ export default {
return !this.errors.healthCheckInterval
},
getSettings: function () {
this.original.healthCheckInterval = this.project?.launcherSettings?.healthCheckInterval
this.input.healthCheckInterval = this.project?.launcherSettings.healthCheckInterval
this.original.healthCheckInterval = this.project?.launcherSettings?.healthCheckInterval ?? 7500
this.input.healthCheckInterval = this.project?.launcherSettings?.healthCheckInterval ?? 7500
this.original.disableAutoSafeMode = this.project?.launcherSettings?.disableAutoSafeMode ?? false
this.input.disableAutoSafeMode = this.project?.launcherSettings?.disableAutoSafeMode ?? false
},
async saveSettings () {
const launcherSettings = {
healthCheckInterval: this.input.healthCheckInterval
const launcherSettings = {}
// only send update if the value has changed
if (+this.original.healthCheckInterval !== +this.input.healthCheckInterval) {
launcherSettings.healthCheckInterval = +this.input.healthCheckInterval
}
// only send the update if the launcher supports the feature
if (this.launcherSupportsAutoSafeMode) {
// only send update if the value has changed
if (this.original.disableAutoSafeMode !== this.input.disableAutoSafeMode) {
launcherSettings.disableAutoSafeMode = this.input.disableAutoSafeMode
}
}
if (!this.validateFormInputs()) {
alerts.emit('Please correct the errors before saving.', 'error')
Expand Down
13 changes: 8 additions & 5 deletions test/unit/forge/routes/api/project_spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ const setup = require('../setup')

const FF_UTIL = require('flowforge-test-utils')
const { Roles } = FF_UTIL.require('forge/lib/roles')
const { KEY_HEALTH_CHECK_INTERVAL } = FF_UTIL.require('forge/db/models/ProjectSettings')
const { KEY_HEALTH_CHECK_INTERVAL, KEY_DISABLE_AUTO_SAFE_MODE } = FF_UTIL.require('forge/db/models/ProjectSettings')
const { START_DELAY, STOP_DELAY } = FF_UTIL.require('forge/containers/stub/index.js')

describe('Project API', function () {
Expand Down Expand Up @@ -1691,7 +1691,7 @@ describe('Project API', function () {
{ name: 'two', value: '2' }
]) // should be unchanged
})
it('Change launcher health check interval - owner', async function () {
it('Change launcher settings - owner', async function () {
// Setup some flows/credentials
await addFlowsToProject(app,
TestObjects.project1.id,
Expand All @@ -1708,15 +1708,18 @@ describe('Project API', function () {
url: `/api/v1/projects/${TestObjects.project1.id}`,
payload: {
launcherSettings: {
healthCheckInterval: 9876
healthCheckInterval: 9876,
disableAutoSafeMode: true
}
},
cookies: { sid: TestObjects.tokens.alice }
})
response.statusCode.should.equal(200)

const newValue = await TestObjects.project1.getSetting(KEY_HEALTH_CHECK_INTERVAL)
should(newValue).equal(9876)
const healthValue = await TestObjects.project1.getSetting(KEY_HEALTH_CHECK_INTERVAL)
should(healthValue).equal(9876)
const safeModeValue = await TestObjects.project1.getSetting(KEY_DISABLE_AUTO_SAFE_MODE)
should(safeModeValue).equal(true)
})
it('Change launcher health check interval bad value - owner', async function () {
// Setup some flows/credentials
Expand Down
Loading