mirror of
https://github.com/hcengineering/platform.git
synced 2026-08-17 18:05:42 +02:00
* Add script to verify all backup data is present in a workspace Signed-off-by: Artyom Savchenko <armisav@gmail.com> * Verify blobs exist Signed-off-by: Artyom Savchenko <armisav@gmail.com> * Fix formatting Signed-off-by: Artyom Savchenko <armisav@gmail.com> --------- Signed-off-by: Artyom Savchenko <armisav@gmail.com>
1701 lines
62 KiB
TypeScript
1701 lines
62 KiB
TypeScript
//
|
|
// Copyright © 2020, 2021 Anticrm Platform Contributors.
|
|
// Copyright © 2021, 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.
|
|
//
|
|
/* eslint-disable @typescript-eslint/no-unused-vars */
|
|
import accountPlugin, {
|
|
assignWorkspace,
|
|
createWorkspaceRecord,
|
|
flattenStatus,
|
|
getAccountDB,
|
|
getWorkspaceInfoWithStatusById,
|
|
getWorkspaces,
|
|
getWorkspacesInfoWithStatusByIds,
|
|
signUpByEmail,
|
|
updateWorkspaceInfo,
|
|
type AccountDB,
|
|
type Workspace
|
|
} from '@hcengineering/account'
|
|
import {
|
|
getMongoAccountDB,
|
|
type Account as OldAccount,
|
|
type Workspace as OldWorkspace
|
|
} from '@hcengineering/account-service'
|
|
import { getWorkspaceClient as getHulylakeClient } from '@hcengineering/hulylake-client'
|
|
import { setMetadata } from '@hcengineering/platform'
|
|
import {
|
|
createPostgreeDestroyAdapter,
|
|
createPostgresAdapter,
|
|
createPostgresTxAdapter,
|
|
getDBClient,
|
|
shutdownPostgres
|
|
} from '@hcengineering/postgres'
|
|
import {
|
|
backup,
|
|
backupDownload,
|
|
backupFind,
|
|
checkBackupIntegrity,
|
|
checkWorkspaceBackup,
|
|
compactBackup,
|
|
createFileBackupStorage,
|
|
createStorageBackupStorage,
|
|
restore
|
|
} from '@hcengineering/server-backup'
|
|
import serverClientPlugin, { BlobClient, getAccountClient, getTransactorEndpoint } from '@hcengineering/server-client'
|
|
import {
|
|
createBackupPipeline,
|
|
createEmptyBroadcastOps,
|
|
registerAdapterFactory,
|
|
registerDestroyFactory,
|
|
registerServerPlugins,
|
|
registerStringLoaders,
|
|
registerTxAdapterFactory,
|
|
setAdapterSecurity
|
|
} from '@hcengineering/server-pipeline'
|
|
import serverToken, { decodeToken, generateToken } from '@hcengineering/server-token'
|
|
import { createWorkspace, upgradeWorkspace } from '@hcengineering/workspace-service'
|
|
|
|
import { faker } from '@faker-js/faker'
|
|
import { getPlatformQueue } from '@hcengineering/kafka'
|
|
import { buildStorageFromConfig, createStorageFromConfig, storageConfigFromEnv } from '@hcengineering/server-storage'
|
|
import { program, type Command } from 'commander'
|
|
import { updateField } from './workspace'
|
|
|
|
import { RatingCalculator, ratingEvents, type QueueRatingMessage } from '@hcengineering/pod-rating'
|
|
|
|
import core, {
|
|
AccountRole,
|
|
isArchivingMode,
|
|
isDeletingMode,
|
|
MeasureMetricsContext,
|
|
metricsToString,
|
|
SocialIdType,
|
|
systemAccountEmail,
|
|
systemAccountUuid,
|
|
TxOperations,
|
|
type AccountUuid,
|
|
type Class,
|
|
type Client,
|
|
type Data,
|
|
type Doc,
|
|
type PersonId,
|
|
type PersonUuid,
|
|
type Ref,
|
|
type Tx,
|
|
type Version,
|
|
type WorkspaceDataId,
|
|
type WorkspaceUuid
|
|
} from '@hcengineering/core'
|
|
import { consoleModelLogger, type MigrateOperation } from '@hcengineering/model'
|
|
import {
|
|
createMongoAdapter,
|
|
createMongoDestroyAdapter,
|
|
createMongoTxAdapter,
|
|
getMongoClient,
|
|
shutdownMongo
|
|
} from '@hcengineering/mongo'
|
|
|
|
import { getModelVersion } from '@hcengineering/model-all'
|
|
import {
|
|
QueueTopic,
|
|
workspaceEvents,
|
|
type Pipeline,
|
|
type QueueWorkspaceMessage,
|
|
type StorageAdapter
|
|
} from '@hcengineering/server-core'
|
|
import { getAccountDBUrl, getKvsUrl, getMongoDBUrl } from './__start'
|
|
import { changeConfiguration } from './configuration'
|
|
|
|
import { performCalendarAccountMigrations } from './calendar'
|
|
import {
|
|
ensureGlobalPersonsForLocalAccounts,
|
|
filterMergedAccountsInMembers,
|
|
migrateCreatedModifiedBy,
|
|
migrateMergedAccounts,
|
|
migrateTrustedV6Accounts,
|
|
moveAccountDbFromMongoToPG,
|
|
restoreFromv6All,
|
|
restoreTrustedV6Workspace
|
|
} from './db'
|
|
import { ensureMissingSocialIdentities } from './contact'
|
|
import { performGithubAccountMigrations } from './github'
|
|
import { performGmailAccountMigrations } from './gmail'
|
|
import { getToolToken, getWorkspace, getWorkspaceTransactorEndpoint } from './utils'
|
|
|
|
import { createRestClient } from '@hcengineering/api-client'
|
|
import { type CardID } from '@hcengineering/communication-types'
|
|
import { connect, sendTransactorEvent } from '@hcengineering/server-tool'
|
|
import { existsSync } from 'fs'
|
|
import { mkdir, writeFile } from 'fs/promises'
|
|
import { dirname } from 'path'
|
|
import { restoreMarkupRefs } from './markup'
|
|
import { restoreGithubIntegrations } from './restoreGithub'
|
|
import { migrateWorkspaceChat } from './communication'
|
|
|
|
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'
|
|
}
|
|
|
|
// Register close on process exit.
|
|
process.on('exit', () => {
|
|
shutdownPostgres().catch((err) => {
|
|
console.error(err)
|
|
})
|
|
shutdownMongo().catch((err) => {
|
|
console.error(err)
|
|
})
|
|
})
|
|
|
|
/**
|
|
* @public
|
|
*/
|
|
export function devTool (
|
|
prepareTools: () => {
|
|
dbUrl: string
|
|
txes: Tx[]
|
|
version: Data<Version>
|
|
migrateOperations: [string, MigrateOperation][]
|
|
},
|
|
extendProgram?: (prog: Command) => void
|
|
): void {
|
|
const toolCtx = new MeasureMetricsContext('tool', {})
|
|
|
|
registerTxAdapterFactory('mongodb', createMongoTxAdapter)
|
|
registerAdapterFactory('mongodb', createMongoAdapter)
|
|
registerDestroyFactory('mongodb', createMongoDestroyAdapter)
|
|
|
|
registerTxAdapterFactory('postgresql', createPostgresTxAdapter, true)
|
|
registerAdapterFactory('postgresql', createPostgresAdapter, true)
|
|
registerDestroyFactory('postgresql', createPostgreeDestroyAdapter, true)
|
|
setAdapterSecurity('postgresql', true)
|
|
|
|
registerServerPlugins()
|
|
registerStringLoaders()
|
|
|
|
const serverSecret = process.env.SERVER_SECRET
|
|
if (serverSecret === undefined) {
|
|
console.error('please provide server secret')
|
|
process.exit(1)
|
|
}
|
|
|
|
const accountsUrl = process.env.ACCOUNTS_URL
|
|
if (accountsUrl === undefined) {
|
|
console.error('please provide accounts url.')
|
|
process.exit(1)
|
|
}
|
|
|
|
const transactorUrl = process.env.TRANSACTOR_URL
|
|
if (transactorUrl === undefined) {
|
|
console.error('please provide transactor url.')
|
|
}
|
|
|
|
setMetadata(accountPlugin.metadata.Transactors, transactorUrl)
|
|
setMetadata(serverClientPlugin.metadata.Endpoint, accountsUrl)
|
|
setMetadata(serverToken.metadata.Secret, serverSecret)
|
|
setMetadata(serverToken.metadata.Service, 'tool')
|
|
|
|
async function withAccountDatabase (
|
|
f: (db: AccountDB) => Promise<any>,
|
|
dbOverride?: string,
|
|
nsOverride?: string
|
|
): Promise<void> {
|
|
const uri = dbOverride ?? getAccountDBUrl()
|
|
const ns = nsOverride ?? process.env.ACCOUNT_DB_NS
|
|
|
|
const [accountDb, closeAccountsDb] = await getAccountDB(uri, ns)
|
|
try {
|
|
await f(accountDb)
|
|
} catch (err: any) {
|
|
console.error(err)
|
|
}
|
|
closeAccountsDb()
|
|
await shutdownMongo()
|
|
}
|
|
|
|
async function withStorage (f: (storageAdapter: StorageAdapter) => Promise<any>): Promise<void> {
|
|
const adapter = buildStorageFromConfig(storageConfigFromEnv())
|
|
try {
|
|
await f(adapter)
|
|
} catch (err: any) {
|
|
console.error(err)
|
|
}
|
|
await adapter.close()
|
|
}
|
|
|
|
program.version('0.0.1')
|
|
|
|
program.command('version').action(() => {
|
|
console.log(
|
|
`tools git_version: ${process.env.GIT_REVISION ?? ''} model_version: ${process.env.MODEL_VERSION ?? ''} ${JSON.stringify(getModelVersion())}`
|
|
)
|
|
})
|
|
|
|
// create-account john.appleseed@gmail.com --password 123 --workspace workspace --fullname "John Appleseed"
|
|
program
|
|
.command('create-account <email>')
|
|
.description('create user and corresponding account in master database')
|
|
.requiredOption('-p, --password <password>', 'user password')
|
|
.requiredOption('-f, --first <first>', 'first name')
|
|
.requiredOption('-l, --last <last>', 'last name')
|
|
.option('-n, --notconfirmed', 'creates not confirmed account', false)
|
|
.action(async (email: string, cmd: { password: string, first: string, last: string, notconfirmed: boolean }) => {
|
|
await withAccountDatabase(async (db) => {
|
|
console.log(`creating account ${cmd.first} ${cmd.last} (${email})...`)
|
|
await signUpByEmail(toolCtx, db, null, email, cmd.password, cmd.first, cmd.last, !cmd.notconfirmed)
|
|
})
|
|
})
|
|
|
|
program
|
|
.command('assign-workspace <email> <workspace>')
|
|
.description('assign workspace')
|
|
.action(async (email: string, workspace: string, cmd) => {
|
|
await withAccountDatabase(async (db) => {
|
|
console.log(`assigning user ${email} to ${workspace}...`)
|
|
try {
|
|
const ws = await getWorkspace(db, workspace)
|
|
if (ws === null) {
|
|
throw new Error(`Workspace ${workspace} not found`)
|
|
}
|
|
|
|
await assignWorkspace(toolCtx, db, null, getToolToken(), {
|
|
email,
|
|
workspaceUuid: ws.uuid,
|
|
role: AccountRole.User
|
|
})
|
|
} catch (err: any) {
|
|
console.error(err)
|
|
}
|
|
})
|
|
})
|
|
|
|
program
|
|
.command('create-workspace <name> <owner_social_id>')
|
|
.description('create workspace')
|
|
.option('-i, --init <ws>', 'Init from workspace')
|
|
.option('-r, --region <region>', 'Region')
|
|
.option('-d, --dataId <dataId>', 'DataId for workspace')
|
|
.option('-b, --branding <key>', 'Branding key')
|
|
.action(
|
|
async (
|
|
name,
|
|
socialString,
|
|
cmd: { account: string, init?: string, branding?: string, region?: string, dataId?: string }
|
|
) => {
|
|
const { txes, version, migrateOperations } = prepareTools()
|
|
await withAccountDatabase(async (db) => {
|
|
const measureCtx = new MeasureMetricsContext('create-workspace', {})
|
|
const brandingObj =
|
|
cmd.branding !== undefined || cmd.init !== undefined ? { key: cmd.branding, initWorkspace: cmd.init } : null
|
|
const socialId = await db.socialId.findOne({ key: socialString as PersonId })
|
|
if (socialId == null) {
|
|
throw new Error(`Social id ${socialString} not found`)
|
|
}
|
|
|
|
const res = await createWorkspaceRecord(
|
|
measureCtx,
|
|
db,
|
|
brandingObj,
|
|
name,
|
|
socialId.personUuid,
|
|
cmd.region,
|
|
'manual-creation',
|
|
cmd.dataId as WorkspaceDataId
|
|
)
|
|
const wsInfo = await getWorkspaceInfoWithStatusById(db, res.workspaceUuid)
|
|
|
|
if (wsInfo == null) {
|
|
throw new Error(`Created workspace record ${res.workspaceUuid} not found`)
|
|
}
|
|
const coreWsInfo = flattenStatus(wsInfo)
|
|
const accountClient = getAccountClient(getToolToken())
|
|
|
|
const queue = getPlatformQueue('tool', cmd.region)
|
|
const wsProducer = queue.getProducer<QueueWorkspaceMessage>(toolCtx, QueueTopic.Workspace)
|
|
|
|
await createWorkspace(
|
|
measureCtx,
|
|
version,
|
|
brandingObj,
|
|
coreWsInfo,
|
|
txes,
|
|
migrateOperations,
|
|
accountClient,
|
|
wsProducer,
|
|
undefined,
|
|
true
|
|
)
|
|
await updateWorkspaceInfo(measureCtx, db, brandingObj, getToolToken(), {
|
|
workspaceUuid: res.workspaceUuid,
|
|
event: 'create-done',
|
|
version,
|
|
progress: 100
|
|
})
|
|
|
|
await wsProducer.send(measureCtx, res.workspaceUuid, [workspaceEvents.created()])
|
|
await queue.shutdown()
|
|
console.log(queue)
|
|
})
|
|
}
|
|
)
|
|
|
|
program
|
|
.command('set-user-role <email> <workspace> <role>')
|
|
.description('set user role')
|
|
.action(async (email: string, workspace: string, role: AccountRole, cmd) => {
|
|
console.log(`set user ${email} role for ${workspace}...`)
|
|
await withAccountDatabase(async (db) => {
|
|
const rolesArray = ['DocGuest', 'GUEST', 'USER', 'MAINTAINER', 'OWNER']
|
|
if (!rolesArray.includes(role)) {
|
|
throw new Error(`Invalid role ${role}. Valid roles are ${rolesArray.join(', ')}`)
|
|
}
|
|
|
|
const ws = await getWorkspace(db, workspace)
|
|
if (ws === null) {
|
|
throw new Error(`Workspace ${workspace} not found`)
|
|
}
|
|
|
|
await assignWorkspace(toolCtx, db, null, getToolToken(), { email, workspaceUuid: ws.uuid, role })
|
|
})
|
|
})
|
|
|
|
async function doUpgrade (
|
|
toolCtx: MeasureMetricsContext,
|
|
workspace: WorkspaceUuid,
|
|
forceUpdate: boolean,
|
|
forceIndexes: boolean
|
|
): Promise<void> {
|
|
const { version, txes, migrateOperations } = prepareTools()
|
|
|
|
await withAccountDatabase(async (db) => {
|
|
const info = await getWorkspace(db, workspace)
|
|
if (info === null) {
|
|
throw new Error(`workspace ${workspace} not found`)
|
|
}
|
|
|
|
const wsInfo = await getWorkspaceInfoWithStatusById(db, info.uuid)
|
|
if (wsInfo === null) {
|
|
throw new Error(`workspace ${workspace} not found`)
|
|
}
|
|
|
|
const coreWsInfo = flattenStatus(wsInfo)
|
|
const measureCtx = new MeasureMetricsContext('upgrade-workspace', {})
|
|
const accountClient = getAccountClient(getToolToken(wsInfo.uuid))
|
|
const queue = getPlatformQueue('tool', info.region)
|
|
const wsProducer = queue.getProducer<QueueWorkspaceMessage>(toolCtx, QueueTopic.Workspace)
|
|
await upgradeWorkspace(
|
|
measureCtx,
|
|
version,
|
|
txes,
|
|
migrateOperations,
|
|
accountClient,
|
|
coreWsInfo,
|
|
consoleModelLogger,
|
|
wsProducer,
|
|
async () => {},
|
|
forceUpdate,
|
|
forceIndexes,
|
|
true
|
|
)
|
|
|
|
await updateWorkspaceInfo(measureCtx, db, null, getToolToken(), {
|
|
workspaceUuid: info.uuid,
|
|
event: 'upgrade-done',
|
|
version,
|
|
progress: 100
|
|
})
|
|
|
|
console.log(metricsToString(measureCtx.metrics, 'upgrade', 60))
|
|
|
|
await wsProducer.send(measureCtx, info.uuid, [workspaceEvents.upgraded()])
|
|
await queue.shutdown()
|
|
console.log('upgrade-workspace done')
|
|
})
|
|
}
|
|
|
|
program
|
|
.command('upgrade-workspace <name>')
|
|
.description('upgrade workspace')
|
|
.option('-f|--force [force]', 'Force update', true)
|
|
.option('-i|--indexes [indexes]', 'Force indexes rebuild', false)
|
|
.action(async (workspace, cmd: { force: boolean, indexes: boolean }) => {
|
|
await doUpgrade(toolCtx, workspace, cmd.force, cmd.indexes)
|
|
})
|
|
|
|
program
|
|
.command('backup <dirName> <workspace>')
|
|
.description('dump workspace transactions, blobs and accounts')
|
|
.option('-i, --include <include>', 'A list of ; separated domain names to include during backup', '*')
|
|
.option('-s, --skip <skip>', 'A list of ; separated domain names to skip during backup', '')
|
|
.option('--full', 'Full recheck', false)
|
|
.option(
|
|
'--ct, --contentTypes <contentTypes>',
|
|
'A list of ; separated content types for blobs to skip download if size >= limit',
|
|
''
|
|
)
|
|
.option('--bl, --blobLimit <blobLimit>', 'A blob size limit in megabytes (default 5mb)', '5')
|
|
.option('-f, --force', 'Force backup', false)
|
|
.option('-t, --timeout <timeout>', 'Connect timeout in seconds', '30')
|
|
.option('-k, --keepSnapshots <keepSnapshots>', 'Keep snapshots for days', '14')
|
|
.option('--fv, --fullVerify', 'Full verification', false)
|
|
.action(
|
|
async (
|
|
dirName: string,
|
|
workspace: string,
|
|
cmd: {
|
|
skip: string
|
|
force: boolean
|
|
timeout: string
|
|
include: string
|
|
blobLimit: string
|
|
contentTypes: string
|
|
full: boolean
|
|
keepSnapshots: string
|
|
fullVerify: boolean
|
|
}
|
|
) => {
|
|
const storage = await createFileBackupStorage(dirName)
|
|
await withAccountDatabase(async (db) => {
|
|
const { txes, dbUrl } = prepareTools()
|
|
const ws = await getWorkspace(db, workspace)
|
|
if (ws === null) {
|
|
throw new Error(`workspace ${workspace} not found`)
|
|
}
|
|
const wsIds = {
|
|
uuid: ws.uuid,
|
|
dataId: ws.dataId,
|
|
url: ws.url
|
|
}
|
|
const storageConfig = storageConfigFromEnv()
|
|
|
|
const workspaceStorage: StorageAdapter = buildStorageFromConfig(storageConfig)
|
|
|
|
let pipeline: Pipeline | undefined
|
|
try {
|
|
pipeline = await createBackupPipeline(toolCtx, dbUrl, txes, {
|
|
externalStorage: workspaceStorage,
|
|
usePassedCtx: true
|
|
})(
|
|
toolCtx,
|
|
{
|
|
uuid: ws.uuid,
|
|
url: ws.url ?? '',
|
|
dataId: ws.dataId
|
|
},
|
|
createEmptyBroadcastOps(),
|
|
null
|
|
)
|
|
if (pipeline === undefined) {
|
|
toolCtx.error('failed to restore, pipeline is undefined', { workspace })
|
|
return
|
|
}
|
|
|
|
await backup(toolCtx, pipeline, wsIds, storage, db, {
|
|
force: cmd.force,
|
|
include: cmd.include === '*' ? undefined : new Set(cmd.include.split(';').map((it) => it.trim())),
|
|
skipDomains: (cmd.skip ?? '').split(';').map((it) => it.trim()),
|
|
timeout: 0,
|
|
connectTimeout: parseInt(cmd.timeout) * 1000,
|
|
blobDownloadLimit: parseInt(cmd.blobLimit),
|
|
skipBlobContentTypes: cmd.contentTypes
|
|
.split(';')
|
|
.map((it) => it.trim())
|
|
.filter((it) => it.length > 0),
|
|
keepSnapshots: parseInt(cmd.keepSnapshots),
|
|
fullVerify: cmd.fullVerify
|
|
})
|
|
} catch (err: any) {
|
|
toolCtx.error('Failed to backup workspace', { err, workspace })
|
|
} finally {
|
|
if (pipeline !== undefined) {
|
|
await pipeline.close()
|
|
}
|
|
await workspaceStorage.close()
|
|
}
|
|
})
|
|
}
|
|
)
|
|
program
|
|
.command('backup-find <dirName> <fileId>')
|
|
.description('dump workspace transactions and minio resources')
|
|
.option('-d, --domain <domain>', 'Check only domain')
|
|
.option('-a, --all', 'Show all versions', false)
|
|
.action(async (dirName: string, fileId: string, cmd: { domain: string | undefined, all: boolean }) => {
|
|
const storage = await createFileBackupStorage(dirName)
|
|
console.log(cmd.all)
|
|
await backupFind(storage, fileId as unknown as Ref<Doc>, cmd.all, cmd.domain)
|
|
})
|
|
|
|
program
|
|
.command('backup-compact <dirName>')
|
|
.description('Compact a given backup, will create one snapshot clean unused resources')
|
|
.option('-f, --force', 'Force compact.', false)
|
|
.option(
|
|
'--ct, --contentTypes <contentTypes>',
|
|
'A list of ; separated content types for blobs to exclude from backup',
|
|
'video/;application/octet-stream;audio/;image/'
|
|
)
|
|
.option('-k, --keepSnapshots <keepSnapshots>', 'Keep snapshots for days', '14')
|
|
.action(async (dirName: string, cmd: { force: boolean, contentTypes: string, keepSnapshots: string }) => {
|
|
const storage = await createFileBackupStorage(dirName)
|
|
await compactBackup(toolCtx, storage, cmd.force, {
|
|
blobLimit: 5 * 1024 * 1024, // 5 MB
|
|
skipContentTypes: cmd.contentTypes.split(';')
|
|
})
|
|
})
|
|
program
|
|
.command('backup-check <dirName>')
|
|
.description('Compact a given backup, will create one snapshot clean unused resources')
|
|
.action(async (dirName: string, cmd: any) => {
|
|
const storage = await createFileBackupStorage(dirName)
|
|
await checkBackupIntegrity(toolCtx, storage)
|
|
})
|
|
|
|
program
|
|
.command('backup-check-all')
|
|
.description('Check Backup integrity')
|
|
.option('-r|--region [region]', 'Timeout in days', '')
|
|
.option('-w|--workspace [workspace]', 'Force backup of selected workspace', '')
|
|
.option('-s|--skip [skip]', 'A command separated list of workspaces to skip', '')
|
|
.option('-d|--dry [dry]', 'Dry run', false)
|
|
.action(async (cmd: { timeout: string, workspace: string, region: string, dry: boolean, skip: string }) => {
|
|
const bucketName = process.env.BUCKET_NAME
|
|
if (bucketName === '' || bucketName == null) {
|
|
console.error('please provide butket name env')
|
|
process.exit(1)
|
|
}
|
|
|
|
const skipWorkspaces = new Set(cmd.skip.split(',').map((it) => it.trim()))
|
|
|
|
const token = generateToken(systemAccountUuid, undefined, {
|
|
service: 'tool'
|
|
})
|
|
const workspaces = (await getAccountClient(token).listWorkspaces(cmd.region))
|
|
.sort((a, b) => {
|
|
const bsize = b.backupInfo?.backupSize ?? 0
|
|
const asize = a.backupInfo?.backupSize ?? 0
|
|
return bsize - asize
|
|
})
|
|
.filter((it) => (cmd.workspace === '' || cmd.workspace === it.url) && !skipWorkspaces.has(it.url))
|
|
|
|
const backupStorageConfig = storageConfigFromEnv(process.env.STORAGE)
|
|
const storageAdapter = createStorageFromConfig(backupStorageConfig.storages[0])
|
|
for (const ws of workspaces) {
|
|
const lastVisitDays = Math.floor((Date.now() - (ws.lastVisit ?? 0)) / 1000 / 3600 / 24)
|
|
|
|
toolCtx.warn('--- checking workspace backup', {
|
|
url: ws.url,
|
|
id: ws.uuid,
|
|
lastVisitDays,
|
|
backupSize: ws.backupInfo?.blobsSize ?? 0,
|
|
mode: ws.mode
|
|
})
|
|
if (cmd.dry) {
|
|
continue
|
|
}
|
|
try {
|
|
const st = Date.now()
|
|
|
|
try {
|
|
const storage = await createStorageBackupStorage(
|
|
toolCtx,
|
|
storageAdapter,
|
|
{
|
|
uuid: 'backup' as WorkspaceUuid,
|
|
url: 'backup',
|
|
dataId: bucketName as WorkspaceDataId
|
|
},
|
|
ws.dataId ?? ws.uuid
|
|
)
|
|
await checkBackupIntegrity(toolCtx, storage)
|
|
} catch (err: any) {
|
|
toolCtx.error('failed to size backup', { err })
|
|
}
|
|
const ed = Date.now()
|
|
toolCtx.warn('--- check complete', {
|
|
time: ed - st
|
|
})
|
|
} catch (err: any) {
|
|
toolCtx.error('Restore of f workspace failedarchive workspace', { workspace: ws.url })
|
|
}
|
|
}
|
|
await storageAdapter.close()
|
|
})
|
|
|
|
program
|
|
.command('backup-check-workspace <dirName> <workspace> [date]')
|
|
.description(
|
|
'Check whether all data from a backup is present in the workspace. Read-only: no data is added, removed or changed.'
|
|
)
|
|
.action(async (dirName: string, workspaceId: string, date: string | undefined) => {
|
|
await withAccountDatabase(async (db) => {
|
|
const { txes, dbUrl } = prepareTools()
|
|
const ws = await getWorkspace(db, workspaceId)
|
|
if (ws === null) {
|
|
throw new Error(`workspace ${workspaceId} not found`)
|
|
}
|
|
|
|
const wsIds = { uuid: ws.uuid, dataId: ws.dataId, url: ws.url ?? '' }
|
|
const storage = await createFileBackupStorage(dirName)
|
|
const workspaceStorage: StorageAdapter = buildStorageFromConfig(storageConfigFromEnv())
|
|
|
|
let pipeline: Pipeline | undefined
|
|
try {
|
|
pipeline = await createBackupPipeline(toolCtx, dbUrl, txes, {
|
|
externalStorage: workspaceStorage,
|
|
usePassedCtx: true
|
|
})(
|
|
toolCtx,
|
|
{
|
|
uuid: ws.uuid,
|
|
url: ws.url ?? '',
|
|
dataId: ws.dataId
|
|
},
|
|
createEmptyBroadcastOps(),
|
|
null
|
|
)
|
|
if (pipeline === undefined) {
|
|
toolCtx.error('failed to check, pipeline is undefined', { workspaceId })
|
|
process.exitCode = 1
|
|
return
|
|
}
|
|
|
|
const result = await checkWorkspaceBackup(toolCtx, pipeline, wsIds, storage, parseInt(date ?? '-1'))
|
|
|
|
console.log('')
|
|
for (const d of result.domains) {
|
|
const ok = d.missing.length === 0 && d.modified.length === 0
|
|
console.log(
|
|
`${ok ? 'OK ' : 'FAIL'} ${d.domain}: backup=${d.backupCount} workspace=${d.workspaceCount} missing=${d.missing.length} modified=${d.modified.length}`
|
|
)
|
|
}
|
|
console.log(
|
|
`${result.blobs.ok ? 'OK ' : 'FAIL'} blobs (storage): total=${result.blobs.total} missing=${result.blobs.missing.length}`
|
|
)
|
|
console.log('')
|
|
if (result.ok) {
|
|
console.log('OK: workspace contains all data from backup')
|
|
} else {
|
|
console.log('FAILED: workspace is missing data present in the backup')
|
|
process.exitCode = 1
|
|
}
|
|
} catch (err: any) {
|
|
toolCtx.error('failed to check workspace against backup', { err, workspaceId })
|
|
process.exitCode = 1
|
|
} finally {
|
|
await pipeline?.close()
|
|
await workspaceStorage?.close()
|
|
}
|
|
})
|
|
})
|
|
|
|
program
|
|
.command('validate-workspace <workspace>')
|
|
.description('Validate a (restored) workspace: connect as system, check model, data counts and blob download')
|
|
.option('--blobs <blobs>', 'Number of sample blobs to download-check (0 to skip)', '5')
|
|
.option('--blob-limit <blobLimit>', 'Stop enumerating blobs after this many (0 = iterate all)', '0')
|
|
.action(async (workspace: string, cmd: { blobs: string, blobLimit: string }) => {
|
|
await withAccountDatabase(async (db) => {
|
|
const ws = await getWorkspace(db, workspace)
|
|
if (ws === null) {
|
|
throw new Error(`workspace ${workspace} not found`)
|
|
}
|
|
const wsIds = { uuid: ws.uuid, dataId: ws.dataId, url: ws.url ?? '' }
|
|
|
|
const failures: string[] = []
|
|
const pass = (m: string): void => {
|
|
console.log(` ✓ ${m}`)
|
|
}
|
|
const fail = (m: string): void => {
|
|
console.error(` ✗ ${m}`)
|
|
failures.push(m)
|
|
}
|
|
const info = (m: string): void => {
|
|
console.log(` • ${m}`)
|
|
}
|
|
const warn = (m: string): void => {
|
|
console.warn(` ! ${m}`)
|
|
}
|
|
const emsg = (err: any): string => (err instanceof Error ? err.message : String(err))
|
|
|
|
console.log(`Validating workspace ${ws.url ?? ws.uuid} (${ws.uuid})`)
|
|
|
|
if ((process.env.STORAGE_CONFIG ?? '').trim() === '') {
|
|
warn('STORAGE_CONFIG is not set — storage falls back to default minio; blob results may be wrong')
|
|
}
|
|
|
|
const workspaceStorage: StorageAdapter = buildStorageFromConfig(storageConfigFromEnv())
|
|
let connection: Client | undefined
|
|
try {
|
|
// 1. Connectivity: resolve transactor endpoint and connect as system user
|
|
let endpoint: string
|
|
try {
|
|
endpoint = await getWorkspaceTransactorEndpoint(ws.uuid)
|
|
pass('resolved transactor endpoint')
|
|
} catch (err: any) {
|
|
fail(`cannot resolve transactor endpoint: ${emsg(err)}`)
|
|
process.exitCode = 1
|
|
return
|
|
}
|
|
|
|
try {
|
|
connection = await connect(endpoint, ws.uuid, undefined, { service: 'tool', admin: 'true' })
|
|
pass('connected to transactor as system user')
|
|
} catch (err: any) {
|
|
fail(`connect failed: ${emsg(err)}`)
|
|
process.exitCode = 1
|
|
return
|
|
}
|
|
|
|
const ops = new TxOperations(connection, core.account.System)
|
|
|
|
// 2. Model loads and hierarchy is populated
|
|
try {
|
|
const classes = ops.getHierarchy().getDescendants(core.class.Doc).length
|
|
if (classes > 0) {
|
|
pass(`model loaded: ${classes} classes in hierarchy`)
|
|
} else {
|
|
fail('model loaded but hierarchy is empty')
|
|
}
|
|
} catch (err: any) {
|
|
fail(`model load failed: ${emsg(err)}`)
|
|
}
|
|
|
|
// 3. Read path: count key domains
|
|
const countOf = async (label: string, _class: Ref<Class<Doc>>): Promise<number> => {
|
|
try {
|
|
const r = await ops.findAll(_class, {}, { limit: 1, total: true })
|
|
info(`${label}: ${r.total}`)
|
|
return r.total
|
|
} catch (err: any) {
|
|
fail(`findAll ${label} failed: ${emsg(err)}`)
|
|
return -1
|
|
}
|
|
}
|
|
await countOf('transactions (Tx)', core.class.Tx)
|
|
await countOf('spaces (Space)', core.class.Space)
|
|
|
|
// 4. Verify blobs count
|
|
const nBlobs = parseInt(cmd.blobs)
|
|
const blobLimit = parseInt(cmd.blobLimit)
|
|
const hasBlobLimit = !Number.isNaN(blobLimit) && blobLimit > 0
|
|
try {
|
|
const blobClient = new BlobClient(workspaceStorage, wsIds)
|
|
const iterator = await workspaceStorage.listStream(toolCtx, wsIds)
|
|
let blobCount = 0
|
|
let capped = false
|
|
const sample: Array<{ id: Ref<Doc>, size: number }> = []
|
|
try {
|
|
while (true) {
|
|
const batch = await iterator.next()
|
|
if (batch.length === 0) {
|
|
break
|
|
}
|
|
for (const b of batch) {
|
|
blobCount++
|
|
if (!Number.isNaN(nBlobs) && nBlobs > 0 && sample.length < nBlobs) {
|
|
sample.push({ id: b._id, size: b.size })
|
|
}
|
|
if (hasBlobLimit && blobCount >= blobLimit) {
|
|
capped = true
|
|
break
|
|
}
|
|
}
|
|
if (capped) {
|
|
break
|
|
}
|
|
}
|
|
} finally {
|
|
await iterator.close()
|
|
}
|
|
|
|
if (capped) {
|
|
info(`blobs in storage: >= ${blobCount} (stopped at --blob-limit ${blobLimit})`)
|
|
} else {
|
|
info(`blobs in storage: ${blobCount}`)
|
|
}
|
|
if (blobCount === 0) {
|
|
fail(
|
|
'no blobs found in storage — either the workspace has no files, ' +
|
|
"or STORAGE_CONFIG does not point at this workspace's storage"
|
|
)
|
|
} else if (!Number.isNaN(nBlobs) && nBlobs > 0) {
|
|
let okCount = 0
|
|
for (const s of sample) {
|
|
try {
|
|
let bytes = 0
|
|
await blobClient.writeTo(toolCtx, s.id, s.size, {
|
|
write: (buffer: Buffer, cb: (err?: any) => void) => {
|
|
bytes += buffer.length
|
|
cb()
|
|
},
|
|
end: (cb: () => void) => {
|
|
cb()
|
|
}
|
|
})
|
|
if (bytes === s.size) {
|
|
okCount++
|
|
} else {
|
|
fail(`blob ${s.id}: downloaded ${bytes} of ${s.size} bytes`)
|
|
}
|
|
} catch (err: any) {
|
|
fail(`blob ${s.id} download failed: ${emsg(err)}`)
|
|
}
|
|
}
|
|
if (okCount === sample.length && okCount > 0) {
|
|
pass(`downloaded ${okCount}/${sample.length} sample blobs, sizes match metadata`)
|
|
}
|
|
}
|
|
} catch (err: any) {
|
|
fail(`blob storage check failed: ${emsg(err)}`)
|
|
}
|
|
} finally {
|
|
await connection?.close()
|
|
await workspaceStorage.close()
|
|
}
|
|
|
|
if (failures.length > 0) {
|
|
console.error(`\nVALIDATION FAILED: ${failures.length} issue(s)`)
|
|
process.exitCode = 1
|
|
} else {
|
|
console.log('\nVALIDATION PASSED')
|
|
}
|
|
})
|
|
})
|
|
|
|
program
|
|
.command('backup-restore <dirName> <workspace> [date]')
|
|
.option('-m, --merge', 'Enable merge of remote and backup content.', false)
|
|
.option('-p, --parallel <parallel>', 'Enable merge of remote and backup content.', '1')
|
|
.option('-c, --recheck', 'Force hash recheck on server', false)
|
|
.option('-i, --include <include>', 'A list of ; separated domain names to include during backup', '*')
|
|
.option('-s, --skip <skip>', 'A list of ; separated domain names to skip during backup', '')
|
|
.option('--upgrade', 'Upgrade workspace', false)
|
|
.option('--noqueue', 'NoQueue', false)
|
|
.option('--accounts', 'Restore accounts (person/socialId) from backup', false)
|
|
.option(
|
|
'--history-file <historyFile>',
|
|
'Store blob send info into file. Will skip already send documents.',
|
|
undefined
|
|
)
|
|
.description('dump workspace transactions and minio resources')
|
|
.action(
|
|
async (
|
|
dirName: string,
|
|
workspaceId: string,
|
|
date,
|
|
cmd: {
|
|
merge: boolean
|
|
parallel: string
|
|
recheck: boolean
|
|
include: string
|
|
skip: string
|
|
useStorage: string
|
|
historyFile: string
|
|
upgrade: boolean
|
|
noqueue: boolean
|
|
accounts: boolean
|
|
}
|
|
) => {
|
|
if ((process.env.STORAGE_CONFIG ?? '').trim() === '') {
|
|
console.warn(
|
|
'WARNING: STORAGE_CONFIG is not set — blob storage falls back to default minio. ' +
|
|
'Restored blobs may be written to the wrong storage. Set STORAGE_CONFIG to match the transactor before restoring.'
|
|
)
|
|
}
|
|
await withAccountDatabase(async (db) => {
|
|
const { txes, dbUrl } = prepareTools()
|
|
const ws = await getWorkspace(db, workspaceId)
|
|
if (ws === null) {
|
|
throw new Error(`workspace ${workspaceId} not found`)
|
|
}
|
|
|
|
const workspace = ws.uuid
|
|
const wsIds = {
|
|
uuid: ws.uuid,
|
|
dataId: ws.dataId,
|
|
url: ws.url
|
|
}
|
|
const storage = await createFileBackupStorage(dirName)
|
|
const storageConfig = storageConfigFromEnv()
|
|
|
|
const queue = !cmd.noqueue ? getPlatformQueue('tool', ws.region) : undefined
|
|
const wsProducer = queue?.getProducer<QueueWorkspaceMessage>(toolCtx, QueueTopic.Workspace)
|
|
|
|
await wsProducer?.send(toolCtx, ws.uuid, [workspaceEvents.restoring()])
|
|
|
|
const workspaceStorage: StorageAdapter = buildStorageFromConfig(storageConfig)
|
|
|
|
let pipeline: Pipeline | undefined
|
|
try {
|
|
pipeline = await createBackupPipeline(toolCtx, dbUrl, txes, {
|
|
externalStorage: workspaceStorage,
|
|
usePassedCtx: true
|
|
})(
|
|
toolCtx,
|
|
{
|
|
uuid: ws.uuid,
|
|
url: ws.url ?? '',
|
|
dataId: ws.dataId
|
|
},
|
|
createEmptyBroadcastOps(),
|
|
null
|
|
)
|
|
if (pipeline === undefined) {
|
|
toolCtx.error('failed to restore, pipeline is undefined', { workspaceId })
|
|
return
|
|
}
|
|
await sendTransactorEvent(workspace, 'force-maintenance')
|
|
|
|
await restore(toolCtx, pipeline, wsIds, storage, cmd.accounts ? db : undefined, {
|
|
date: parseInt(date ?? '-1'),
|
|
merge: cmd.merge,
|
|
parallel: parseInt(cmd.parallel ?? '1'),
|
|
recheck: cmd.recheck,
|
|
include: cmd.include === '*' ? undefined : new Set(cmd.include.split(';')),
|
|
skip: new Set(cmd.skip.split(';')),
|
|
historyFile: cmd.historyFile
|
|
})
|
|
|
|
if (cmd.upgrade) {
|
|
await doUpgrade(toolCtx, workspace, true, true)
|
|
} else {
|
|
await sendTransactorEvent(workspace, 'force-close')
|
|
}
|
|
|
|
console.log('workspace restored')
|
|
await wsProducer?.send(toolCtx, ws.uuid, [workspaceEvents.restored()])
|
|
} catch (err) {
|
|
toolCtx.error('failed to restore', { err })
|
|
}
|
|
await pipeline?.close()
|
|
await queue?.shutdown()
|
|
await workspaceStorage?.close()
|
|
})
|
|
}
|
|
)
|
|
|
|
program
|
|
.command('backup-s3-compact <bucketName> <dirName>')
|
|
.description('Compact a given backup to just one snapshot')
|
|
.option('-f, --force', 'Force compact.', false)
|
|
.option(
|
|
'--ct, --contentTypes <contentTypes>',
|
|
'A list of ; separated content types for blobs to exclude from backup',
|
|
'video/;application/octet-stream;audio/;image/'
|
|
)
|
|
.action(async (bucketName: string, dirName: string, cmd: { force: boolean, contentTypes: string }) => {
|
|
const backupStorageConfig = storageConfigFromEnv(process.env.STORAGE)
|
|
const storageAdapter = createStorageFromConfig(backupStorageConfig.storages[0])
|
|
const backupIds = { uuid: bucketName as WorkspaceUuid, dataId: bucketName as WorkspaceDataId, url: '' }
|
|
try {
|
|
const storage = await createStorageBackupStorage(toolCtx, storageAdapter, backupIds, dirName)
|
|
await compactBackup(
|
|
toolCtx,
|
|
storage,
|
|
cmd.force,
|
|
{
|
|
blobLimit: 5 * 1024 * 1024, // 5 MB
|
|
skipContentTypes: cmd.contentTypes !== undefined ? cmd.contentTypes.split(';') : undefined
|
|
},
|
|
true
|
|
)
|
|
} catch (err: any) {
|
|
toolCtx.error('failed to size backup', { err })
|
|
}
|
|
await storageAdapter.close()
|
|
})
|
|
|
|
program
|
|
.command('backup-s3-download <bucketName> <dirName> <storeIn>')
|
|
.description('Download a full backup from s3 to local dir')
|
|
.option('-s, --skip <skip>', 'skip downloading of these files', '')
|
|
.action(async (bucketName: string, dirName: string, storeIn: string, cmd) => {
|
|
const backupStorageConfig = storageConfigFromEnv(process.env.STORAGE)
|
|
const storageAdapter = createStorageFromConfig(backupStorageConfig.storages[0])
|
|
const backupIds = { uuid: bucketName as WorkspaceUuid, dataId: bucketName as WorkspaceDataId, url: '' }
|
|
try {
|
|
const storage = await createStorageBackupStorage(toolCtx, storageAdapter, backupIds, dirName)
|
|
console.log('downloading backup...', cmd.skip)
|
|
await backupDownload(storage, storeIn, new Set(cmd.skip.split(';')))
|
|
} catch (err: any) {
|
|
toolCtx.error('failed to download backup', { err })
|
|
}
|
|
await storageAdapter.close()
|
|
})
|
|
|
|
program
|
|
.command('generate-token <name> <workspace>')
|
|
.description('generate token')
|
|
.option('--admin', 'Generate token with admin access', false)
|
|
.action(async (name: string, workspace: string, opt: { admin: boolean }) => {
|
|
await withAccountDatabase(async (db) => {
|
|
if (name === systemAccountEmail) {
|
|
name = systemAccountUuid
|
|
}
|
|
const wsByUrl = await db.workspace.findOne({ url: workspace })
|
|
const account = await db.socialId.findOne({ value: name })
|
|
console.log(
|
|
generateToken(account?.personUuid ?? (name as AccountUuid), wsByUrl?.uuid ?? (workspace as WorkspaceUuid), {
|
|
...(opt.admin ? { admin: 'true' } : {})
|
|
})
|
|
)
|
|
})
|
|
})
|
|
program
|
|
.command('profile <endpoint> <mode>')
|
|
.description('Enable or disable profiling')
|
|
.option('-o, --output <output>', 'Output file', 'profile.cpuprofile')
|
|
.action(async (endpoint: string, mode: string, opt: { output: string }) => {
|
|
const token = generateToken(systemAccountUuid, undefined, { admin: 'true' })
|
|
if (mode === 'start') {
|
|
await fetch(`${endpoint}/api/v1/manage?token=${token}&operation=profile-start`, {
|
|
method: 'PUT'
|
|
})
|
|
} else {
|
|
const resp = await fetch(`${endpoint}/api/v1/manage?token=${token}&operation=profile-stop`, {
|
|
method: 'PUT'
|
|
})
|
|
if (resp.ok) {
|
|
const bdir = dirname(opt.output)
|
|
if (!existsSync(bdir)) {
|
|
await mkdir(bdir, { recursive: true })
|
|
}
|
|
const bytes = await resp.arrayBuffer()
|
|
console.log('writing to', opt.output)
|
|
await writeFile(opt.output, new Uint8Array(bytes))
|
|
} else {
|
|
console.error('failed to stop profile', resp.headers)
|
|
}
|
|
}
|
|
})
|
|
|
|
program
|
|
.command('generate-persons <workspace>')
|
|
.description('generate a random persons into workspace')
|
|
.option('--admin', 'Generate token with admin access', false)
|
|
.option('--count <count>', 'Number of persons to generate', '1000')
|
|
.action(async (workspace: string, opt: { admin: boolean, count: string }) => {
|
|
const count = parseInt(opt.count)
|
|
const token = generateToken(systemAccountUuid, workspace as WorkspaceUuid, {
|
|
...(opt.admin ? { admin: 'true', service: 'tool' } : { service: 'tool' })
|
|
})
|
|
const endpoint = await getTransactorEndpoint(token, 'external')
|
|
const client = createRestClient(endpoint, workspace, token)
|
|
for (let i = 0; i < count; i++) {
|
|
const email = `${faker.internet.email()}`
|
|
await client.ensurePerson(SocialIdType.EMAIL, email, faker.person.firstName(), faker.person.lastName())
|
|
}
|
|
})
|
|
|
|
program
|
|
.command('decode-token <token>')
|
|
.description('decode token')
|
|
.action(async (token) => {
|
|
console.log(decodeToken(token))
|
|
})
|
|
|
|
program
|
|
.command('configure <workspace>')
|
|
.description('clean archived spaces')
|
|
.option('--enable <enable>', 'Enable plugin configuration', '')
|
|
.option('--disable <disable>', 'Disable plugin configuration', '')
|
|
.option('--list', 'List plugin states', false)
|
|
.action(async (workspace: string, cmd: { enable: string, disable: string, list: boolean }) => {
|
|
await withAccountDatabase(async (db) => {
|
|
console.log(JSON.stringify(cmd))
|
|
const ws = await getWorkspace(db, workspace)
|
|
if (ws === null) {
|
|
throw new Error(`workspace ${workspace} not found`)
|
|
}
|
|
|
|
await changeConfiguration(ws.uuid, await getWorkspaceTransactorEndpoint(ws.uuid), cmd)
|
|
})
|
|
})
|
|
|
|
program
|
|
.command('change-field <workspace>')
|
|
.description('change field value for the object')
|
|
.requiredOption('--objectId <objectId>', 'objectId')
|
|
.requiredOption('--objectClass <objectClass>')
|
|
.requiredOption('--attribute <attribute>')
|
|
.requiredOption('--type <type>', 'number | string')
|
|
.requiredOption('--value <value>')
|
|
.action(
|
|
async (
|
|
workspace: string,
|
|
cmd: { objectId: string, objectClass: string, type: string, attribute: string, value: string, domain: string }
|
|
) => {
|
|
await withAccountDatabase(async (db) => {
|
|
const ws = await getWorkspace(db, workspace)
|
|
if (ws === null) {
|
|
throw new Error(`workspace ${workspace} not found`)
|
|
}
|
|
await updateField(ws.uuid, await getWorkspaceTransactorEndpoint(ws.uuid), cmd)
|
|
})
|
|
}
|
|
)
|
|
|
|
program
|
|
.command('fulltext-reindex <workspace>')
|
|
.description('reindex workspace')
|
|
.action(async (workspace: string) => {
|
|
const fulltextUrl = process.env.FULLTEXT_URL
|
|
if (fulltextUrl === undefined) {
|
|
console.error('please provide FULLTEXT_URL')
|
|
process.exit(1)
|
|
}
|
|
|
|
await withAccountDatabase(async (db) => {
|
|
const ws = await getWorkspace(db, workspace)
|
|
|
|
if (ws == null) {
|
|
throw new Error(`workspace ${workspace} not found`)
|
|
}
|
|
|
|
console.log('reindex workspace', workspace)
|
|
const queue = getPlatformQueue('tool', ws.region)
|
|
const wsProducer = queue.getProducer<QueueWorkspaceMessage>(toolCtx, QueueTopic.Workspace)
|
|
await wsProducer.send(toolCtx, ws.uuid, [workspaceEvents.fullReindex()])
|
|
await queue.shutdown()
|
|
console.log('done', workspace)
|
|
})
|
|
})
|
|
|
|
program
|
|
.command('fulltext-reindex-all')
|
|
.description('reindex workspaces')
|
|
.action(async () => {
|
|
const fulltextUrl = process.env.FULLTEXT_URL
|
|
if (fulltextUrl === undefined) {
|
|
console.error('please provide FULLTEXT_URL')
|
|
process.exit(1)
|
|
}
|
|
|
|
let workspaces: Workspace[] = []
|
|
|
|
await withAccountDatabase(async (db) => {
|
|
const statuses = await db.workspaceStatus.find({ mode: 'active', isDisabled: false })
|
|
const statusByWs = new Map(statuses.map((it) => [it.workspaceUuid, it]))
|
|
|
|
workspaces = await db.workspace.find({})
|
|
workspaces = workspaces.filter((p) => statusByWs.has(p.uuid))
|
|
workspaces.sort((a, b) => {
|
|
const sa = statusByWs.get(a.uuid)
|
|
const sb = statusByWs.get(b.uuid)
|
|
return (sb?.lastVisit ?? 0) - (sa?.lastVisit ?? 0)
|
|
})
|
|
})
|
|
|
|
console.log('found workspaces', workspaces.length)
|
|
for (const ws of workspaces) {
|
|
console.log('reindex workspace', ws)
|
|
const queue = getPlatformQueue('tool', ws.region)
|
|
const wsProducer = queue.getProducer<QueueWorkspaceMessage>(toolCtx, QueueTopic.Workspace)
|
|
await wsProducer.send(toolCtx, ws.uuid, [workspaceEvents.fullReindex()])
|
|
await queue.shutdown()
|
|
}
|
|
console.log('done')
|
|
})
|
|
|
|
program.command('move-account-db-to-pg').action(async () => {
|
|
const { dbUrl } = prepareTools()
|
|
const mongodbUri = getMongoDBUrl()
|
|
|
|
if (mongodbUri === dbUrl) {
|
|
throw new Error('MONGO_URL and DB_URL are the same')
|
|
}
|
|
|
|
const mongoNs = process.env.OLD_ACCOUNTS_NS
|
|
|
|
await withAccountDatabase(async (pgDb) => {
|
|
await withAccountDatabase(
|
|
async (mongoDb) => {
|
|
await moveAccountDbFromMongoToPG(toolCtx, mongoDb, pgDb)
|
|
},
|
|
mongodbUri,
|
|
mongoNs
|
|
)
|
|
}, dbUrl)
|
|
})
|
|
|
|
program
|
|
.command('migrate-created-modified-by')
|
|
.option('--include-domains <includeDomains>', 'Domains to migrate(comma-separated)')
|
|
.option('--exclude-domains <excludeDomains>', 'Domains to skip migration for(comma-separated)')
|
|
.option('--lifetime <lifetime>', 'Max lifetime for the connection in seconds')
|
|
.option('--batch <batch>', 'Batch size')
|
|
.option('--force <force>', 'Force update', false)
|
|
.option('--max-reconnects <maxReconnects>', 'Max reconnects', '30')
|
|
.option('--max-retries <maxRetries>', 'Max reconnects', '50')
|
|
.option('--workspaces <workspaces>', 'Workspaces to migrate(comma-separated)')
|
|
.action(
|
|
async (cmd: {
|
|
includeDomains?: string
|
|
excludeDomains?: string
|
|
lifetime?: string
|
|
batch?: string
|
|
workspaces?: string
|
|
force: boolean
|
|
maxReconnects: string
|
|
maxRetries: string
|
|
}) => {
|
|
const { dbUrl } = prepareTools()
|
|
const includeDomains = cmd.includeDomains?.split(',').map((d) => d.trim())
|
|
const excludeDomains = cmd.excludeDomains?.split(',').map((d) => d.trim())
|
|
const maxLifetime = cmd.lifetime != null ? parseInt(cmd.lifetime) : undefined
|
|
const batchSize = cmd.batch != null ? parseInt(cmd.batch) : undefined
|
|
const maxReconnects = parseInt(cmd.maxReconnects)
|
|
const maxRetries = parseInt(cmd.maxRetries)
|
|
const wsUuids = cmd.workspaces?.split(',').map((it) => it.trim()) as WorkspaceUuid[]
|
|
|
|
await withAccountDatabase(async (accDb) => {
|
|
const rawWorkspaces =
|
|
wsUuids != null && wsUuids.length > 0
|
|
? await getWorkspacesInfoWithStatusByIds(accDb, wsUuids)
|
|
: await getWorkspaces(accDb, null, null, null)
|
|
const workspaces = rawWorkspaces
|
|
.filter((it) => !isArchivingMode(it.status.mode) && !isDeletingMode(it.status.mode))
|
|
.sort((a, b) => (b.status.lastVisit ?? 0) - (a.status.lastVisit ?? 0))
|
|
|
|
toolCtx.info('Workspaces found', { count: workspaces.length })
|
|
|
|
for (const workspace of workspaces) {
|
|
await migrateCreatedModifiedBy(
|
|
toolCtx,
|
|
dbUrl,
|
|
workspace,
|
|
includeDomains,
|
|
excludeDomains,
|
|
maxLifetime,
|
|
batchSize,
|
|
cmd.force,
|
|
maxReconnects,
|
|
maxRetries
|
|
)
|
|
}
|
|
})
|
|
}
|
|
)
|
|
|
|
program.command('ensure-global-persons-for-local-accounts').action(async () => {
|
|
const { dbUrl } = prepareTools()
|
|
|
|
await withAccountDatabase(async (accDb) => {
|
|
await ensureGlobalPersonsForLocalAccounts(toolCtx, dbUrl, accDb)
|
|
}, dbUrl)
|
|
})
|
|
|
|
program.command('migrate-merged-accounts').action(async () => {
|
|
const { dbUrl } = prepareTools()
|
|
|
|
await withAccountDatabase(async (accDb) => {
|
|
await migrateMergedAccounts(toolCtx, dbUrl, accDb)
|
|
}, dbUrl)
|
|
})
|
|
|
|
program.command('filter-merged-accounts-in-members').action(async () => {
|
|
const { dbUrl } = prepareTools()
|
|
|
|
await withAccountDatabase(async (accDb) => {
|
|
await filterMergedAccountsInMembers(toolCtx, dbUrl, accDb)
|
|
}, dbUrl)
|
|
})
|
|
|
|
program
|
|
.command('migrate-github-account')
|
|
.option('--db <db>', 'Github DB', '%github')
|
|
.option('--region <region>', 'Github DB')
|
|
.action(async (cmd: { db: string, region?: string }) => {
|
|
const mongodbUri = getMongoDBUrl()
|
|
const client = getMongoClient(mongodbUri)
|
|
const _client = await client.getClient()
|
|
|
|
const { dbUrl, txes } = prepareTools()
|
|
|
|
await performGithubAccountMigrations(_client.db(cmd.db), dbUrl, txes, cmd.region ?? null)
|
|
await _client.close()
|
|
client.close()
|
|
})
|
|
|
|
program
|
|
.command('queue-init-topics')
|
|
.description('create required kafka topics')
|
|
.option('--tx <tx>', 'Number of TX partitions', '5')
|
|
.action(async (cmd: { tx: string }) => {
|
|
const queue = getPlatformQueue('tool')
|
|
await queue.createTopics(parseInt(cmd.tx ?? '1'))
|
|
})
|
|
|
|
program
|
|
.command('migrate-gmail-account')
|
|
.option('--db <db>', 'DB name', 'gmail-service')
|
|
.option('--region <region>', 'DB region')
|
|
.action(async (cmd: { db: string, region?: string }) => {
|
|
const mongodbUri = getMongoDBUrl()
|
|
const client = getMongoClient(mongodbUri)
|
|
const _client = await client.getClient()
|
|
|
|
const kvsUrl = getKvsUrl()
|
|
const { dbUrl, txes } = prepareTools()
|
|
|
|
await performGmailAccountMigrations(_client.db(cmd.db), dbUrl, cmd.region ?? null, kvsUrl, txes)
|
|
await _client.close()
|
|
client.close()
|
|
})
|
|
|
|
program
|
|
.command('migrate-calendar-integrations-data')
|
|
.option('--db <db>', 'DB name', 'calendar-service')
|
|
.option('--region <region>', 'DB region')
|
|
.action(async (cmd: { db: string, region?: string }) => {
|
|
const mongodbUri = getMongoDBUrl()
|
|
const client = getMongoClient(mongodbUri)
|
|
const _client = await client.getClient()
|
|
|
|
const kvsUrl = getKvsUrl()
|
|
await performCalendarAccountMigrations(_client.db(cmd.db), cmd.region ?? null, kvsUrl)
|
|
await _client.close()
|
|
client.close()
|
|
})
|
|
|
|
program
|
|
.command('restore-markup-refs')
|
|
.option('--region <region>', 'DB region')
|
|
.action(async (cmd: { region?: string }) => {
|
|
const { dbUrl, txes } = prepareTools()
|
|
const region = cmd.region ?? null
|
|
|
|
await withStorage(async (adapter) => {
|
|
await restoreMarkupRefs(dbUrl, txes, adapter, region)
|
|
})
|
|
})
|
|
|
|
program
|
|
.command('restore-github-integrations')
|
|
.option('-d, --dryrun', 'Dry run', false)
|
|
.action(async (cmd: { dryrun: boolean }) => {
|
|
const { dbUrl } = prepareTools()
|
|
|
|
await restoreGithubIntegrations(dbUrl, cmd.dryrun)
|
|
})
|
|
|
|
program
|
|
.command('migrate-trusted-v6-accounts')
|
|
.description('Migrate trusted v6 accounts')
|
|
.option('-s|--skip [skip]', 'A command separated list of workspaces to skip', '')
|
|
.option('-d|--dry [dry]', 'Dry run', false)
|
|
.action(async (cmd: { skip: string, dry: boolean }) => {
|
|
const { dbUrl } = prepareTools()
|
|
const mongodbUri = getMongoDBUrl()
|
|
|
|
if (mongodbUri === dbUrl) {
|
|
throw new Error('MONGO_URL and DB_URL are the same')
|
|
}
|
|
|
|
const mongoNs = process.env.OLD_ACCOUNTS_NS
|
|
const skipWorkspaces = new Set(cmd.skip.split(',').map((it) => it.trim()))
|
|
|
|
await withAccountDatabase(async (pgDb) => {
|
|
const [v6MongoAccountDb, closeMongoAccountDb] = await getMongoAccountDB(mongodbUri, mongoNs)
|
|
try {
|
|
await migrateTrustedV6Accounts(toolCtx, pgDb, v6MongoAccountDb, cmd.dry, skipWorkspaces)
|
|
} finally {
|
|
closeMongoAccountDb()
|
|
}
|
|
}, dbUrl)
|
|
})
|
|
|
|
program
|
|
.command('restore-from-v6-all <dirName>')
|
|
.description('Restore from full v6 dump')
|
|
.action(async (dirName) => {
|
|
const { txes, dbUrl } = prepareTools()
|
|
|
|
await withAccountDatabase(async (pgDb) => {
|
|
await restoreFromv6All(toolCtx, pgDb, dirName, txes, dbUrl)
|
|
}, dbUrl)
|
|
})
|
|
|
|
program
|
|
.command('restore-v6-from-storage <workspace> <accsRoot>')
|
|
.description('Restore a workspace from v6 backup storage with accounts info')
|
|
.option('-r, --region <region>', 'Region to restore workspace to')
|
|
.option('-b, --branding <branding>', 'Branding to restore workspace with', 'huly')
|
|
.option('-s, --suffix <suffix>', 'Url suffix if conflicting', 'bold')
|
|
.option('-f, --force', 'Force restore if the same uuid', false)
|
|
.action(async (workspace, accsRoot, cmd: { suffix: string, region: string, branding: string, force: boolean }) => {
|
|
const bucketName = process.env.BUCKET_NAME
|
|
if (bucketName === '' || bucketName == null) {
|
|
console.error('please provide bucket name env')
|
|
process.exit(1)
|
|
}
|
|
|
|
const backupStorageConfig = storageConfigFromEnv(process.env.BACKUP_STORAGE)
|
|
const backupStorageAdapter = createStorageFromConfig(backupStorageConfig.storages[0])
|
|
const backupIds = { uuid: bucketName as WorkspaceUuid, dataId: bucketName as WorkspaceDataId, url: '' }
|
|
const backupAccsStorage = await createStorageBackupStorage(toolCtx, backupStorageAdapter, backupIds, accsRoot)
|
|
const v6AccountsFile = 'account.accounts.json'
|
|
const v6WorkspacesFile = 'account.workspaces.json'
|
|
const v6InvitesFile = 'account.invites.json'
|
|
|
|
if (!(await backupAccsStorage.exists(v6AccountsFile))) {
|
|
toolCtx.error('file not present', { file: v6AccountsFile })
|
|
throw new Error(`${v6AccountsFile} should be present to restore`)
|
|
}
|
|
if (!(await backupAccsStorage.exists(v6WorkspacesFile))) {
|
|
toolCtx.error('file not present', { file: v6WorkspacesFile })
|
|
throw new Error(`${v6WorkspacesFile} should be present to restore`)
|
|
}
|
|
if (!(await backupAccsStorage.exists(v6InvitesFile))) {
|
|
toolCtx.error('file not present', { file: v6InvitesFile })
|
|
throw new Error(`${v6InvitesFile} should be present to restore`)
|
|
}
|
|
|
|
const v6Workspaces = JSON.parse((await backupAccsStorage.loadFile(v6WorkspacesFile)).toString()) as OldWorkspace[]
|
|
const v6Workspace = v6Workspaces.find((it) => it.workspace === workspace)
|
|
|
|
if (v6Workspace == null) {
|
|
toolCtx.error('workspace not found in the accounts backup', { workspace })
|
|
throw new Error(`workspace ${workspace} not found in the accounts backup`)
|
|
}
|
|
|
|
const uniqueWorkspaceAccounts = new Set((v6Workspace.accounts ?? []).map((it) => it.toString()))
|
|
const v6AccountsRaw = JSON.parse((await backupAccsStorage.loadFile(v6AccountsFile)).toString()) as any[]
|
|
const v6WorkspaceAccountsRaw = v6AccountsRaw.filter((acc) => uniqueWorkspaceAccounts.has(acc._id.toString()))
|
|
|
|
const v6WorkspaceAccounts: OldAccount[] = []
|
|
for (const rawAccount of v6WorkspaceAccountsRaw) {
|
|
const hashTypedArray = rawAccount.hash != null ? new Uint8Array(rawAccount.hash.data) : null
|
|
const saltTypedArray = new Uint8Array(rawAccount.salt.data)
|
|
|
|
v6WorkspaceAccounts.push({
|
|
...rawAccount,
|
|
hash: hashTypedArray != null ? Buffer.from(hashTypedArray.buffer) : null,
|
|
salt: Buffer.from(saltTypedArray.buffer)
|
|
})
|
|
}
|
|
|
|
let v6Invites = JSON.parse((await backupAccsStorage.loadFile(v6InvitesFile)).toString()) as any[]
|
|
v6Invites = v6Invites.filter((invite: any) => invite.workspace.name === v6Workspace.workspace)
|
|
|
|
const { txes, dbUrl } = prepareTools()
|
|
const backupWsStorage = await createStorageBackupStorage(
|
|
toolCtx,
|
|
backupStorageAdapter,
|
|
backupIds,
|
|
v6Workspace.uuid ?? v6Workspace.workspace
|
|
)
|
|
|
|
const storageConfig = storageConfigFromEnv()
|
|
const workspaceStorage: StorageAdapter = buildStorageFromConfig(storageConfig)
|
|
const { suffix, region, branding, force } = cmd
|
|
|
|
await withAccountDatabase(async (pgDb) => {
|
|
await restoreTrustedV6Workspace(
|
|
toolCtx,
|
|
pgDb,
|
|
v6Workspace,
|
|
v6WorkspaceAccounts,
|
|
v6Invites,
|
|
backupWsStorage,
|
|
workspaceStorage,
|
|
txes,
|
|
dbUrl,
|
|
{ conflictSuffix: suffix, region, branding, force }
|
|
)
|
|
}, dbUrl)
|
|
})
|
|
|
|
program
|
|
.command('migrate-chat-to-communication')
|
|
.description('Migrate old chat to new communication')
|
|
.option('-w, --workspace <workspace>', 'Workspace to migrate')
|
|
.action(async (cmd: { workspace?: WorkspaceUuid }) => {
|
|
const { dbUrl, txes } = prepareTools()
|
|
const hulylakeUrl = process.env.HULYLAKE_URL ?? ''
|
|
|
|
const workspace = cmd.workspace
|
|
console.log('Workspace', workspace)
|
|
|
|
if (hulylakeUrl === '') {
|
|
throw new Error('HULYLAKE_URL should be specified')
|
|
}
|
|
|
|
const token = generateToken(systemAccountUuid, undefined, {
|
|
service: 'tool'
|
|
})
|
|
const db = getDBClient(dbUrl, undefined, 'tool')
|
|
const dbClient = await db.getClient()
|
|
const accountClient = getAccountClient(token)
|
|
const personUuidBySocialId = new Map<PersonId, PersonUuid>()
|
|
const storageConfig = storageConfigFromEnv()
|
|
const storage: StorageAdapter = buildStorageFromConfig(storageConfig)
|
|
|
|
await withAccountDatabase(async (accountDb) => {
|
|
const workspaces =
|
|
workspace != null
|
|
? await accountDb.workspaceStatus.find({ workspaceUuid: workspace })
|
|
: await accountDb.workspaceStatus.find({}, { lastVisit: 'descending' })
|
|
for (const wss of workspaces) {
|
|
try {
|
|
const ws = await accountDb.workspace.findOne({ uuid: wss.workspaceUuid })
|
|
if (ws == null) continue
|
|
const hulylake = getHulylakeClient(hulylakeUrl, ws.uuid, token)
|
|
|
|
let pipeline: Pipeline | undefined
|
|
try {
|
|
pipeline = await createBackupPipeline(toolCtx, dbUrl, txes, {
|
|
externalStorage: storage,
|
|
usePassedCtx: true
|
|
})(
|
|
toolCtx,
|
|
{
|
|
uuid: ws.uuid,
|
|
url: ws.url ?? '',
|
|
dataId: ws.dataId
|
|
},
|
|
createEmptyBroadcastOps(),
|
|
null
|
|
)
|
|
} catch (e) {
|
|
pipeline = undefined
|
|
}
|
|
if (pipeline === undefined) {
|
|
toolCtx.error('failed to migrate, pipeline is undefined', { ws })
|
|
return
|
|
}
|
|
const client = pipeline.context.lowLevelStorage
|
|
|
|
if (client == null) {
|
|
toolCtx.error('failed to migrate, lowLevelStorage is undefined', { ws })
|
|
return
|
|
}
|
|
|
|
console.log('------------start workspace migration', ws.name)
|
|
const s = Date.now()
|
|
await migrateWorkspaceChat(
|
|
toolCtx.newChild(ws.name, {}),
|
|
ws,
|
|
dbClient,
|
|
client,
|
|
pipeline.context.hierarchy,
|
|
hulylake,
|
|
accountClient,
|
|
personUuidBySocialId
|
|
)
|
|
const e = Date.now()
|
|
console.log('---------------done workspace migration', ws.name, ((e - s) / 1000 / 60).toFixed(2), 'minutes')
|
|
await pipeline.close()
|
|
} catch (err: any) {
|
|
console.error('failed to migrate workspace', wss.workspaceUuid)
|
|
console.error(err)
|
|
}
|
|
}
|
|
db.close()
|
|
}, dbUrl)
|
|
|
|
console.log('done')
|
|
})
|
|
|
|
program
|
|
.command('calculate-ratings <workspace>')
|
|
.description('Perform a rating re-calculation')
|
|
.option('-q <queue>', 'Send to queue', false)
|
|
.option('-r, --region <region>', 'Region')
|
|
.action(async (workspace: string, cmd: { queue: boolean | undefined, region: string | undefined }) => {
|
|
await withAccountDatabase(async (db) => {
|
|
const { txes, dbUrl } = prepareTools()
|
|
const ws = await getWorkspace(db, workspace)
|
|
if (ws === null) {
|
|
throw new Error(`workspace ${workspace} not found`)
|
|
}
|
|
|
|
if (cmd.queue === true) {
|
|
const queue = getPlatformQueue('tool', cmd.region ?? '')
|
|
const ratingQueue = queue.getProducer<QueueRatingMessage>(toolCtx, 'rating')
|
|
|
|
await ratingQueue.send(toolCtx, ws.uuid, [ratingEvents.reindex()])
|
|
|
|
await queue.shutdown()
|
|
} else {
|
|
const wsIds = {
|
|
uuid: ws.uuid,
|
|
dataId: ws.dataId,
|
|
url: ws.url
|
|
}
|
|
const calculator = await RatingCalculator.create(toolCtx, txes, wsIds, dbUrl, async () => '')
|
|
|
|
await calculator.recalculateAll(toolCtx)
|
|
|
|
await calculator.close()
|
|
}
|
|
})
|
|
})
|
|
|
|
program
|
|
.command('ensure-missing-social-identities <workspace>')
|
|
.description(
|
|
'Create contact.class.SocialIdentity in the workspace for account social ids missing on the person (see contact migration createSocialIdentities)'
|
|
)
|
|
.option('-d, --dry-run', 'Only log persons/social ids that would be created', false)
|
|
.action(async (workspace: string, cmd: { dryRun: boolean }) => {
|
|
await withAccountDatabase(async (db) => {
|
|
const info = await getWorkspace(db, workspace)
|
|
if (info === null) {
|
|
throw new Error(`Workspace ${workspace} not found`)
|
|
}
|
|
const wsUuid = info.uuid
|
|
const endpoint = await getWorkspaceTransactorEndpoint(wsUuid)
|
|
const accountClient = getAccountClient(getToolToken(wsUuid))
|
|
const connection = await connect(endpoint, wsUuid, undefined, { model: 'upgrade' })
|
|
const ops = new TxOperations(connection, core.account.ConfigUser)
|
|
try {
|
|
const { skippedPersons, wouldCreate, created } = await ensureMissingSocialIdentities(
|
|
toolCtx,
|
|
ops,
|
|
accountClient,
|
|
cmd.dryRun
|
|
)
|
|
console.log(
|
|
cmd.dryRun
|
|
? `ensure-missing-social-identities dry-run: persons without personUuid skipped=${skippedPersons}, social identities that would be created=${wouldCreate}`
|
|
: `ensure-missing-social-identities: persons without personUuid skipped=${skippedPersons}, SocialIdentity docs created=${created}`
|
|
)
|
|
} finally {
|
|
await connection.close()
|
|
}
|
|
})
|
|
})
|
|
|
|
extendProgram?.(program)
|
|
|
|
process.on('unhandledRejection', (reason, promise) => {
|
|
toolCtx.error('Unhandled Rejection at:', { reason, promise })
|
|
})
|
|
|
|
process.on('uncaughtException', (error, origin) => {
|
|
toolCtx.error('Uncaught Exception at:', { origin, error })
|
|
})
|
|
|
|
program.parse(process.argv)
|
|
}
|