UNPKG

@budibase/server

Version:
427 lines (383 loc) • 13.1 kB
import { cache, context, db as dbCore, errors, events, } from "@budibase/backend-core" import { backups } from "@budibase/pro" import { Automation, BackupTrigger, DeploymentDoc, DeploymentProgressResponse, DeploymentStatus, FieldType, FetchDeploymentResponse, FormulaType, PublishStatusResponse, PublishWorkspaceRequest, PublishWorkspaceResponse, Table, UserCtx, Workspace, } from "@budibase/types" import { clearMetadata, disableAllCrons, enableCronOrEmailTrigger, } from "../../../automations/utils" import { DocumentType, getAutomationParams } from "../../../db/utils" import env from "../../../environment" import sdk from "../../../sdk" import { builderSocket } from "../../../websockets" import { doInMigrationLock } from "../../../workspaceMigrations" import Deployment from "./Deployment" import { updateAllFormulasInTable } from "../row/staticFormula" // the max time we can wait for an invalidation to complete before considering it failed const MAX_PENDING_TIME_MS = 30 * 60000 // checks that deployments are in a good state, any pending will be updated async function checkAllDeployments( deployments: any ): Promise<{ updated: boolean; deployments: DeploymentDoc }> { let updated = false let deployment: any for (deployment of Object.values(deployments.history)) { // check that no deployments have crashed etc and are now stuck if ( deployment.status === DeploymentStatus.PENDING && Date.now() - deployment.updatedAt > MAX_PENDING_TIME_MS ) { deployment.status = DeploymentStatus.FAILURE deployment.err = "Timed out" updated = true } } return { updated, deployments } } async function storeDeploymentHistory(deployment: Deployment) { const deploymentJSON = deployment.getJSON() const db = context.getWorkspaceDB() let deploymentDoc try { // theres only one deployment doc per app database deploymentDoc = await db.get<any>(DocumentType.DEPLOYMENTS) } catch (err) { deploymentDoc = { _id: DocumentType.DEPLOYMENTS, history: {} } } const deploymentId = deploymentJSON._id // first time deployment if (!deploymentDoc.history[deploymentId]) deploymentDoc.history[deploymentId] = {} deploymentDoc.history[deploymentId] = { ...deploymentDoc.history[deploymentId], ...deploymentJSON, updatedAt: Date.now(), } await db.put(deploymentDoc) deployment.fromJSON(deploymentDoc.history[deploymentId]) return deployment } async function initDeployedApp(prodAppId: string) { const db = context.getProdWorkspaceDB() console.log("Reading automation docs") const automations = ( await db.allDocs<Automation>( getAutomationParams(null, { include_docs: true, }) ) ).rows.map(row => row.doc!) await clearMetadata() const { count } = await disableAllCrons(prodAppId) const promises = [] for (let automation of automations) { promises.push( enableCronOrEmailTrigger(prodAppId, automation).catch(err => { throw new Error( `Failed to enable CRON or Email trigger for automation "${automation.name}": ${err.message}`, { cause: err } ) }) ) } const results = await Promise.all(promises) const enabledCount = results .map(result => result.enabled) .filter(result => result).length console.log( `Cleared ${count} old CRON, enabled ${enabledCount} new CRON triggers for app deployment` ) // sync the automations back to the dev DB - since there is now CRON // information attached await sdk.workspaces.syncWorkspace(dbCore.getDevWorkspaceID(prodAppId), { automationOnly: true, }) } async function applyPendingColumnRenames(workspaceId: string) { await context.doInWorkspaceContext(workspaceId, async () => { const db = context.getWorkspaceDB() const tables = await sdk.tables.getAllInternalTables() for (let table of tables) { if (table._deleted) { continue } const pendingColumnRenames = table.pendingColumnRenames if (!pendingColumnRenames?.length) { continue } for (const rename of pendingColumnRenames) { const tableToUpdate: Table = { ...table, schema: { ...table.schema }, } const existingNew = tableToUpdate.schema[rename.updated] const existingOld = tableToUpdate.schema[rename.old] // If the updatd column schema doesn't exist (replication left the old schema), // use the old column schema to the new name so the rename can complete. if (!existingNew && existingOld) { tableToUpdate.schema[rename.updated] = { ...existingOld, name: rename.updated, } delete tableToUpdate.schema[rename.old] } await sdk.tables.update(tableToUpdate, rename) table = await sdk.tables.getTable(table._id!) } const updatedTable: Table = { ...table, pendingColumnRenames: [] } await db.put(updatedTable) } }) } async function clearPendingColumnRenames(workspaceId: string) { await context.doInWorkspaceContext(workspaceId, async () => { const db = context.getWorkspaceDB() const tables = await sdk.tables.getAllInternalTables() for (const table of tables) { if (table._deleted) { continue } if (!table.pendingColumnRenames?.length) { continue } const updatedTable: Table = { ...table, pendingColumnRenames: [], } await db.put(updatedTable) } }) } async function syncStaticFormulasToProduction(prodWorkspaceId: string) { await context.doInWorkspaceContext(prodWorkspaceId, async () => { const tables = await sdk.tables.getAllInternalTables() for (const table of tables) { const hasStaticFormula = Object.values(table.schema).some( column => column?.type === FieldType.FORMULA && column.formulaType === FormulaType.STATIC ) if (hasStaticFormula) { await updateAllFormulasInTable(table) } } }) } export async function fetchDeployments( ctx: UserCtx<void, FetchDeploymentResponse> ) { try { const db = context.getWorkspaceDB() const deploymentDoc = await db.get(DocumentType.DEPLOYMENTS) const { updated, deployments } = await checkAllDeployments(deploymentDoc) if (updated) { await db.put(deployments) } ctx.body = deployments.history ? Object.values(deployments.history).reverse() : [] } catch (err) { ctx.body = [] } } export async function deploymentProgress( ctx: UserCtx<void, DeploymentProgressResponse> ) { try { const db = context.getWorkspaceDB() const deploymentDoc = await db.get<DeploymentDoc>(DocumentType.DEPLOYMENTS) if (!deploymentDoc.history?.[ctx.params.deploymentId]) { ctx.throw(404, "No deployment found") } ctx.body = deploymentDoc.history?.[ctx.params.deploymentId] } catch (err) { ctx.throw( 500, `Error fetching data for deployment ${ctx.params.deploymentId}` ) } } export async function publishStatus(ctx: UserCtx<void, PublishStatusResponse>) { const { automations, workspaceApps, tables } = await sdk.deployment.status() ctx.body = { automations, workspaceApps, tables, } } export const publishWorkspace = async function ( ctx: UserCtx<PublishWorkspaceRequest, PublishWorkspaceResponse> ) { if (ctx.request.body?.automationIds || ctx.request.body?.workspaceAppIds) { throw new errors.NotImplementedError( "Publishing resources by ID not currently supported" ) } const seedProductionTables = ctx.request.body?.seedProductionTables let deployment = new Deployment() deployment.setStatus(DeploymentStatus.PENDING) deployment = await storeDeploymentHistory(deployment) let tablesToSync: "all" | string[] | undefined if (env.isTest()) { // TODO: a lot of tests depend on old behaviour of data being published // we could do with going through the tests and updating them all to write // data to production instead of development - but doesn't improve test // quality - so keep publishing data in dev for now tablesToSync = "all" } else if (seedProductionTables) { try { tablesToSync = await sdk.tables.listEmptyProductionTables() } catch (e) { tablesToSync = [] } } const appId = context.getWorkspaceId()! let migrationResult: { app: Workspace; prodWorkspaceId: string } try { migrationResult = await doInMigrationLock(appId, async () => { let replication try { const devId = dbCore.getDevWorkspaceID(appId) const prodId = dbCore.getProdWorkspaceID(appId) if (!(await sdk.workspaces.isWorkspacePublished(prodId))) { const allWorkspaceApps = await sdk.workspaceApps.fetch() for (const workspaceApp of allWorkspaceApps) { if (workspaceApp.disabled !== undefined) { continue } await sdk.workspaceApps.update({ ...workspaceApp, disabled: true }) } const allAutomations = await sdk.automations.fetch() for (const automation of allAutomations) { if (automation.disabled !== undefined) { continue } await sdk.automations.update({ ...automation, disabled: true }) } } const isPublished = await sdk.workspaces.isWorkspacePublished(prodId) if (await backups.isEnabled()) { await backups.triggerAppBackup(prodId, BackupTrigger.PUBLISH, { createdBy: ctx.user._id, }) } const config = { source: devId, target: prodId, } replication = new dbCore.Replication(config) const devDb = context.getDevWorkspaceDB() const devTablesIds = await sdk.tables.getAllInternalTableIds() await replication.resolveInconsistencies(devTablesIds) await devDb.compact() await replication.replicate( replication.appReplicateOpts({ isCreation: !isPublished, tablesToSync, // don't use checkpoints, this can stop previously ignored data being replicated checkpoint: !seedProductionTables, }) ) await applyPendingColumnRenames(prodId) await clearPendingColumnRenames(devId) const db = context.getProdWorkspaceDB() const appDoc = await sdk.workspaces.metadata.tryGet({ production: false, }) if (!appDoc) { throw new Error( "Unable to publish - cannot retrieve development app metadata" ) } const prodAppDoc = await sdk.workspaces.metadata.tryGet({ production: true, }) if (prodAppDoc) { appDoc._rev = prodAppDoc._rev } else { delete appDoc._rev } deployment.appUrl = appDoc.url appDoc.appId = prodId appDoc.instance._id = prodId const [automations, workspaceApps, tables] = await Promise.all([ sdk.automations.fetch(), sdk.workspaceApps.fetch(), sdk.tables.getAllInternalTables(), ]) const automationIds = automations.map(auto => auto._id!) const workspaceAppIds = workspaceApps.map(app => app._id!) const tableIds = tables.map(table => table._id!) const fullMap = [ ...(automationIds ?? []), ...(workspaceAppIds ?? []), ...(tableIds ?? []), ] appDoc.resourcesPublishedAt = { ...prodAppDoc?.resourcesPublishedAt, ...Object.fromEntries( fullMap.map(id => [id, new Date().toISOString()]) ), } delete appDoc.automationErrors await db.put(appDoc) await cache.workspace.invalidateWorkspaceMetadata(prodId) await initDeployedApp(prodId) return { app: appDoc, prodWorkspaceId: prodId } } finally { if (replication) { await replication.close() } } }) } catch (error: unknown) { const message = error instanceof Error ? error.message : typeof error === "string" ? error : "Unknown error" deployment.setStatus(DeploymentStatus.FAILURE, message) await storeDeploymentHistory(deployment) throw new Error(`Deployment Failed: ${message}`, { cause: error }) } try { await syncStaticFormulasToProduction(migrationResult.prodWorkspaceId) } catch (error: unknown) { const message = error instanceof Error ? error.message : typeof error === "string" ? error : "Unknown error" deployment.setStatus(DeploymentStatus.FAILURE, message) await storeDeploymentHistory(deployment) throw new Error(`Deployment Failed: ${message}`, { cause: error }) } deployment.setStatus(DeploymentStatus.SUCCESS) await storeDeploymentHistory(deployment) await events.app.published(migrationResult.app) ctx.body = deployment builderSocket?.emitAppPublish(ctx) }