Migrations 2.0

This commit is contained in:
Rory Powell 2022-01-24 10:48:59 +00:00
parent 0eecab7eed
commit 3fdce44d56
23 changed files with 254 additions and 100 deletions

View File

@ -1,4 +1,5 @@
module.exports = { module.exports = {
...require("./src/db/utils"), ...require("./src/db/utils"),
...require("./src/db/constants"), ...require("./src/db/constants"),
...require("./src/db/views"),
} }

View File

@ -12,6 +12,7 @@ const {
tenancy, tenancy,
appTenancy, appTenancy,
authError, authError,
internalApi,
} = require("./middleware") } = require("./middleware")
// Strategies // Strategies
@ -42,4 +43,5 @@ module.exports = {
buildAppTenancyMiddleware: appTenancy, buildAppTenancyMiddleware: appTenancy,
auditLog, auditLog,
authError, authError,
internalApi,
} }

View File

@ -7,6 +7,7 @@ const authenticated = require("./authenticated")
const auditLog = require("./auditLog") const auditLog = require("./auditLog")
const tenancy = require("./tenancy") const tenancy = require("./tenancy")
const appTenancy = require("./appTenancy") const appTenancy = require("./appTenancy")
const internalApi = require("./internalApi")
module.exports = { module.exports = {
google, google,
@ -18,4 +19,5 @@ module.exports = {
tenancy, tenancy,
appTenancy, appTenancy,
authError, authError,
internalApi,
} }

View File

@ -0,0 +1,14 @@
const env = require("../environment")
const { Headers } = require("../constants")
/**
* API Key only endpoint.
*/
module.exports = async (ctx, next) => {
const apiKey = ctx.request.headers[Headers.API_KEY]
if (apiKey !== env.INTERNAL_API_KEY) {
ctx.throw(403, "Unauthorized")
}
return next()
}

View File

@ -1,20 +1,12 @@
const { DEFAULT_TENANT_ID } = require("../constants")
const { DocumentTypes } = require("../db/constants") const { DocumentTypes } = require("../db/constants")
const { getGlobalDB, getTenantId } = require("../tenancy") const { getAllApps } = require("../db/utils")
const environment = require("../environment")
const { doInTenant, getTenantIds, getGlobalDBName } = require("../tenancy")
exports.MIGRATION_DBS = { exports.MIGRATION_TYPES = {
GLOBAL_DB: "GLOBAL_DB", GLOBAL: "global", // run once, recorded in global db, global db is provided as an argument
} APP: "app", // run per app, recorded in each app db, app db is provided as an argument
exports.MIGRATIONS = {
USER_EMAIL_VIEW_CASING: "user_email_view_casing",
QUOTAS_1: "quotas_1",
}
const DB_LOOKUP = {
[exports.MIGRATION_DBS.GLOBAL_DB]: [
exports.MIGRATIONS.USER_EMAIL_VIEW_CASING,
exports.MIGRATIONS.QUOTAS_1,
],
} }
exports.getMigrationsDoc = async db => { exports.getMigrationsDoc = async db => {
@ -28,40 +20,84 @@ exports.getMigrationsDoc = async db => {
} }
} }
exports.migrateIfRequired = async (migrationDb, migrationName, migrateFn) => { const runMigration = async (tenantId, CouchDB, migration, options = {}) => {
const tenantId = getTenantId() const migrationType = migration.type
try { const migrationName = migration.name
let db
if (migrationDb === exports.MIGRATION_DBS.GLOBAL_DB) {
db = getGlobalDB()
} else {
throw new Error(`Unrecognised migration db [${migrationDb}]`)
}
if (!DB_LOOKUP[migrationDb].includes(migrationName)) { // get the db to store the migration in
throw new Error( let dbNames
`Unrecognised migration name [${migrationName}] for db [${migrationDb}]` if (migrationType === exports.MIGRATION_TYPES.GLOBAL) {
) dbNames = [getGlobalDBName(tenantId)]
} } else if (migrationType === exports.MIGRATION_TYPES.APP) {
dbNames = await getAllApps(CouchDB, { all: true })
const doc = await exports.getMigrationsDoc(db) } else {
// exit if the migration has been performed throw new Error(
if (doc[migrationName]) { `[Tenant: ${tenantId}] Unrecognised migration type [${migrationType}]`
return
}
console.log(`[Tenant: ${tenantId}] Performing migration: ${migrationName}`)
await migrateFn()
console.log(`[Tenant: ${tenantId}] Migration complete: ${migrationName}`)
// mark as complete
doc[migrationName] = Date.now()
await db.put(doc)
} catch (err) {
console.error(
`[Tenant: ${tenantId}] Error performing migration: ${migrationName}: `,
err
) )
throw err }
// run the migration against each db
for (const dbName of dbNames) {
const db = new CouchDB(dbName)
try {
const doc = await exports.getMigrationsDoc(db)
// exit if the migration has been performed already
if (doc[migrationName]) {
if (
options.force &&
options.force[migrationType] &&
options.force[migrationType].includes(migrationName)
) {
console.log(
`[Tenant: ${tenantId}] Forcing migration [${migrationName}]`
)
} else {
// the migration has already been performed
return
}
}
console.log(
`[Tenant: ${tenantId}] Performing migration: ${migrationName}`
)
// run the migration with tenant context
await doInTenant(tenantId, () => migration.fn(db))
console.log(`[Tenant: ${tenantId}] Migration complete: ${migrationName}`)
// mark as complete
doc[migrationName] = Date.now()
await db.put(doc)
} catch (err) {
console.error(
`[Tenant: ${tenantId}] Error performing migration: ${migrationName} on db: ${db.name}: `,
err
)
throw err
}
} }
} }
exports.runMigrations = async (CouchDB, migrations, options = {}) => {
console.log("Running migrations")
let tenantIds
if (environment.MULTI_TENANCY) {
if (!options.tenantIds || !options.tenantIds.length) {
// run for all tenants
tenantIds = await getTenantIds()
}
} else {
// single tenancy
tenantIds = [DEFAULT_TENANT_ID]
}
// for all tenants
for (const tenantId of tenantIds) {
// for all migrations
for (const migration of migrations) {
// run the migration
await runMigration(tenantId, CouchDB, migration, options)
}
}
console.log("Migrations complete")
}

