UBERF-6490: Rework backup tool (#5386)

Signed-off-by: Andrey Sobolev <haiodo@gmail.com>
This commit is contained in:
Andrey Sobolev 2024-04-17 22:18:15 +07:00 committed by GitHub
parent 4e1ca00fe1
commit b64dc7b54f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
27 changed files with 1824 additions and 1468 deletions

View File

@ -56,6 +56,9 @@ dependencies:
'@rush-temp/auth-providers':
specifier: file:./projects/auth-providers.tgz
version: file:projects/auth-providers.tgz(@types/node@20.11.19)(esbuild@0.20.1)(ts-node@10.9.2)
'@rush-temp/backup-service':
specifier: file:./projects/backup-service.tgz
version: file:projects/backup-service.tgz(esbuild@0.20.1)(ts-node@10.9.2)
'@rush-temp/bitrix':
specifier: file:./projects/bitrix.tgz
version: file:projects/bitrix.tgz(@types/node@20.11.19)(esbuild@0.20.1)(ts-node@10.9.2)
@ -17038,7 +17041,7 @@ packages:
dev: false
file:projects/account.tgz(@types/node@20.11.19)(bufferutil@4.0.8)(esbuild@0.20.1)(ts-node@10.9.2):
resolution: {integrity: sha512-gdOrX67T6n/Km9z3E9vHU90AXEZ8QwyOYLrLQfO0w+EanVjFErtvCNomVUIsgTkfjWk4diMN/FscChJ2FfO0lw==, tarball: file:projects/account.tgz}
resolution: {integrity: sha512-9RuPhqNNHTYjQwezirzcJ6WZpMJTzbjc72jZSJC6dQFG7WqW0a2QZ8VTaa32IM902stPP950kaRUDGGEKlxEwg==, tarball: file:projects/account.tgz}
id: file:projects/account.tgz
name: '@rush-temp/account'
version: 0.0.0
@ -17416,6 +17419,38 @@ packages:
- ts-node
dev: false
file:projects/backup-service.tgz(esbuild@0.20.1)(ts-node@10.9.2):
resolution: {integrity: sha512-V3tol7QGRHUEOi2hp9fv+nnjYVJJiMo/Pvfl5aRoL/CySk9vq/8KdQ6dBI2cFTLsRVjHiS4F4tzgsxcgZ09DQw==, tarball: file:projects/backup-service.tgz}
id: file:projects/backup-service.tgz
name: '@rush-temp/backup-service'
version: 0.0.0
dependencies:
'@types/jest': 29.5.12
'@types/node': 20.11.19
'@types/tar-stream': 2.2.3
'@typescript-eslint/eslint-plugin': 6.21.0(@typescript-eslint/parser@6.21.0)(eslint@8.56.0)(typescript@5.3.3)
'@typescript-eslint/parser': 6.21.0(eslint@8.56.0)(typescript@5.3.3)
eslint: 8.56.0
eslint-config-standard-with-typescript: 40.0.0(@typescript-eslint/eslint-plugin@6.21.0)(eslint-plugin-import@2.29.1)(eslint-plugin-n@15.7.0)(eslint-plugin-promise@6.1.1)(eslint@8.56.0)(typescript@5.3.3)
eslint-plugin-import: 2.29.1(eslint@8.56.0)
eslint-plugin-n: 15.7.0(eslint@8.56.0)
eslint-plugin-promise: 6.1.1(eslint@8.56.0)
jest: 29.7.0(@types/node@20.11.19)(ts-node@10.9.2)
prettier: 3.2.5
tar-stream: 2.2.0
ts-jest: 29.1.2(esbuild@0.20.1)(jest@29.7.0)(typescript@5.3.3)
typescript: 5.3.3
transitivePeerDependencies:
- '@babel/core'
- '@jest/types'
- babel-jest
- babel-plugin-macros
- esbuild
- node-notifier
- supports-color
- ts-node
dev: false
file:projects/bitrix-assets.tgz(esbuild@0.20.1)(ts-node@10.9.2):
resolution: {integrity: sha512-aQD6a0vO8LgVaG8WlZUbCBOj/Al75h7pzI3u4zDr9QTqcCi4mj302yoJzPj7dRHBoTtmE0U/kqZsfVZaeOURxw==, tarball: file:projects/bitrix-assets.tgz}
id: file:projects/bitrix-assets.tgz
@ -18081,7 +18116,7 @@ packages:
dev: false
file:projects/contact-resources.tgz(@types/node@20.11.19)(esbuild@0.20.1)(postcss-load-config@4.0.2)(postcss@8.4.35)(ts-node@10.9.2):
resolution: {integrity: sha512-VgYcfJpD4o2gzdBvypvWGaC7aOMeMYNL+Mc7+CnvxKI7mwSu1urHvxzrJCsKnptub6dBNxarnj0ESfdb4Gr+ww==, tarball: file:projects/contact-resources.tgz}
resolution: {integrity: sha512-IFjZO0UGQ83ccZ4ki0quTRNpYBaVMpRyv5gf7QoOm85RbUzxhBm7jJOH2HXGBfL+XSSlmgt8TFxhLJDoGBLw0g==, tarball: file:projects/contact-resources.tgz}
id: file:projects/contact-resources.tgz
name: '@rush-temp/contact-resources'
version: 0.0.0
@ -18840,7 +18875,7 @@ packages:
dev: false
file:projects/hr-resources.tgz(@types/node@20.11.19)(esbuild@0.20.1)(postcss-load-config@4.0.2)(postcss@8.4.35)(ts-node@10.9.2):
resolution: {integrity: sha512-82/nL+Vm0JAnfzAJV1hW8bQWYYeOFHhjYh/rXvkOJDttpW+m/J/b4uE9xIsSAmP+g2SSfzFEWysCr6p5gfEmmQ==, tarball: file:projects/hr-resources.tgz}
resolution: {integrity: sha512-qkvCJBEeNFe/BPRkLU3SasE4DMHQBxotTd4FahDfLz8x/gDDbphklWmXIJh5rOdV+XLiCfpKGyWg9yYUELcuRg==, tarball: file:projects/hr-resources.tgz}
id: file:projects/hr-resources.tgz
name: '@rush-temp/hr-resources'
version: 0.0.0
@ -22371,7 +22406,7 @@ packages:
dev: false
file:projects/server-notification-resources.tgz(@types/node@20.11.19)(esbuild@0.20.1)(ts-node@10.9.2):
resolution: {integrity: sha512-9ctaiwEU+M3RZ4Qz0/OuQ1lLh4Os9V8vlpf2FK/cpGOvAt5RUfV0qwiJLaImsLVbS62uz6hbZ1oy7Q3T+qex8Q==, tarball: file:projects/server-notification-resources.tgz}
resolution: {integrity: sha512-uhmmKoMg/lF5f1B/DlmNUTfo8sDauKZSZEgqSNbNw8SsYoRJG9/xQHlXRPHs71tKeskt+jvwgs/z2385w5R3IA==, tarball: file:projects/server-notification-resources.tgz}
id: file:projects/server-notification-resources.tgz
name: '@rush-temp/server-notification-resources'
version: 0.0.0
@ -23007,7 +23042,7 @@ packages:
dev: false
file:projects/server-tracker-resources.tgz(@types/node@20.11.19)(esbuild@0.20.1)(ts-node@10.9.2):
resolution: {integrity: sha512-sSU5k2FJCNeg9Jsmbkv3MskBYBjaYD+QOJBZCWaN5z0wpj2Gr3+dzhInFV3NMNn1wT8jjx91i2lQ3DOxK1CdJA==, tarball: file:projects/server-tracker-resources.tgz}
resolution: {integrity: sha512-XBDFV1uHjmsarrrNZIixKXw9ov021Xq0yqDIwNWtJ3pLLXK1gFYPTWDARljpQx1DKFvB6Hy+qUxTmOj4xunNAQ==, tarball: file:projects/server-tracker-resources.tgz}
id: file:projects/server-tracker-resources.tgz
name: '@rush-temp/server-tracker-resources'
version: 0.0.0
@ -23929,7 +23964,7 @@ packages:
dev: false
file:projects/text-editor.tgz(@types/node@20.11.19)(bufferutil@4.0.8)(esbuild@0.20.1)(postcss-load-config@4.0.2)(postcss@8.4.35)(prosemirror-model@1.19.4)(ts-node@10.9.2):
resolution: {integrity: sha512-JbV3dbxXXUQ8HmfqUkI/XcmvHmhOOAwJcwkFybbg8pNHPO8jTRKmYQB6HyXNMgoe4XFfxKDwWRFG/DS0QBaLzg==, tarball: file:projects/text-editor.tgz}
resolution: {integrity: sha512-G6/SsJ7NNINfdGwdI+6F9alZ+ow8Kmv+48UNx08Jh7Xz6lwvHuXWKRgh16LpzR7b5IPZJLyK3uz4zFo50JMTxA==, tarball: file:projects/text-editor.tgz}
id: file:projects/text-editor.tgz
name: '@rush-temp/text-editor'
version: 0.0.0

View File

@ -25,7 +25,6 @@ import {
getAccount,
getWorkspaceById,
listAccounts,
listWorkspaces,
listWorkspacesPure,
listWorkspacesRaw,
replacePassword,
@ -58,6 +57,7 @@ import core, {
MeasureMetricsContext,
metricsToString,
RateLimiter,
versionToString,
type AccountRole,
type Data,
type Tx,
@ -68,6 +68,7 @@ import contact from '@hcengineering/model-contact'
import { getMongoClient, getWorkspaceDB } from '@hcengineering/mongo'
import { openAIConfigDefaults } from '@hcengineering/openai'
import { type StorageAdapter } from '@hcengineering/server-core'
import { deepEqual } from 'fast-equals'
import path from 'path'
import { benchmark } from './benchmark'
import {
@ -86,6 +87,18 @@ import { fixMixinForeignAttributes, showMixinForeignAttributes } from './mixin'
import { openAIConfig } from './openai'
import { fixAccountEmails, renameAccount } from './renameAccount'
const colorConstants = {
colorRed: '\u001b[31m',
colorBlue: '\u001b[34m',
colorWhiteCyan: '\u001b[37;46m',
colorRedYellow: '\u001b[31;43m',
colorPing: '\u001b[38;5;201m',
colorLavander: '\u001b[38;5;147m',
colorAqua: '\u001b[38;2;145;231;255m',
colorPencil: '\u001b[38;2;253;182;0m',
reset: '\u001b[0m'
}
/**
* @public
*/
@ -468,11 +481,42 @@ export function devTool (
program
.command('list-workspaces')
.description('List workspaces')
.action(async () => {
.option('-e|--expired [expired]', 'Show only expired', false)
.action(async (cmd: { expired: boolean }) => {
const { mongodbUri, version } = prepareTools()
await withDatabase(mongodbUri, async (db) => {
const workspacesJSON = JSON.stringify(await listWorkspaces(toolCtx, db, productId), null, 2)
console.info(workspacesJSON)
const workspacesJSON = await listWorkspacesPure(db, productId)
for (const ws of workspacesJSON) {
let lastVisit = Math.floor((Date.now() - ws.lastVisit) / 1000 / 3600 / 24)
if (cmd.expired && lastVisit <= 7) {
continue
}
console.log(
colorConstants.colorBlue +
'####################################################################################################' +
colorConstants.reset
)
console.log('id:', colorConstants.colorWhiteCyan + ws.workspace + colorConstants.reset)
console.log('url:', ws.workspaceUrl, 'name:', ws.workspaceName)
console.log(
'version:',
ws.version !== undefined ? versionToString(ws.version) : 'not-set',
!deepEqual(ws.version, version) ? `upgrade to ${versionToString(version)} is required` : ''
)
console.log('disabled:', ws.disabled)
console.log('created by:', ws.createdBy)
console.log('members:', (ws.accounts ?? []).length)
if (Number.isNaN(lastVisit)) {
lastVisit = 365
}
if (lastVisit > 30) {
console.log(colorConstants.colorRed + `last visit: ${lastVisit} days ago` + colorConstants.reset)
} else if (lastVisit > 7) {
console.log(colorConstants.colorRedYellow + `last visit: ${lastVisit} days ago` + colorConstants.reset)
} else {
console.log('last visit:', lastVisit, 'days ago')
}
}
console.log('latest model version:', JSON.stringify(version))
})
@ -481,7 +525,7 @@ export function devTool (
program.command('fix-person-accounts').action(async () => {
const { mongodbUri, version } = prepareTools()
await withDatabase(mongodbUri, async (db, client) => {
const ws = await listWorkspaces(toolCtx, db, productId)
const ws = await listWorkspacesPure(db, productId)
for (const w of ws) {
const wsDb = getWorkspaceDB(client, { name: w.workspace, productId })
await wsDb.collection('tx').updateMany(
@ -534,6 +578,7 @@ export function devTool (
.action(async (dirName: string, workspace: string, cmd: { skip: string, force: boolean }) => {
const storage = await createFileBackupStorage(dirName)
await backup(
toolCtx,
transactorUrl,
getWorkspaceId(workspace, productId),
storage,
@ -548,7 +593,7 @@ export function devTool (
.option('-f, --force', 'Force compact.', false)
.action(async (dirName: string, cmd: { force: boolean }) => {
const storage = await createFileBackupStorage(dirName)
await compactBackup(storage, cmd.force)
await compactBackup(toolCtx, storage, cmd.force)
})
program
@ -557,7 +602,14 @@ export function devTool (
.description('dump workspace transactions and minio resources')
.action(async (dirName: string, workspace: string, date, cmd: { merge: boolean }) => {
const storage = await createFileBackupStorage(dirName)
await restore(transactorUrl, getWorkspaceId(workspace, productId), storage, parseInt(date ?? '-1'), cmd.merge)
await restore(
toolCtx,
transactorUrl,
getWorkspaceId(workspace, productId),
storage,
parseInt(date ?? '-1'),
cmd.merge
)
})
program
@ -579,7 +631,7 @@ export function devTool (
getWorkspaceId(bucketName, productId),
dirName
)
await backup(transactorUrl, getWorkspaceId(workspace, productId), storage)
await backup(toolCtx, transactorUrl, getWorkspaceId(workspace, productId), storage)
})
program
@ -594,7 +646,7 @@ export function devTool (
getWorkspaceId(bucketName, productId),
dirName
)
await compactBackup(storage, cmd.force)
await compactBackup(toolCtx, storage, cmd.force)
})
program
@ -611,11 +663,11 @@ export function devTool (
getWorkspaceId(bucketName, productId),
dirName
)
const workspaces = await listWorkspaces(toolCtx, db, productId)
const workspaces = await listWorkspacesPure(db, productId)
for (const w of workspaces) {
console.log(`clearing ${w.workspace} history:`)
await compactBackup(storage, cmd.force)
await compactBackup(toolCtx, storage, cmd.force)
}
})
})
@ -625,7 +677,7 @@ export function devTool (
.action(async (bucketName: string, dirName: string, workspace: string, date, cmd) => {
const { storageAdapter } = prepareTools()
const storage = await createStorageBackupStorage(toolCtx, storageAdapter, getWorkspaceId(bucketName), dirName)
await restore(transactorUrl, getWorkspaceId(workspace, productId), storage, parseInt(date ?? '-1'))
await restore(toolCtx, transactorUrl, getWorkspaceId(workspace, productId), storage, parseInt(date ?? '-1'))
})
program
.command('backup-s3-list <bucketName> <dirName>')
@ -695,7 +747,7 @@ export function devTool (
process.exit(1)
}
const workspaces = await listWorkspaces(toolCtx, db, productId)
const workspaces = await listWorkspacesPure(db, productId)
for (const w of workspaces) {
console.log(`clearing ${w.workspace} history:`)

View File

@ -650,3 +650,20 @@ export interface DomainIndexConfiguration extends Doc {
skip?: string[]
}
export interface BaseWorkspaceInfo {
workspace: string // An uniq workspace name, Database names
productId: string
disabled?: boolean
version?: Data<Version>
workspaceUrl?: string | null // An optional url to the workspace, if not set workspace will be used
workspaceName?: string // An displayed workspace name
createdOn: number
lastVisit: number
createdBy: string
creating?: boolean
createProgress?: number // Some progress
}

View File

@ -79,6 +79,8 @@ class Connection implements ClientConnection {
private sessionId: string | undefined
private closed = false
private upgrading: boolean = false
private pingResponse: number = Date.now()
constructor (
@ -161,7 +163,11 @@ class Connection implements ClientConnection {
throw new Error('connection closed')
}
this.pending = undefined
console.log('failed to connect', err)
if (!this.upgrading) {
console.log('connection: failed to connect', this.lastId)
} else {
console.log('connection: workspace during upgrade', this.lastId)
}
if (err?.code === UNAUTHORIZED.code) {
Analytics.handleError(err)
this.onUnauthorized?.()
@ -169,7 +175,9 @@ class Connection implements ClientConnection {
}
await new Promise((resolve) => {
setTimeout(() => {
console.log(`delay ${this.delay} second`)
if (!this.upgrading) {
console.log(`delay ${this.delay} second`)
}
resolve(null)
if (this.delay < 5) {
this.delay++
@ -220,7 +228,12 @@ class Connection implements ClientConnection {
websocket.onmessage = (event: MessageEvent) => {
const resp = readResponse<any>(event.data, binaryResponse)
if (resp.id === -1 && resp.result === 'upgrading') {
this.upgrading = true
return
}
if (resp.id === -1 && resp.result === 'hello') {
this.upgrading = false
if ((resp as HelloResponse).alreadyConnected === true) {
this.sessionId = generateId()
if (typeof sessionStorage !== 'undefined') {

View File

@ -26,11 +26,11 @@
{#await connect(getMetadata(workbench.metadata.PlatformTitle) ?? 'Platform')}
<Loading />
{:then client}
{#if !client && versionError}
{#if $versionError}
<div class="version-wrapper">
<div class="antiPopup version-popup">
<h1><Label label={workbench.string.ServerUnderMaintenance} /></h1>
{versionError}
{$versionError}
</div>
</div>
{:else if client}

View File

@ -2,18 +2,19 @@ import { Analytics } from '@hcengineering/analytics'
import client from '@hcengineering/client'
import core, {
ClientConnectEvent,
setCurrentAccount,
versionToString,
type AccountClient,
type Client,
type Version,
setCurrentAccount
type Version
} from '@hcengineering/core'
import login, { loginId } from '@hcengineering/login'
import { getMetadata, getResource, setMetadata } from '@hcengineering/platform'
import presentation, { closeClient, refreshClient, setClient } from '@hcengineering/presentation'
import { fetchMetadataLocalStorage, getCurrentLocation, navigate, setMetadataLocalStorage } from '@hcengineering/ui'
import { writable } from 'svelte/store'
export let versionError: string | undefined = ''
export const versionError = writable<string | undefined>(undefined)
let _token: string | undefined
let _client: AccountClient | undefined
@ -93,7 +94,7 @@ export async function connect (title: string): Promise<Client | undefined> {
if (currentVersionStr !== reconnectVersionStr) {
// It seems upgrade happened
// location.reload()
versionError = `${currentVersionStr} != ${reconnectVersionStr}`
versionError.set(`${currentVersionStr} != ${reconnectVersionStr}`)
}
const serverVersion: { version: string } = await (
await fetch(serverEndpoint + '/api/v1/version', {})
@ -101,7 +102,7 @@ export async function connect (title: string): Promise<Client | undefined> {
console.log('Server version', serverVersion.version)
if (serverVersion.version !== '' && serverVersion.version !== currentVersionStr) {
versionError = `${currentVersionStr} => ${serverVersion.version}`
versionError.set(`${currentVersionStr} => ${serverVersion.version}`)
}
}
})()
@ -131,7 +132,7 @@ export async function connect (title: string): Promise<Client | undefined> {
const versionStr = versionToString(version)
if (version === undefined || requiredVersion !== versionStr) {
versionError = `${versionStr} => ${requiredVersion}`
versionError.set(`${versionStr} => ${requiredVersion}`)
return undefined
}
}
@ -139,17 +140,17 @@ export async function connect (title: string): Promise<Client | undefined> {
try {
const serverVersion: { version: string } = await (await fetch(serverEndpoint + '/api/v1/version', {})).json()
console.log('Server version', serverVersion.version)
console.log('Server version', serverVersion.version, version !== undefined ? versionToString(version) : '')
if (
serverVersion.version !== '' &&
(version === undefined || serverVersion.version !== versionToString(version))
) {
const versionStr = version !== undefined ? versionToString(version) : 'unknown'
versionError = `${versionStr} => ${serverVersion.version}`
versionError.set(`${versionStr} => ${serverVersion.version}`)
return
}
} catch (err: any) {
versionError = 'server version not available'
versionError.set('server version not available')
return
}
} catch (err: any) {
@ -158,11 +159,12 @@ export async function connect (title: string): Promise<Client | undefined> {
const requirdVersion = getMetadata(presentation.metadata.RequiredVersion)
console.log('checking min model version', requirdVersion)
if (requirdVersion !== undefined) {
versionError = `'unknown' => ${requirdVersion}`
versionError.set(`'unknown' => ${requirdVersion}`)
return undefined
}
}
versionError.set(undefined)
// Update window title
document.title = [ws, title].filter((it) => it).join(' - ')
_clientSet = true

View File

@ -206,13 +206,13 @@
{/if}
<ComponentExtensions
extension={tracker.extensions.EditIssueTitle}
props={{ size: 'medium', kind: 'ghost', space: issue.space, issue, readonly }}
props={{ size: 'medium', kind: 'ghost', space: issue.space, value: issue, readonly }}
/>
</svelte:fragment>
<svelte:fragment slot="pre-utils">
<ComponentExtensions
extension={tracker.extensions.EditIssueHeader}
props={{ size: 'medium', kind: 'ghost', space: issue.space, readonly, issue }}
props={{ size: 'medium', kind: 'ghost', space: issue.space, readonly, value: issue }}
/>
{#if saved}
<Label label={presentation.string.Saved} />

View File

@ -64,7 +64,7 @@
{/if}
</Loading>
{:then client}
{#if !client && versionError}
{#if $versionError}
<div class="version-wrapper">
<div class="antiPopup version-popup">
{#if isNeedUpgrade}
@ -73,7 +73,7 @@
{:else}
<h1><Label label={workbench.string.ServerUnderMaintenance} /></h1>
{/if}
{versionError}
{$versionError}
</div>
</div>
{:else if client}

View File

@ -22,10 +22,11 @@ import {
networkStatus,
setMetadataLocalStorage
} from '@hcengineering/ui'
import { writable } from 'svelte/store'
import plugin from './plugin'
import { workspaceCreating } from './utils'
export let versionError: string | undefined = ''
export const versionError = writable<string | undefined>(undefined)
let _token: string | undefined
let _client: AccountClient | undefined
@ -176,7 +177,7 @@ export async function connect (title: string): Promise<Client | undefined> {
if (currentVersionStr !== reconnectVersionStr) {
// It seems upgrade happened
// location.reload()
versionError = `${currentVersionStr} != ${reconnectVersionStr}`
versionError.set(`${currentVersionStr} != ${reconnectVersionStr}`)
}
const serverVersion: { version: string } = await ctx.with(
'fetch-server-version',
@ -184,9 +185,15 @@ export async function connect (title: string): Promise<Client | undefined> {
async () => await (await fetch(serverEndpoint + '/api/v1/version', {})).json()
)
console.log('Server version', serverVersion.version)
console.log(
'Server version',
serverVersion.version,
version !== undefined ? versionToString(version) : ''
)
if (serverVersion.version !== '' && serverVersion.version !== currentVersionStr) {
versionError = `${currentVersionStr} => ${serverVersion.version}`
versionError.set(`${currentVersionStr} => ${serverVersion.version}`)
} else {
versionError.set(undefined)
}
}
})()
@ -237,7 +244,7 @@ export async function connect (title: string): Promise<Client | undefined> {
const versionStr = versionToString(version)
if (version === undefined || requiredVersion !== versionStr) {
versionError = `${versionStr} => ${requiredVersion}`
versionError.set(`${versionStr} => ${requiredVersion}`)
return undefined
}
}
@ -249,30 +256,32 @@ export async function connect (title: string): Promise<Client | undefined> {
async () => await (await fetch(serverEndpoint + '/api/v1/version', {})).json()
)
console.log('Server version', serverVersion.version)
console.log('Server version', serverVersion.version, version !== undefined ? versionToString(version) : '')
if (
serverVersion.version !== '' &&
(version === undefined || serverVersion.version !== versionToString(version))
) {
const versionStr = version !== undefined ? versionToString(version) : 'unknown'
versionError = `${versionStr} => ${serverVersion.version}`
versionError.set(`${versionStr} => ${serverVersion.version}`)
return
}
} catch (err: any) {
versionError = 'server version not available'
versionError.set('server version not available')
return
}
} catch (err: any) {
console.error(err)
Analytics.handleError(err)
const requirdVersion = getMetadata(presentation.metadata.RequiredVersion)
console.log('checking min model version', requirdVersion)
if (requirdVersion !== undefined) {
versionError = `'unknown' => ${requirdVersion}`
const requiredVersion = getMetadata(presentation.metadata.RequiredVersion)
console.log('checking min model version', requiredVersion)
if (requiredVersion !== undefined) {
versionError.set(`'unknown' => ${requiredVersion}`)
return undefined
}
}
versionError.set(undefined)
// Update window title
document.title = [ws, title].filter((it) => it).join(' - ')
_clientSet = true

View File

@ -47,16 +47,12 @@
},
"dependencies": {
"@hcengineering/platform": "^0.6.9",
"mongodb": "^6.3.0",
"@hcengineering/server-tool": "^0.6.0",
"@hcengineering/server-token": "^0.6.7",
"@hcengineering/client": "^0.6.14",
"@hcengineering/client-resources": "^0.6.23",
"@hcengineering/core": "^0.6.28",
"dotenv": "~16.0.0",
"got": "^11.8.3",
"@hcengineering/server-backup": "^0.6.0",
"@hcengineering/server-core": "^0.6.1",
"@hcengineering/minio": "^0.6.0"
"@hcengineering/backup-service": "^0.6.0"
}
}

View File

@ -13,24 +13,8 @@
// limitations under the License.
//
import { PlatformWorker } from './platform'
import { MeasureMetricsContext } from '@hcengineering/core'
import { startBackup } from '@hcengineering/backup-service'
const main = async (): Promise<void> => {
const platformWorker = await PlatformWorker.create()
const shutdown = (): void => {
void platformWorker.close().then(() => {
process.exit()
})
}
process.on('SIGINT', shutdown)
process.on('SIGTERM', shutdown)
process.on('uncaughtException', (e) => {
console.error(e)
})
process.on('unhandledRejection', (e) => {
console.error(e)
})
}
void main()
const ctx = new MeasureMetricsContext('backup-service', {})
startBackup(ctx)

View File

@ -1,119 +0,0 @@
//
// Copyright © 2022 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//
// See the License for the specific language governing permissions and
// limitations under the License.
//
import { getWorkspaceId, MeasureMetricsContext } from '@hcengineering/core'
import { MinioService } from '@hcengineering/minio'
import { setMetadata } from '@hcengineering/platform'
import { backup, createStorageBackupStorage } from '@hcengineering/server-backup'
import { type StorageAdapter } from '@hcengineering/server-core'
import serverToken from '@hcengineering/server-token'
import toolPlugin from '@hcengineering/server-tool'
import got from 'got'
import { type ObjectId } from 'mongodb'
import config from './config'
/**
* @public
*/
export interface Workspace {
_id: ObjectId
workspace: string
organisation: string
accounts: ObjectId[]
productId: string
}
async function getWorkspaces (): Promise<Workspace[]> {
const { body }: { body: { error?: string, result?: any[] } } = await got.post(config.AccountsURL, {
json: {
method: 'listWorkspaces',
params: []
},
responseType: 'json'
})
if (body.error !== undefined) {
throw Error(body.error)
}
return (body.result as Workspace[]) ?? []
}
export class PlatformWorker {
storageAdapter!: StorageAdapter
async close (): Promise<void> {}
async init (): Promise<void> {
setMetadata(serverToken.metadata.Secret, config.Secret)
let minioPort = 9000
let minioEndpoint = config.MinioEndpoint
const sp = minioEndpoint.split(':')
if (sp.length > 1) {
minioEndpoint = sp[0]
minioPort = parseInt(sp[1])
}
this.storageAdapter = new MinioService({
endPoint: minioEndpoint,
port: minioPort,
useSSL: false,
accessKey: config.MinioAccessKey,
secretKey: config.MinioSecretKey
})
setMetadata(toolPlugin.metadata.UserAgent, config.ServiceID)
await this.backup().then(() => {
void this.schedule()
})
}
async schedule (): Promise<void> {
console.log('schedule timeout for', config.Interval, ' seconds')
setTimeout(() => {
void this.backup().then(() => {
void this.schedule()
})
}, config.Interval * 1000)
}
async backup (): Promise<void> {
const workspaces = await getWorkspaces()
const ctx = new MeasureMetricsContext('backup', {})
for (const ws of workspaces) {
console.log('\n\nBACKUP WORKSPACE ', ws.workspace, ws.productId)
try {
const storage = await createStorageBackupStorage(
ctx,
this.storageAdapter,
getWorkspaceId('backups', ws.productId),
ws.workspace
)
await backup(config.TransactorURL, getWorkspaceId(ws.workspace, ws.productId), storage)
} catch (err: any) {
console.error('\n\nFAILED to BACKUP', ws, err)
}
}
}
static async create (): Promise<PlatformWorker> {
const worker = new PlatformWorker()
await worker.init()
return worker
}
private constructor () {}
}

View File

@ -1367,6 +1367,11 @@
"projectFolder": "server/backup",
"shouldPublish": false
},
{
"packageName": "@hcengineering/backup-service",
"projectFolder": "server/backup-service",
"shouldPublish": false
},
{
"packageName": "@hcengineering/pod-backup",
"projectFolder": "pods/backup",

View File

@ -26,6 +26,7 @@ import contact, {
} from '@hcengineering/contact'
import core, {
AccountRole,
BaseWorkspaceInfo,
Client,
concatLink,
Data,
@ -100,23 +101,9 @@ export interface Account {
/**
* @public
*/
export interface Workspace {
export interface Workspace extends BaseWorkspaceInfo {
_id: ObjectId
workspace: string // An uniq workspace name, Database names
accounts: ObjectId[]
productId: string
disabled?: boolean
version?: Data<Version>
workspaceUrl?: string | null // An optional url to the workspace, if not set workspace will be used
workspaceName?: string // An displayed workspace name
createdOn: number
lastVisit: number
createdBy: string
creating?: boolean
createProgress?: number // Some progress
}
/**
@ -664,7 +651,13 @@ export async function createAccount (
/**
* @public
*/
export async function listWorkspaces (ctx: MeasureContext, db: Db, productId: string): Promise<WorkspaceInfo[]> {
export async function listWorkspaces (
ctx: MeasureContext,
db: Db,
productId: string,
token: string
): Promise<WorkspaceInfo[]> {
decodeToken(token) // Just verify token is valid
return (await db.collection<Workspace>(WORKSPACE_COLLECTION).find(withProductId(productId, {})).toArray())
.map((it) => ({ ...it, productId }))
.filter((it) => it.disabled !== true)

View File

@ -0,0 +1,7 @@
module.exports = {
extends: ['./node_modules/@hcengineering/platform-rig/profiles/default/eslint.config.json'],
parserOptions: {
tsconfigRootDir: __dirname,
project: './tsconfig.json'
}
}

View File

@ -0,0 +1,4 @@
*
!/lib/**
!CHANGELOG.md
/lib/**/__tests__/

View File

@ -0,0 +1,4 @@
{
"$schema": "https://developer.microsoft.com/json-schemas/rig-package/rig.schema.json",
"rigPackageName": "@hcengineering/platform-rig"
}

View File

@ -0,0 +1,7 @@
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
testMatch: ['**/?(*.)+(spec|test).[jt]s?(x)'],
roots: ["./src"],
coverageReporters: ["text-summary", "html"]
}

View File

@ -0,0 +1,51 @@
{
"name": "@hcengineering/backup-service",
"version": "0.6.0",
"main": "lib/index.js",
"svelte": "src/index.ts",
"types": "types/index.d.ts",
"author": "Anticrm Platform Contributors",
"license": "EPL-2.0",
"scripts": {
"build": "compile",
"build:watch": "compile",
"format": "format src",
"run": "ts-node",
"test": "jest --passWithNoTests --silent",
"_phase:build": "compile transpile src",
"_phase:test": "jest --passWithNoTests --silent",
"_phase:format": "format src",
"_phase:validate": "compile validate"
},
"devDependencies": {
"@hcengineering/platform-rig": "^0.6.0",
"@typescript-eslint/eslint-plugin": "^6.11.0",
"eslint-plugin-import": "^2.26.0",
"eslint-plugin-promise": "^6.1.1",
"eslint-plugin-n": "^15.4.0",
"eslint": "^8.54.0",
"@typescript-eslint/parser": "^6.11.0",
"eslint-config-standard-with-typescript": "^40.0.0",
"prettier": "^3.1.0",
"typescript": "^5.3.3",
"@types/tar-stream": "^2.2.2",
"@types/node": "~20.11.16",
"jest": "^29.7.0",
"ts-jest": "^29.1.1",
"@types/jest": "^29.5.5"
},
"dependencies": {
"@hcengineering/platform": "^0.6.9",
"@hcengineering/core": "^0.6.28",
"@hcengineering/contact": "^0.6.20",
"@hcengineering/client-resources": "^0.6.23",
"@hcengineering/client": "^0.6.14",
"@hcengineering/model": "^0.6.7",
"tar-stream": "^2.2.0",
"@hcengineering/server-tool": "^0.6.0",
"@hcengineering/server-core": "^0.6.1",
"@hcengineering/server-backup": "^0.6.0",
"@hcengineering/minio": "^0.6.0",
"@hcengineering/server-token": "^0.6.7"
}
}

View File

@ -13,13 +13,16 @@
// limitations under the License.
//
interface Config {
import { type BackupConfig } from '@hcengineering/server-backup'
interface Config extends Omit<BackupConfig, 'Token'> {
TransactorURL: string
AccountsURL: string
ServiceID: string
Secret: string
Interval: number
Interval: number // Timeout in seconds
Timeout: number // Timeout in seconds
BucketName: string
MinioEndpoint: string
@ -36,7 +39,8 @@ const envMap: { [key in keyof Config]: string } = {
Interval: 'INTERVAL',
MinioEndpoint: 'MINIO_ENDPOINT',
MinioAccessKey: 'MINIO_ACCESS_KEY',
MinioSecretKey: 'MINIO_SECRET_KEY'
MinioSecretKey: 'MINIO_SECRET_KEY',
Timeout: 'TIMEOUT'
}
const required: Array<keyof Config> = [
@ -55,12 +59,13 @@ const config: Config = (() => {
TransactorURL: process.env[envMap.TransactorURL],
AccountsURL: process.env[envMap.AccountsURL],
Secret: process.env[envMap.Secret],
BucketName: process.env[envMap.BucketName],
BucketName: process.env[envMap.BucketName] ?? 'backups',
ServiceID: process.env[envMap.ServiceID] ?? 'backup-service',
Interval: parseInt(process.env[envMap.Interval] ?? '3600'),
MinioEndpoint: process.env[envMap.MinioEndpoint],
MinioAccessKey: process.env[envMap.MinioAccessKey],
MinioSecretKey: process.env[envMap.MinioSecretKey]
MinioSecretKey: process.env[envMap.MinioSecretKey],
Timeout: parseInt(process.env[envMap.Timeout] ?? '3600')
}
const missingEnv = required.filter((key) => params[key] === undefined).map((key) => envMap[key])

View File

@ -0,0 +1,58 @@
//
// Copyright © 2022 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//
// See the License for the specific language governing permissions and
// limitations under the License.
//
import { MeasureContext, systemAccountEmail } from '@hcengineering/core'
import { MinioService } from '@hcengineering/minio'
import { setMetadata } from '@hcengineering/platform'
import { backupService } from '@hcengineering/server-backup'
import serverToken, { generateToken } from '@hcengineering/server-token'
import toolPlugin from '@hcengineering/server-tool'
import config from './config'
export function startBackup (ctx: MeasureContext): void {
setMetadata(serverToken.metadata.Secret, config.Secret)
let minioPort = 9000
let minioEndpoint = config.MinioEndpoint
const sp = minioEndpoint.split(':')
if (sp.length > 1) {
minioEndpoint = sp[0]
minioPort = parseInt(sp[1])
}
const storageAdapter = new MinioService({
endPoint: minioEndpoint,
port: minioPort,
useSSL: false,
accessKey: config.MinioAccessKey,
secretKey: config.MinioSecretKey
})
setMetadata(toolPlugin.metadata.UserAgent, config.ServiceID)
// A token to access account service
const token = generateToken(systemAccountEmail, { name: 'backup', productId: '' })
const shutdown = backupService(ctx, storageAdapter, { ...config, Token: token })
process.on('SIGINT', shutdown)
process.on('SIGTERM', shutdown)
process.on('uncaughtException', (e) => {
void ctx.error('uncaughtException', { err: e })
})
process.on('unhandledRejection', (e) => {
void ctx.error('unhandledRejection', { err: e })
})
}

View File

@ -0,0 +1,10 @@
{
"extends": "./node_modules/@hcengineering/platform-rig/profiles/default/tsconfig.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./lib",
"declarationDir": "./types",
"tsBuildInfoFile": ".build/build.tsbuildinfo"
}
}

1322
server/backup/src/backup.ts Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,115 @@
//
// Copyright © 2024 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//
// See the License for the specific language governing permissions and
// limitations under the License.
//
import { BaseWorkspaceInfo, getWorkspaceId, type MeasureContext } from '@hcengineering/core'
import { type StorageAdapter } from '@hcengineering/server-core'
import { backup } from '.'
import { createStorageBackupStorage } from './storage'
async function getWorkspaces (accounts: string, token: string): Promise<BaseWorkspaceInfo[]> {
const workspaces = await (
await fetch(accounts, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
method: 'listWorkspaces',
params: [token]
})
})
).json()
return (workspaces.result as BaseWorkspaceInfo[]) ?? []
}
export interface BackupConfig {
TransactorURL: string
AccountsURL: string
Token: string
Interval: number // Timeout in seconds
Timeout: number // Timeout in seconds
BucketName: string
}
class BackupWorker {
constructor (
readonly storageAdapter: StorageAdapter,
readonly config: BackupConfig
) {}
canceled = false
interval: any
async close (): Promise<void> {
this.canceled = true
clearTimeout(this.interval)
}
async schedule (ctx: MeasureContext): Promise<void> {
console.log('schedule timeout for', this.config.Interval, ' seconds')
this.interval = setTimeout(() => {
void this.backup(ctx).then(() => {
void this.schedule(ctx)
})
}, this.config.Interval * 1000)
}
async backup (ctx: MeasureContext): Promise<void> {
const workspaces = await getWorkspaces(this.config.AccountsURL, this.config.Token)
workspaces.sort((a, b) => b.lastVisit - a.lastVisit)
for (const ws of workspaces) {
if (this.canceled) {
return
}
await ctx.info('\n\nBACKUP WORKSPACE ', { workspace: ws.workspace, productId: ws.productId })
try {
const storage = await createStorageBackupStorage(
ctx,
this.storageAdapter,
getWorkspaceId(this.config.BucketName, ws.productId),
ws.workspace
)
await ctx.with('backup', { workspace: ws.workspace }, async (ctx) => {
await backup(
ctx,
this.config.TransactorURL,
getWorkspaceId(ws.workspace, ws.productId),
storage,
[],
false,
this.config.Timeout * 1000
)
})
} catch (err: any) {
await ctx.error('\n\nFAILED to BACKUP', { workspace: ws.workspace, err })
}
}
}
}
export function backupService (ctx: MeasureContext, storage: StorageAdapter, config: BackupConfig): () => void {
const backupWorker = new BackupWorker(storage, config)
const shutdown = (): void => {
void backupWorker.close()
}
void backupWorker.backup(ctx).then(() => {
void backupWorker.schedule(ctx)
})
return shutdown
}

View File

@ -20,6 +20,8 @@ import core, {
generateId,
systemAccountEmail,
toWorkspaceString,
versionToString,
type BaseWorkspaceInfo,
type MeasureContext,
type Ref,
type Space,
@ -43,10 +45,7 @@ import {
type Workspace
} from './types'
interface WorkspaceLoginInfo {
workspaceName?: string // A company name
workspace: string
}
interface WorkspaceLoginInfo extends BaseWorkspaceInfo {}
function timeoutPromise (time: number): Promise<void> {
return new Promise((resolve) => {
@ -75,6 +74,8 @@ class TSessionManager implements SessionManager {
maintenanceTimer: any
timeMinutes = 0
modelVersion = process.env.MODEL_VERSION ?? ''
constructor (
readonly ctx: MeasureContext,
readonly sessionFactory: (token: Token, pipeline: Pipeline, broadcast: BroadcastCall) => Session,
@ -179,15 +180,7 @@ class TSessionManager implements SessionManager {
return this.sessionFactory(token, pipeline, this.broadcast.bind(this))
}
async getWorkspaceInfo (
accounts: string,
token: string
): Promise<{
workspace: string
workspaceUrl?: string | null
workspaceName?: string
creating?: boolean
}> {
async getWorkspaceInfo (accounts: string, token: string): Promise<BaseWorkspaceInfo> {
const userInfo = await (
await fetch(accounts, {
method: 'POST',
@ -230,10 +223,25 @@ class TSessionManager implements SessionManager {
if (workspaceInfo === undefined && token.extra?.admin !== 'true') {
// No access to workspace for token.
return { error: new Error(`No access to workspace for token ${token.email} ${token.workspace.name}`) }
} else {
} else if (workspaceInfo === undefined) {
workspaceInfo = this.wsFromToken(token)
}
if (
this.modelVersion !== '' &&
workspaceInfo.version !== undefined &&
this.modelVersion !== versionToString(workspaceInfo.version) &&
token.extra?.model !== 'upgrade' &&
token.extra?.mode !== 'backup'
) {
await ctx.info('model version mismatch', {
version: this.modelVersion,
workspaceVersion: versionToString(workspaceInfo.version)
})
// Version mismatch, return upgrading.
return { upgrade: true }
}
let workspace = this.workspaces.get(wsString)
if (workspace?.closeTimeout !== undefined) {
await ctx.info('Cancel workspace warm close', { wsString })
@ -311,16 +319,18 @@ class TSessionManager implements SessionManager {
})
}
private wsFromToken (token: Token): {
workspace: string
workspaceUrl?: string | null
workspaceName?: string
creating?: boolean
} {
private wsFromToken (token: Token): BaseWorkspaceInfo {
return {
workspace: token.workspace.name,
workspaceUrl: token.workspace.name,
workspaceName: token.workspace.name
workspaceName: token.workspace.name,
createdBy: '',
createdOn: Date.now(),
lastVisit: Date.now(),
productId: '',
createProgress: 100,
creating: false,
disabled: false
}
}

View File

@ -13,6 +13,7 @@
// limitations under the License.
//
import { Analytics } from '@hcengineering/analytics'
import { generateId, type MeasureContext } from '@hcengineering/core'
import { UNAUTHORIZED } from '@hcengineering/platform'
import { serialize, type Response } from '@hcengineering/rpc'
@ -30,7 +31,6 @@ import {
type PipelineFactory,
type SessionManager
} from './types'
import { Analytics } from '@hcengineering/analytics'
/**
* @public
@ -223,7 +223,11 @@ export function startHttpServer (
if ('error' in session) {
void ctx.error('error', { error: session.error?.message, stack: session.error?.stack })
}
cs.close()
await cs.send(ctx, { id: -1, result: 'upgrading' }, false, false)
// Wait 1 second before closing the connection
setTimeout(() => {
cs.close()
}, 1000)
return
}
// eslint-disable-next-line @typescript-eslint/no-misused-promises