dsh 0.2.0-rc.2: in-container update (experimental channel), path-agnostic compose, upstream patches adapted to 0.1.x/0.2.x
This commit is contained in:
commit
e6dcef9bf5
302 files changed
+75178
No files matched your search
@@ -0,0 +1,437 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { randomBytes } from 'node:crypto'
|
||||
import { existsSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { JsonlLogManager } from '../../modules/log-manager/index.mjs'
|
||||
import { createManagementServer, listenManagement } from './server.mjs'
|
||||
import { LocalApiClient } from '../../modules/updater/lib/client.mjs'
|
||||
import { consumeInternalCapability } from '../../../platform/lib/access-capability.mjs'
|
||||
import { MetadataClient, NpmRegistryClient } from '../../modules/updater/lib/metadata.mjs'
|
||||
import { TargetPreparer } from '../../modules/updater/lib/preparer.mjs'
|
||||
import { UpdateCoordinator } from '../../modules/updater/lib/coordinator.mjs'
|
||||
import { UpdateScheduler } from '../../modules/updater/lib/scheduler.mjs'
|
||||
import { UpdateStateStore } from '../../modules/updater/lib/state.mjs'
|
||||
import { AutomaticCheckStateStore } from '../../modules/updater/lib/automatic-check.mjs'
|
||||
import { PlatformActivator } from '../../modules/updater/lib/activator.mjs'
|
||||
import { UpdateJournal } from '../../modules/updater/lib/journal.mjs'
|
||||
import { SnapshotClient } from '../../modules/updater/lib/snapshot-client.mjs'
|
||||
import { reconcileRecoveredState, resumeInterruptedReconcile } from '../../modules/updater/lib/recovery.mjs'
|
||||
import { ChannelStateStore } from '../../modules/updater/lib/channel-state.mjs'
|
||||
import { CompleteStateRecovery } from '../../modules/updater/lib/rollback.mjs'
|
||||
import { PlatformPaths } from '../../../platform/lib/paths.mjs'
|
||||
import { readDeploymentStatus } from '../../../platform/lib/deployment-status.mjs'
|
||||
import { parseImageInventory } from '../../../platform/lib/deployment-contracts.mjs'
|
||||
import { SettingsDocumentStore } from './settings-document.mjs'
|
||||
import { markUserPluginRestartState, UserPluginInventory } from '../../modules/plugin-manager/user-inventory.mjs'
|
||||
import { UserPluginJournal } from '../../modules/plugin-manager/user-journal.mjs'
|
||||
import { UserPluginSelectionStore } from '../../modules/plugin-manager/user-state.mjs'
|
||||
import { UserPluginTransactionManager } from '../../modules/plugin-manager/user-transaction.mjs'
|
||||
import { UserSkillManager } from './user-skills.mjs'
|
||||
import { createScopedFetch } from './scoped-fetch.mjs'
|
||||
import { OutboundProxyControlClient } from './outbound-proxy-client.mjs'
|
||||
import { ProviderInventory } from './provider-inventory.mjs'
|
||||
import { DshUpstreamAuthentication } from '../gateway/lib/dsh-upstream-auth.mjs'
|
||||
import { PROXY_SCOPE_CATALOG } from '../outbound-proxy/lib/scope-catalog.mjs'
|
||||
import { defaultProxyConfiguration } from '../outbound-proxy/lib/contracts.mjs'
|
||||
import { requestOrigin } from '../gateway/lib/browser-auth.mjs'
|
||||
import { createRestrictedCliServer, restrictedCliRoute } from './restricted-cli.mjs'
|
||||
import qrcode from 'qrcode-generator'
|
||||
|
||||
const dataRoot = process.env.DSH_PLATFORM_DATA ?? '/data/platform'
|
||||
const runRoot = process.env.DSH_PLATFORM_RUN ?? '/run/dsh-platform'
|
||||
const paths = new PlatformPaths(dataRoot, runRoot)
|
||||
const seedRoot = process.env.DSH_PLATFORM_SEED ?? '/opt/dsh-platform/seed'
|
||||
const imageInventory = parseImageInventory(await readFile(join(seedRoot, 'inventory.json')))
|
||||
const trust = new LocalApiClient(paths.trustSocket)
|
||||
const bootstrap = new LocalApiClient(paths.bootstrapSocket)
|
||||
const access = new LocalApiClient(paths.accessSocket)
|
||||
const snapshotApi = new LocalApiClient(paths.snapshotSocket)
|
||||
const userSkillApi = new LocalApiClient(paths.userSkillSocket)
|
||||
const outboundProxy = new OutboundProxyControlClient(paths.proxyControlSocket)
|
||||
const dshHome = process.env.DSH_HOME ?? '/data/dsh'
|
||||
const logs = new JsonlLogManager({
|
||||
root: paths.logsRoot,
|
||||
maxBytes: Number(process.env.DSH_LOG_MAX_BYTES ?? 104857600),
|
||||
retentionDays: Number(process.env.DSH_LOG_RETENTION_DAYS ?? 14),
|
||||
output: { stdout: process.stdout, stderr: process.stderr },
|
||||
})
|
||||
const updateFetch = createScopedFetch('updates')
|
||||
const dshUpstreamAuthentication = new DshUpstreamAuthentication({ socketPath: paths.dshLifecycleSocket })
|
||||
const providerInventory = new ProviderInventory({
|
||||
cachePath: paths.proxyProviderInventoryPath,
|
||||
authentication: dshUpstreamAuthentication,
|
||||
})
|
||||
|
||||
function qrCodeDataUri(value) {
|
||||
const code = qrcode(0, 'M')
|
||||
code.addData(value, 'Byte')
|
||||
code.make()
|
||||
const svg = code.createSvgTag({ cellSize: 5, margin: 10, scalable: true })
|
||||
return `data:image/svg+xml;base64,${Buffer.from(svg).toString('base64')}`
|
||||
}
|
||||
|
||||
let lastProxyConfiguration
|
||||
|
||||
function unavailableProxyConfiguration() {
|
||||
const fallback = lastProxyConfiguration ?? {
|
||||
...defaultProxyConfiguration(),
|
||||
revision: null,
|
||||
routeHealth: {},
|
||||
lastTest: null,
|
||||
}
|
||||
return Object.freeze({
|
||||
...fallback,
|
||||
componentReady: false,
|
||||
configurationAvailable: lastProxyConfiguration !== undefined,
|
||||
scopeCatalog: PROXY_SCOPE_CATALOG,
|
||||
})
|
||||
}
|
||||
|
||||
function proxySnapshot(view) {
|
||||
return Object.freeze({
|
||||
revision: view.revision,
|
||||
configuration: Object.freeze({
|
||||
schema: view.schema,
|
||||
enabled: view.enabled,
|
||||
proxy: view.proxy,
|
||||
scopes: view.scopes,
|
||||
environment: view.environment,
|
||||
modelApi: view.modelApi,
|
||||
noProxy: view.noProxy,
|
||||
bypass: view.bypass,
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
const proxyConfiguration = async () => {
|
||||
try {
|
||||
lastProxyConfiguration = await outboundProxy.configuration()
|
||||
return Object.freeze({
|
||||
...lastProxyConfiguration,
|
||||
configurationAvailable: true,
|
||||
scopeCatalog: PROXY_SCOPE_CATALOG,
|
||||
})
|
||||
} catch (error) {
|
||||
if (error?.code === 'PROXY_MANAGER_UNAVAILABLE') return unavailableProxyConfiguration()
|
||||
throw error
|
||||
}
|
||||
}
|
||||
void proxyConfiguration().catch(error => logs.diagnostic('platform-management', 'proxy.configuration.cache.failed', { error }))
|
||||
logs.on('error', error => { void logs.diagnostic('log-manager', 'capture.failed', { error }) })
|
||||
await logs.diagnostic('platform-management', 'management.starting', {
|
||||
imageBuildId: imageInventory.imageBuildId,
|
||||
targetSequence: imageInventory.targetSequence,
|
||||
})
|
||||
const metadata = new MetadataClient({
|
||||
baseUrl: process.env.DSH_UPDATE_METADATA_URL,
|
||||
trust,
|
||||
fetchImpl: updateFetch,
|
||||
retryRequestTimeoutMs: 15_000,
|
||||
})
|
||||
const preparer = new TargetPreparer({ untrustedRoot: paths.downloadsRoot, trust, fetchImpl: updateFetch })
|
||||
const activator = new PlatformActivator({ dataRoot, runRoot, stage0: trust })
|
||||
const journal = new UpdateJournal(join(paths.updaterStateRoot, 'transaction.json'))
|
||||
const snapshots = new SnapshotClient(snapshotApi, 'dsh')
|
||||
const completeRecovery = new CompleteStateRecovery({ journal, snapshots, activator })
|
||||
const automaticChecks = new AutomaticCheckStateStore(join(paths.updaterStateRoot, 'automatic-check.json'))
|
||||
const coordinator = new UpdateCoordinator({
|
||||
metadata,
|
||||
preparer,
|
||||
activator,
|
||||
state: new UpdateStateStore(join(paths.updaterStateRoot, 'status.json')),
|
||||
npm: new NpmRegistryClient({ fetchImpl: updateFetch, retryRequestTimeoutMs: 15_000 }),
|
||||
journal,
|
||||
snapshots,
|
||||
probationSeconds: Number(process.env.DSH_EXPERIMENTAL_PROBATION_SECONDS ?? 120),
|
||||
channelState: new ChannelStateStore(join(paths.updaterStateRoot, 'channel.json')),
|
||||
completeRecovery,
|
||||
automaticChecks,
|
||||
allowUnavailableMetadata: imageInventory.authority === 'development',
|
||||
report: (message, fields) => logs.diagnostic('updater', message, fields),
|
||||
})
|
||||
const settingsDocument = new SettingsDocumentStore(dshHome)
|
||||
const listBundledPlugins = async () => (await bootstrap.request('GET', '/v1/system-plugins')).plugins
|
||||
const listSystemSkills = async () => (await bootstrap.request('GET', '/v1/system-skills')).skills
|
||||
const userSkills = new UserSkillManager({
|
||||
dshHome,
|
||||
agentsHome: process.env.DSH_AGENTS_HOME ?? '/home/node/.agents',
|
||||
})
|
||||
const userPluginInventory = new UserPluginInventory({
|
||||
dshHome,
|
||||
selectionPath: paths.userPluginStatePath,
|
||||
systemPluginNames: async () => (await listBundledPlugins()).map(plugin => `@dsh-docker/${plugin.id}`),
|
||||
})
|
||||
const userPluginTransactions = new UserPluginTransactionManager({
|
||||
inventory: userPluginInventory,
|
||||
selectionStore: new UserPluginSelectionStore(paths.userPluginStatePath),
|
||||
snapshots: new SnapshotClient(snapshotApi, 'user-plugin'),
|
||||
journal: new UserPluginJournal(paths.userPluginJournalPath),
|
||||
pauseDsh: () => bootstrap.request('POST', '/v1/components/dsh-runtime/pause'),
|
||||
restartDsh: () => bootstrap.request('POST', '/v1/components/dsh-runtime/restart'),
|
||||
report: (message, fields) => logs.diagnostic('user-plugin-manager', message, fields),
|
||||
})
|
||||
const initialUserPluginTransaction = await userPluginTransactions.recoverBeforeDshStart()
|
||||
if (initialUserPluginTransaction !== undefined) {
|
||||
await logs.diagnostic('user-plugin-manager', 'user-plugin.startup-state.reconciled', {
|
||||
taskId: initialUserPluginTransaction.taskId,
|
||||
phase: initialUserPluginTransaction.phase,
|
||||
recoveryResult: initialUserPluginTransaction.recoveryResult,
|
||||
})
|
||||
}
|
||||
let loadedUserPluginInventory
|
||||
const markUserPluginsLoaded = async () => {
|
||||
loadedUserPluginInventory = await userPluginInventory.read()
|
||||
return loadedUserPluginInventory
|
||||
}
|
||||
const listUserPlugins = async () => markUserPluginRestartState(
|
||||
await userPluginInventory.read(),
|
||||
loadedUserPluginInventory,
|
||||
)
|
||||
const waitForBootstrapStartup = async () => {
|
||||
for (;;) {
|
||||
try {
|
||||
const status = await bootstrap.status()
|
||||
if (status.startupComplete === true) return status
|
||||
} catch (error) {
|
||||
await logs.diagnostic('user-plugin-manager', 'bootstrap.startup-status.retrying', {
|
||||
error,
|
||||
level: 'warning',
|
||||
})
|
||||
}
|
||||
await new Promise(resolve => setTimeout(resolve, 100))
|
||||
}
|
||||
}
|
||||
const scheduler = new UpdateScheduler({
|
||||
check: () => coordinator.check('automatic'),
|
||||
onError: error => logs.diagnostic('updater', 'update.automatic-check.failed', { error }),
|
||||
})
|
||||
let server
|
||||
const restrictedCliToken = randomBytes(32).toString('base64url')
|
||||
server = createManagementServer({
|
||||
coordinator,
|
||||
logs,
|
||||
authorizeInternal: async request => {
|
||||
const token = request.headers['x-dsh-internal-capability']
|
||||
const audience = request.headers['x-dsh-internal-capability-audience'] === 'plugin' ? 'plugin' : 'management'
|
||||
const restrictedToken = request.headers['x-dsh-restricted-cli']
|
||||
if (restrictedToken === restrictedCliToken
|
||||
&& restrictedCliRoute(request.method ?? 'GET', request.url ?? '/')) return true
|
||||
if (typeof token !== 'string') return false
|
||||
if (['/_dsh_platform/api/v1/auth-settings', '/_dsh_platform/api/v1/auth-totp/enrollments', '/_dsh_platform/api/v1/auth-totp/enrollments/confirm', '/_dsh_platform/api/v1/auth-totp/enrollments/cancel', '/_dsh_platform/api/v1/auth-totp/disable-confirmations', '/_dsh_platform/api/v1/auth-totp/disable-confirmations/confirm', '/_dsh_platform/api/v1/auth-totp/disable-confirmations/cancel', '/_dsh_platform/api/v1/management-origin/transitions', '/_dsh_platform/api/v1/management-origin/transitions/commit', '/_dsh_platform/api/v1/management-origin/transitions/failure', '/_dsh_platform/api/v1/auth-sessions/revoke'].includes(request.url)) return true
|
||||
return consumeInternalCapability(access, {
|
||||
token, audience, method: request.method ?? 'GET', target: request.url ?? '/',
|
||||
})
|
||||
},
|
||||
createManagementOriginTransition: async (value, request) => {
|
||||
return access.request('POST', '/v1/management/transitions', {
|
||||
...value,
|
||||
internalCapability: request.headers['x-dsh-internal-capability'],
|
||||
method: request.method,
|
||||
target: request.url,
|
||||
})
|
||||
},
|
||||
commitManagementOriginTransition: async (value, request) => {
|
||||
return access.request('POST', '/v1/management/transitions/commit', {
|
||||
...value,
|
||||
internalCapability: request.headers['x-dsh-internal-capability'],
|
||||
method: request.method,
|
||||
target: request.url,
|
||||
})
|
||||
},
|
||||
reportManagementOriginTransitionFailure: async value => {
|
||||
if (value?.stage !== 'browser-probe' || !['compat', 'isolated'].includes(value?.mode)) {
|
||||
throw Object.assign(new Error('Management origin transition failure report is invalid'), { statusCode: 400 })
|
||||
}
|
||||
await logs.diagnosticRateLimited('management-origin.probe.failed', 'platform-management', 'management-origin.probe.failed', {
|
||||
level: 'warning', stage: value.stage, mode: value.mode,
|
||||
})
|
||||
return { reported: true }
|
||||
},
|
||||
getAuthenticationSettings: async request => {
|
||||
return access.request('POST', '/v1/management/settings', {
|
||||
internalCapability: request.headers['x-dsh-internal-capability'],
|
||||
method: request.method,
|
||||
target: request.url,
|
||||
origin: requestOrigin(request, { requireHeader: false }),
|
||||
})
|
||||
},
|
||||
updateAuthenticationSettings: async (value, request) => {
|
||||
return access.request('POST', '/v1/management/auth-settings', {
|
||||
...value,
|
||||
internalCapability: request.headers['x-dsh-internal-capability'],
|
||||
method: request.method,
|
||||
target: request.url,
|
||||
})
|
||||
},
|
||||
beginTotpEnrollment: async (value, request) => {
|
||||
const enrollment = await access.request('POST', '/v1/management/totp/enrollments', {
|
||||
...value,
|
||||
internalCapability: request.headers['x-dsh-internal-capability'],
|
||||
method: request.method,
|
||||
target: request.url,
|
||||
})
|
||||
return { ...enrollment, qrCode: qrCodeDataUri(enrollment.uri), uri: undefined }
|
||||
},
|
||||
confirmTotpEnrollment: async (value, request) => {
|
||||
return access.request('POST', '/v1/management/totp/enrollments/confirm', {
|
||||
...value,
|
||||
internalCapability: request.headers['x-dsh-internal-capability'],
|
||||
method: request.method,
|
||||
target: request.url,
|
||||
})
|
||||
},
|
||||
cancelTotpEnrollment: async (value, request) => {
|
||||
return access.request('POST', '/v1/management/totp/enrollments/cancel', {
|
||||
...value,
|
||||
internalCapability: request.headers['x-dsh-internal-capability'],
|
||||
method: request.method,
|
||||
target: request.url,
|
||||
})
|
||||
},
|
||||
beginTotpDisableConfirmation: async (value, request) => {
|
||||
return access.request('POST', '/v1/management/totp/disable-confirmations', {
|
||||
...value,
|
||||
internalCapability: request.headers['x-dsh-internal-capability'],
|
||||
method: request.method,
|
||||
target: request.url,
|
||||
})
|
||||
},
|
||||
confirmTotpDisable: async (value, request) => {
|
||||
return access.request('POST', '/v1/management/totp/disable-confirmations/confirm', {
|
||||
...value,
|
||||
internalCapability: request.headers['x-dsh-internal-capability'],
|
||||
method: request.method,
|
||||
target: request.url,
|
||||
})
|
||||
},
|
||||
cancelTotpDisableConfirmation: async (value, request) => {
|
||||
return access.request('POST', '/v1/management/totp/disable-confirmations/cancel', {
|
||||
...value,
|
||||
internalCapability: request.headers['x-dsh-internal-capability'],
|
||||
method: request.method,
|
||||
target: request.url,
|
||||
})
|
||||
},
|
||||
revokeAuthenticationSessions: async (value, request) => {
|
||||
return access.request('POST', '/v1/management/sessions/revoke', {
|
||||
...value,
|
||||
internalCapability: request.headers['x-dsh-internal-capability'],
|
||||
method: request.method,
|
||||
target: request.url,
|
||||
})
|
||||
},
|
||||
platformStatus: async () => ({
|
||||
...await readDeploymentStatus(paths.deploymentStatusPath),
|
||||
trust: await trust.status(),
|
||||
dshLifecycle: (await bootstrap.status()).dshLifecycle,
|
||||
}),
|
||||
startDsh: () => bootstrap.request('POST', '/v1/components/dsh-runtime/resume'),
|
||||
stopDsh: () => bootstrap.request('POST', '/v1/components/dsh-runtime/pause'),
|
||||
restartDsh: () => bootstrap.request('POST', '/v1/components/dsh-runtime/restart'),
|
||||
resetRuntime: () => bootstrap.request('POST', '/v1/deployments/runtime/reset'),
|
||||
listBundledPlugins,
|
||||
configureBundledPlugin: (id, action) => bootstrap.request('POST', '/v1/system-plugins/action', { id, action }),
|
||||
recoverBundledPlugin: (id, action) => bootstrap.request('POST', '/v1/system-plugins/recovery-action', { id, action }),
|
||||
discardBundledPluginChanges: () => bootstrap.request('POST', '/v1/system-plugins/discard'),
|
||||
listSystemSkills,
|
||||
configureSystemSkill: (skillId, action) => bootstrap.request('POST', '/v1/system-skills/action', { skillId, action }),
|
||||
listUserSkills: () => userSkills.list(),
|
||||
validateUserSkillAction: value => userSkills.validate(value),
|
||||
configureUserSkill: value => userSkillApi.request('POST', '/v1/action', value),
|
||||
listUserPlugins,
|
||||
markUserPluginsLoaded,
|
||||
validateUserPluginActions: value => userPluginTransactions.validate(value),
|
||||
applyUserPluginActions: value => userPluginTransactions.apply(value),
|
||||
initialUserPluginTransaction,
|
||||
recoverUserPluginTransaction: initialUserPluginTransaction?.phase === 'restarting' ? async () => {
|
||||
const status = await waitForBootstrapStartup()
|
||||
try {
|
||||
const health = await bootstrap.request('GET', '/v1/health')
|
||||
const dsh = health.components?.find(component => component.id === 'dsh-runtime')
|
||||
return userPluginTransactions.completeDshStartup({
|
||||
healthy: status.recoveryMode === null && dsh?.healthy === true,
|
||||
error: status.recoveryMode,
|
||||
})
|
||||
} catch (error) {
|
||||
return userPluginTransactions.completeDshStartup({
|
||||
healthy: false,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
})
|
||||
}
|
||||
} : undefined,
|
||||
settingsDocument,
|
||||
privilegedMutationActive: () => existsSync(paths.maintenanceLeasePath),
|
||||
updateAutomaticCheck: async value => {
|
||||
const state = await automaticChecks.configure(value)
|
||||
scheduler.configure(state.automaticCheck)
|
||||
return state.automaticCheck
|
||||
},
|
||||
getProxyConfiguration: proxyConfiguration,
|
||||
updateProxyConfiguration: async value => Object.freeze({
|
||||
...await outboundProxy.updateConfiguration(value),
|
||||
scopeCatalog: PROXY_SCOPE_CATALOG,
|
||||
}),
|
||||
listProxyProviders: async () => {
|
||||
const configuration = await proxyConfiguration()
|
||||
return providerInventory.list(proxySnapshot(configuration))
|
||||
},
|
||||
startProxyTest: value => outboundProxy.startTest(value),
|
||||
getProxyTest: taskId => outboundProxy.test(taskId),
|
||||
cancelProxyTest: taskId => outboundProxy.cancelTest(taskId),
|
||||
})
|
||||
await listenManagement(server, paths.managementSocket)
|
||||
const restrictedCliServer = createRestrictedCliServer({
|
||||
managementSocketPath: paths.managementSocket,
|
||||
token: restrictedCliToken,
|
||||
})
|
||||
await listenManagement(restrictedCliServer, paths.managementCliSocket)
|
||||
void waitForBootstrapStartup()
|
||||
.then(markUserPluginsLoaded)
|
||||
.catch(error => logs.diagnostic('user-plugin-manager', 'user-plugin.loaded-state.capture.failed', { error }))
|
||||
scheduler.configure((await automaticChecks.read()).automaticCheck)
|
||||
let recovered
|
||||
try {
|
||||
recovered = await reconcileRecoveredState({ journal, state: coordinator.state })
|
||||
} catch (error) {
|
||||
await logs.diagnostic('updater', 'update.recovery-state.failed', { error })
|
||||
throw error
|
||||
}
|
||||
const { transaction, persisted } = recovered
|
||||
const journalOwnsState = transaction !== undefined && persisted.taskId === transaction.transactionId
|
||||
const resumeUpdate = !journalOwnsState && !['idle', 'success', 'failed'].includes(persisted.status)
|
||||
if (resumeUpdate) {
|
||||
setImmediate(() => {
|
||||
try {
|
||||
resumeInterruptedReconcile({
|
||||
coordinator,
|
||||
persisted,
|
||||
report: (message, fields) => logs.diagnostic('updater', message, fields),
|
||||
audit: (message, fields) => logs.diagnostic('audit', message, { stream: 'audit', ...fields }),
|
||||
})
|
||||
} catch (error) {
|
||||
void logs.diagnostic('updater', 'update.resume.failed', { error })
|
||||
}
|
||||
})
|
||||
}
|
||||
await logs.diagnostic('platform-management', 'management.ready', { resumedUpdate: resumeUpdate })
|
||||
|
||||
let stopping = false
|
||||
const stop = signal => {
|
||||
if (stopping) return
|
||||
stopping = true
|
||||
scheduler.stop()
|
||||
void logs.diagnostic('platform-management', 'management.stopping', { signal }).then(async () => {
|
||||
await updateFetch.close().catch(error => logs.diagnostic('platform-management', 'proxy.dispatcher.close.failed', { error }))
|
||||
let closed = 0
|
||||
const finish = () => {
|
||||
closed += 1
|
||||
if (closed === 2) void logs.diagnostic('platform-management', 'management.stopped').finally(() => process.exit(0))
|
||||
}
|
||||
restrictedCliServer.close(finish)
|
||||
server.close(finish)
|
||||
})
|
||||
}
|
||||
process.once('SIGINT', () => stop('SIGINT'))
|
||||
process.once('SIGTERM', () => stop('SIGTERM'))
|
||||
Reference in new issue
Block a user