View File

@ -148,3 +148,15 @@ exports.isUserInAppTenant = (appId, user = null) => {
const tenantId = exports.getTenantIDFromAppID(appId) || DEFAULT_TENANT_ID const tenantId = exports.getTenantIDFromAppID(appId) || DEFAULT_TENANT_ID
return tenantId === userTenantId return tenantId === userTenantId
} }
exports.getTenantIds = async () => {
const db = getDB(PLATFORM_INFO_DB)
let tenants
try {
tenants = await db.get(TENANT_DOC)
} catch (err) {
// if theres an error the doc doesn't exist, no tenants exist
return []
}
return (tenants && tenants.tenantIds) || []
}

View File

@ -20,9 +20,6 @@ const { hash } = require("./hashing")
const userCache = require("./cache/user") const userCache = require("./cache/user")
const env = require("./environment") const env = require("./environment")
const { getUserSessions, invalidateSessions } = require("./security/sessions") const { getUserSessions, invalidateSessions } = require("./security/sessions")
const { migrateIfRequired } = require("./migrations")
const { USER_EMAIL_VIEW_CASING } = require("./migrations").MIGRATIONS
const { GLOBAL_DB } = require("./migrations").MIGRATION_DBS
const APP_PREFIX = DocumentTypes.APP + SEPARATOR const APP_PREFIX = DocumentTypes.APP + SEPARATOR
@ -149,11 +146,6 @@ exports.getGlobalUserByEmail = async email => {
} }
const db = getGlobalDB() const db = getGlobalDB()
await migrateIfRequired(GLOBAL_DB, USER_EMAIL_VIEW_CASING, async () => {
// re-create the view with latest changes
await createUserEmailView(db)
})
try { try {
let users = ( let users = (
await db.query(`database/${ViewNames.USER_BY_EMAIL}`, { await db.query(`database/${ViewNames.USER_BY_EMAIL}`, {

View File

@ -0,0 +1,8 @@
const { migrate } = require("../../migrations")
exports.migrate = async ctx => {
const options = ctx.request.body
// don't await as can take a while, just return
migrate(options)
ctx.status = 200
}

View File

@ -24,6 +24,7 @@ const backupRoutes = require("./backup")
const metadataRoutes = require("./metadata") const metadataRoutes = require("./metadata")
const devRoutes = require("./dev") const devRoutes = require("./dev")
const cloudRoutes = require("./cloud") const cloudRoutes = require("./cloud")
const migrationRoutes = require("./migrations")
exports.mainRoutes = [ exports.mainRoutes = [
authRoutes, authRoutes,
@ -53,6 +54,7 @@ exports.mainRoutes = [
// this could be breaking as koa may recognise other routes as this // this could be breaking as koa may recognise other routes as this
tableRoutes, tableRoutes,
rowRoutes, rowRoutes,
migrationRoutes,
] ]
exports.staticRoutes = staticRoutes exports.staticRoutes = staticRoutes

View File

@ -0,0 +1,8 @@
const Router = require("@koa/router")
const migrationsController = require("../controllers/migrations")
const router = Router()
const { internalApi } = require("@budibase/backend-core/auth")
router.post("/api/migrations/run", internalApi, migrationsController.migrate)
module.exports = router

View File

@ -1,7 +1,7 @@
// need to load environment first // need to load environment first
import { ExtendableContext } from "koa" import { ExtendableContext } from "koa"
const env = require("./environment") import * as env from "./environment"
const CouchDB = require("./db") const CouchDB = require("./db")
require("@budibase/backend-core").init(CouchDB) require("@budibase/backend-core").init(CouchDB)
const Koa = require("koa") const Koa = require("koa")
@ -16,6 +16,7 @@ const Sentry = require("@sentry/node")
const fileSystem = require("./utilities/fileSystem") const fileSystem = require("./utilities/fileSystem")
const bullboard = require("./automations/bullboard") const bullboard = require("./automations/bullboard")
const redis = require("./utilities/redis") const redis = require("./utilities/redis")
import * as migrations from "./migrations"
const app = new Koa() const app = new Koa()
@ -84,13 +85,25 @@ module.exports = server.listen(env.PORT || 0, async () => {
await automations.init() await automations.init()
}) })
process.on("uncaughtException", err => { const shutdown = () => {
console.error(err)
server.close() server.close()
server.destroy() server.destroy()
}
process.on("uncaughtException", err => {
console.error(err)
shutdown()
}) })
process.on("SIGTERM", () => { process.on("SIGTERM", () => {
server.close() shutdown()
server.destroy()
}) })
// run migrations on startup if not done via http
// not recommended in a clustered environment
if (!env.HTTP_MIGRATIONS) {
migrations.migrate().catch(err => {
console.error("Error performing migrations. Exiting.\n", err)
shutdown()
})
}

View File

@ -43,6 +43,7 @@ module.exports = {
REDIS_PASSWORD: process.env.REDIS_PASSWORD, REDIS_PASSWORD: process.env.REDIS_PASSWORD,
INTERNAL_API_KEY: process.env.INTERNAL_API_KEY, INTERNAL_API_KEY: process.env.INTERNAL_API_KEY,
MULTI_TENANCY: process.env.MULTI_TENANCY, MULTI_TENANCY: process.env.MULTI_TENANCY,
HTTP_MIGRATIONS: process.env.HTTP_MIGRATIONS,
// environment // environment
NODE_ENV: process.env.NODE_ENV, NODE_ENV: process.env.NODE_ENV,
JEST_WORKER_ID: process.env.JEST_WORKER_ID, JEST_WORKER_ID: process.env.JEST_WORKER_ID,

View File

@ -5,7 +5,6 @@ const {
isExternalTable, isExternalTable,
isRowId: isExternalRowId, isRowId: isExternalRowId,
} = require("../integrations/utils") } = require("../integrations/utils")
const migration = require("../migrations/usageQuotas")
// currently only counting new writes and deletes // currently only counting new writes and deletes
const METHOD_MAP = { const METHOD_MAP = {
@ -74,7 +73,6 @@ module.exports = async (ctx, next) => {
usage = files.map(file => file.size).reduce((total, size) => total + size) usage = files.map(file => file.size).reduce((total, size) => total + size)
} }
try { try {
await migration.run()
await performRequest(ctx, next, property, usage) await performRequest(ctx, next, property, usage)
} catch (err) { } catch (err) {
ctx.throw(400, err) ctx.throw(400, err)

View File

@ -0,0 +1,20 @@
import { runQuotaMigration } from "./usageQuotas"
import * as syncApps from "./usageQuotas/syncApps"
import * as syncRows from "./usageQuotas/syncRows"
/**
* Date:
* January 2022
*
* Description:
* Synchronise the app and row quotas to the state of the db after it was
* discovered that the quota resets were still in place and the row quotas
* weren't being decremented correctly.
*/
export const run = async () => {
await runQuotaMigration(async () => {
await syncApps.run()
await syncRows.run()
})
}

View File

@ -0,0 +1,8 @@
const { useQuotas } = require("../../../utilities/usageQuota")
export const runQuotaMigration = async (migration: Function) => {
if (!useQuotas()) {
return
}
await migration()
}

View File

@ -1,9 +1,9 @@
const { getGlobalDB, getTenantId } = require("@budibase/backend-core/tenancy") const { getGlobalDB, getTenantId } = require("@budibase/backend-core/tenancy")
const { getAllApps } = require("@budibase/backend-core/db") const { getAllApps } = require("@budibase/backend-core/db")
const CouchDB = require("../../db") import CouchDB from "../../../db"
const { getUsageQuotaDoc } = require("../../utilities/usageQuota") import { getUsageQuotaDoc } from "../../../utilities/usageQuota"
exports.run = async () => { export const run = async () => {
const db = getGlobalDB() const db = getGlobalDB()
// get app count // get app count
const devApps = await getAllApps(CouchDB, { dev: true }) const devApps = await getAllApps(CouchDB, { dev: true })

View File

@ -1,14 +1,14 @@
const { getGlobalDB, getTenantId } = require("@budibase/backend-core/tenancy") const { getGlobalDB, getTenantId } = require("@budibase/backend-core/tenancy")
const { getAllApps } = require("@budibase/backend-core/db") const { getAllApps } = require("@budibase/backend-core/db")
const CouchDB = require("../../db") import CouchDB from "../../../db"
const { getUsageQuotaDoc } = require("../../utilities/usageQuota") import { getUsageQuotaDoc } from "../../../utilities/usageQuota"
const { getUniqueRows } = require("../../utilities/usageQuota/rows") import { getUniqueRows } from "../../../utilities/usageQuota/rows"
exports.run = async () => { export const run = async () => {
const db = getGlobalDB() const db = getGlobalDB()
// get all rows in all apps // get all rows in all apps
const allApps = await getAllApps(CouchDB, { all: true }) const allApps = await getAllApps(CouchDB, { all: true })
const appIds = allApps ? allApps.map(app => app.appId) : [] const appIds = allApps ? allApps.map((app: { appId: any }) => app.appId) : []
const rows = await getUniqueRows(appIds) const rows = await getUniqueRows(appIds)
const rowCount = rows ? rows.length : 0 const rowCount = rows ? rows.length : 0

View File

@ -1,5 +1,5 @@
const env = require("../../../environment") const env = require("../../../../environment")
const TestConfig = require("../../../tests/utilities/TestConfiguration") const TestConfig = require("../../../../tests/utilities/TestConfiguration")
const syncApps = jest.fn() const syncApps = jest.fn()
const syncRows = jest.fn() const syncRows = jest.fn()
@ -7,7 +7,7 @@ const syncRows = jest.fn()
jest.mock("../../usageQuotas/syncApps", () => ({ run: syncApps }) ) jest.mock("../../usageQuotas/syncApps", () => ({ run: syncApps }) )
jest.mock("../../usageQuotas/syncRows", () => ({ run: syncRows }) ) jest.mock("../../usageQuotas/syncRows", () => ({ run: syncRows }) )
const migrations = require("../../usageQuotas") const migrations = require("..")
describe("run", () => { describe("run", () => {
let config = new TestConfig(false) let config = new TestConfig(false)

View File

@ -1,7 +1,7 @@
const { getGlobalDB } = require("@budibase/backend-core/tenancy") const { getGlobalDB } = require("@budibase/backend-core/tenancy")
const TestConfig = require("../../../tests/utilities/TestConfiguration") const TestConfig = require("../../../tests/utilities/TestConfiguration")
const { getUsageQuotaDoc, update, Properties } = require("../../../utilities/usageQuota") const { getUsageQuotaDoc, update, Properties } = require("../../../utilities/usageQuota")
const syncApps = require("../../usageQuotas/syncApps") const syncApps = require("../syncApps")
const env = require("../../../environment") const env = require("../../../environment")
describe("syncApps", () => { describe("syncApps", () => {

View File

@ -1,7 +1,7 @@
const { getGlobalDB } = require("@budibase/backend-core/tenancy") const { getGlobalDB } = require("@budibase/backend-core/tenancy")
const TestConfig = require("../../../tests/utilities/TestConfiguration") const TestConfig = require("../../../tests/utilities/TestConfiguration")
const { getUsageQuotaDoc, update, Properties } = require("../../../utilities/usageQuota") const { getUsageQuotaDoc, update, Properties } = require("../../../utilities/usageQuota")
const syncRows = require("../../usageQuotas/syncRows") const syncRows = require("../syncRows")
const env = require("../../../environment") const env = require("../../../environment")
describe("syncRows", () => { describe("syncRows", () => {

View File

@ -0,0 +1,13 @@
const { createUserEmailView } = require("@budibase/backend-core/db")
/**
* Date:
* October 2021
*
* Description:
* Recreate the user email view to include latest changes i.e. lower casing the email address
*/
export const run = async (db: any) => {
await createUserEmailView(db)
}

View File

@ -0,0 +1,48 @@
import CouchDB from "../db"
const {
MIGRATION_TYPES,
runMigrations,
} = require("@budibase/backend-core/migrations")
// migration functions
import * as userEmailViewCasing from "./functions/userEmailViewCasing"
import * as quota1 from "./functions/quotas1"
export interface Migration {
type: string
name: string
fn: Function
}
/**
* e.g.
* {
* tenantIds: ['bb'],
* force: {
* global: ['quota_1']
* }
* }
*/
export interface MigrationOptions {
tenantIds?: string[]
forced?: {
[type: string]: string[]
}
}
const MIGRATIONS: Migration[] = [
{
type: MIGRATION_TYPES.GLOBAL,
name: "user_email_view_casing",
fn: userEmailViewCasing.run,
},
{
type: MIGRATION_TYPES.GLOBAL,
name: "quotas_1",
fn: quota1.run,
},
]
export const migrate = async (options?: MigrationOptions) => {
await runMigrations(CouchDB, MIGRATIONS, options)
}

View File

@ -1,24 +0,0 @@
const {
MIGRATIONS,
MIGRATION_DBS,
migrateIfRequired,
} = require("@budibase/backend-core/migrations")
const { useQuotas } = require("../../utilities/usageQuota")
const syncApps = require("./syncApps")
const syncRows = require("./syncRows")
exports.run = async () => {
if (!useQuotas()) {
return
}
// Jan 2022
await migrateIfRequired(
MIGRATION_DBS.GLOBAL_DB,
MIGRATIONS.QUOTAS_1,
async () => {
await syncApps.run()
await syncRows.run()
}
)
}