Merge pull request #12577 from Budibase/BUDI-7654/expose-migration-status-to-the-fe

[BUDI-7654] - Expose migration status to the frontend
This commit is contained in:
Adria Navarro 2023-12-14 11:53:52 +01:00 committed by GitHub
commit f33e27c6b7
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 177 additions and 58 deletions

View File

@ -11,24 +11,7 @@ export enum Cookie {
OIDC_CONFIG = "budibase:oidc:config", OIDC_CONFIG = "budibase:oidc:config",
} }
export enum Header { export { Header } from "@budibase/shared-core"
API_KEY = "x-budibase-api-key",
LICENSE_KEY = "x-budibase-license-key",
API_VER = "x-budibase-api-version",
APP_ID = "x-budibase-app-id",
SESSION_ID = "x-budibase-session-id",
TYPE = "x-budibase-type",
PREVIEW_ROLE = "x-budibase-role",
TENANT_ID = "x-budibase-tenant-id",
VERIFICATION_CODE = "x-budibase-verification-code",
RETURN_VERIFICATION_CODE = "x-budibase-return-verification-code",
RESET_PASSWORD_CODE = "x-budibase-reset-password-code",
RETURN_RESET_PASSWORD_CODE = "x-budibase-return-reset-password-code",
TOKEN = "x-budibase-token",
CSRF_TOKEN = "x-csrf-token",
CORRELATION_ID = "x-budibase-correlation-id",
AUTHORIZATION = "authorization",
}
export enum GlobalRole { export enum GlobalRole {
OWNER = "owner", OWNER = "owner",

View File

@ -96,7 +96,7 @@ export async function getAppIdFromCtx(ctx: Ctx) {
} }
// look in the path // look in the path
const pathId = parseAppIdFromUrl(ctx.path) const pathId = parseAppIdFromUrlPath(ctx.path)
if (!appId && pathId) { if (!appId && pathId) {
appId = confirmAppId(pathId) appId = confirmAppId(pathId)
} }
@ -116,18 +116,21 @@ export async function getAppIdFromCtx(ctx: Ctx) {
// referer header is present from a builder redirect // referer header is present from a builder redirect
const referer = ctx.request.headers.referer const referer = ctx.request.headers.referer
if (!appId && referer?.includes(BUILDER_APP_PREFIX)) { if (!appId && referer?.includes(BUILDER_APP_PREFIX)) {
const refererId = parseAppIdFromUrl(ctx.request.headers.referer) const refererId = parseAppIdFromUrlPath(ctx.request.headers.referer)
appId = confirmAppId(refererId) appId = confirmAppId(refererId)
} }
return appId return appId
} }
function parseAppIdFromUrl(url?: string) { function parseAppIdFromUrlPath(url?: string) {
if (!url) { if (!url) {
return return
} }
return url.split("/").find(subPath => subPath.startsWith(APP_PREFIX)) return url
.split("?")[0] // Remove any possible query string
.split("/")
.find(subPath => subPath.startsWith(APP_PREFIX))
} }
/** /**

View File

@ -1,4 +1,5 @@
import { Helpers } from "@budibase/bbui" import { Helpers } from "@budibase/bbui"
import { Header } from "@budibase/shared-core"
import { ApiVersion } from "../constants" import { ApiVersion } from "../constants"
import { buildAnalyticsEndpoints } from "./analytics" import { buildAnalyticsEndpoints } from "./analytics"
import { buildAppEndpoints } from "./app" import { buildAppEndpoints } from "./app"
@ -62,6 +63,11 @@ const defaultAPIClientConfig = {
* invoked before the actual JS error is thrown up the stack. * invoked before the actual JS error is thrown up the stack.
*/ */
onError: null, onError: null,
/**
* A function can be passed to be called when an API call returns info about a migration running for a specific app
*/
onMigrationDetected: null,
} }
/** /**
@ -133,9 +139,9 @@ export const createAPIClient = config => {
// Build headers // Build headers
let headers = { Accept: "application/json" } let headers = { Accept: "application/json" }
headers["x-budibase-session-id"] = APISessionID headers[Header.SESSION_ID] = APISessionID
if (!external) { if (!external) {
headers["x-budibase-api-version"] = ApiVersion headers[Header.API_VER] = ApiVersion
} }
if (json) { if (json) {
headers["Content-Type"] = "application/json" headers["Content-Type"] = "application/json"
@ -170,6 +176,7 @@ export const createAPIClient = config => {
// Handle response // Handle response
if (response.status >= 200 && response.status < 400) { if (response.status >= 200 && response.status < 400) {
handleMigrations(response)
try { try {
if (parseResponse) { if (parseResponse) {
return await parseResponse(response) return await parseResponse(response)
@ -186,7 +193,18 @@ export const createAPIClient = config => {
} }
} }
// Performs an API call to the server and caches the response. const handleMigrations = response => {
if (!config.onMigrationDetected) {
return
}
const migration = response.headers.get(Header.MIGRATING_APP)
if (migration) {
config.onMigrationDetected(migration)
}
}
// Performs an API call to the server and caches the response.
// Future invocation for this URL will return the cached result instead of // Future invocation for this URL will return the cached result instead of
// hitting the server again. // hitting the server again.
const makeCachedApiCall = async params => { const makeCachedApiCall = async params => {
@ -242,7 +260,7 @@ export const createAPIClient = config => {
getAppID: () => { getAppID: () => {
let headers = {} let headers = {}
config?.attachHeaders(headers) config?.attachHeaders(headers)
return headers?.["x-budibase-app-id"] return headers?.[Header.APP_ID]
}, },
} }

View File

@ -340,7 +340,7 @@ async function performAppCreate(ctx: UserCtx) {
// Initialise the app migration version as the latest one // Initialise the app migration version as the latest one
await appMigrations.updateAppMigrationMetadata({ await appMigrations.updateAppMigrationMetadata({
appId, appId,
version: appMigrations.latestMigration, version: appMigrations.getLatestMigrationId(),
}) })
await cache.app.invalidateAppMetadata(appId, newApplication) await cache.app.invalidateAppMetadata(appId, newApplication)

View File

@ -1,14 +1,34 @@
import { context } from "@budibase/backend-core"
import { migrate as migrationImpl, MIGRATIONS } from "../../migrations" import { migrate as migrationImpl, MIGRATIONS } from "../../migrations"
import { BBContext } from "@budibase/types" import { Ctx } from "@budibase/types"
import {
getAppMigrationVersion,
getLatestMigrationId,
} from "../../appMigrations"
export async function migrate(ctx: BBContext) { export async function migrate(ctx: Ctx) {
const options = ctx.request.body const options = ctx.request.body
// don't await as can take a while, just return // don't await as can take a while, just return
migrationImpl(options) migrationImpl(options)
ctx.status = 200 ctx.status = 200
} }
export async function fetchDefinitions(ctx: BBContext) { export async function fetchDefinitions(ctx: Ctx) {
ctx.body = MIGRATIONS ctx.body = MIGRATIONS
ctx.status = 200 ctx.status = 200
} }
export async function getMigrationStatus(ctx: Ctx) {
const appId = context.getAppId()
if (!appId) {
ctx.throw("AppId could not be found")
}
const latestAppliedMigration = await getAppMigrationVersion(appId)
const migrated = latestAppliedMigration === getLatestMigrationId()
ctx.body = { migrated }
ctx.status = 200
}

View File

@ -11,4 +11,6 @@ router
auth.internalApi, auth.internalApi,
migrationsController.fetchDefinitions migrationsController.fetchDefinitions
) )
.get("/api/migrations/status", migrationsController.getMigrationStatus)
export default router export default router

View File

@ -1,6 +1,9 @@
import queue from "./queue" import queue from "./queue"
import { Next } from "koa"
import { getAppMigrationVersion } from "./appMigrationMetadata" import { getAppMigrationVersion } from "./appMigrationMetadata"
import { MIGRATIONS } from "./migrations" import { MIGRATIONS } from "./migrations"
import { UserCtx } from "@budibase/types"
import { Header } from "@budibase/backend-core"
export * from "./appMigrationMetadata" export * from "./appMigrationMetadata"
@ -9,14 +12,20 @@ export type AppMigration = {
func: () => Promise<void> func: () => Promise<void>
} }
export const latestMigration = MIGRATIONS.map(m => m.id) export const getLatestMigrationId = () =>
.sort() MIGRATIONS.map(m => m.id)
.reverse()[0] .sort()
.reverse()[0]
const getTimestamp = (versionId: string) => versionId?.split("_")[0] const getTimestamp = (versionId: string) => versionId?.split("_")[0]
export async function checkMissingMigrations(appId: string) { export async function checkMissingMigrations(
ctx: UserCtx,
next: Next,
appId: string
) {
const currentVersion = await getAppMigrationVersion(appId) const currentVersion = await getAppMigrationVersion(appId)
const latestMigration = getLatestMigrationId()
if (getTimestamp(currentVersion) < getTimestamp(latestMigration)) { if (getTimestamp(currentVersion) < getTimestamp(latestMigration)) {
await queue.add( await queue.add(
@ -29,5 +38,9 @@ export async function checkMissingMigrations(appId: string) {
removeOnFail: true, removeOnFail: true,
} }
) )
ctx.response.set(Header.MIGRATING_APP, appId)
} }
return next()
} }

View File

@ -0,0 +1,25 @@
import { context } from "@budibase/backend-core"
import * as setup from "../../api/routes/tests/utilities"
import * as migrations from "../migrations"
describe("migration integrity", () => {
// These test is checking that each migration is "idempotent".
// We should be able to rerun any migration, with any rerun not modifiying anything. The code should be aware that the migration already ran
it("each migration can rerun safely", async () => {
const config = setup.getConfig()
await config.init()
await config.doInContext(config.getAppId(), async () => {
const db = context.getAppDB()
for (const migration of migrations.MIGRATIONS) {
await migration.func()
const docs = await db.allDocs({ include_docs: true })
await migration.func()
const latestDocs = await db.allDocs({ include_docs: true })
expect(docs).toEqual(latestDocs)
}
})
})
})

View File

@ -1,25 +1,53 @@
import { context } from "@budibase/backend-core" import { Header } from "@budibase/backend-core"
import * as setup from "../../api/routes/tests/utilities" import * as setup from "../../api/routes/tests/utilities"
import { MIGRATIONS } from "../migrations" import * as migrations from "../migrations"
import { getAppMigrationVersion } from "../appMigrationMetadata"
describe("migration", () => { jest.mock<typeof migrations>("../migrations", () => ({
// These test is checking that each migration is "idempotent". MIGRATIONS: [
// We should be able to rerun any migration, with any rerun not modifiying anything. The code should be aware that the migration already ran {
it("each migration can rerun safely", async () => { id: "20231211101320_test",
func: async () => {},
},
],
}))
describe("migrations", () => {
it("new apps are created with the latest app migration version set", async () => {
const config = setup.getConfig() const config = setup.getConfig()
await config.init() await config.init()
await config.doInContext(config.getAppId(), async () => { await config.doInContext(config.getAppId(), async () => {
const db = context.getAppDB() const migrationVersion = await getAppMigrationVersion(config.getAppId())
for (const migration of MIGRATIONS) {
await migration.func()
const docs = await db.allDocs({ include_docs: true })
await migration.func() expect(migrationVersion).toEqual("20231211101320_test")
const latestDocs = await db.allDocs({ include_docs: true })
expect(docs).toEqual(latestDocs)
}
}) })
}) })
it("accessing an app that has no pending migrations will not attach the migrating header", async () => {
const config = setup.getConfig()
await config.init()
const appId = config.getAppId()
const response = await config.api.application.getRaw(appId)
expect(response.headers[Header.MIGRATING_APP]).toBeUndefined()
})
it("accessing an app that has pending migrations will attach the migrating header", async () => {
const config = setup.getConfig()
await config.init()
const appId = config.getAppId()
migrations.MIGRATIONS.push({
id: "20231211105812_new-test",
func: async () => {},
})
const response = await config.api.application.getRaw(appId)
expect(response.headers[Header.MIGRATING_APP]).toEqual(appId)
})
}) })

View File

@ -4,12 +4,14 @@ import { getAppMigrationVersion } from "../appMigrationMetadata"
import { context } from "@budibase/backend-core" import { context } from "@budibase/backend-core"
import { AppMigration } from ".." import { AppMigration } from ".."
const futureTimestamp = `20500101174029`
describe("migrationsProcessor", () => { describe("migrationsProcessor", () => {
it("running migrations will update the latest applied migration", async () => { it("running migrations will update the latest applied migration", async () => {
const testMigrations: AppMigration[] = [ const testMigrations: AppMigration[] = [
{ id: "123", func: async () => {} }, { id: `${futureTimestamp}_123`, func: async () => {} },
{ id: "124", func: async () => {} }, { id: `${futureTimestamp}_124`, func: async () => {} },
{ id: "125", func: async () => {} }, { id: `${futureTimestamp}_125`, func: async () => {} },
] ]
const config = setup.getConfig() const config = setup.getConfig()
@ -23,13 +25,13 @@ describe("migrationsProcessor", () => {
expect( expect(
await config.doInContext(appId, () => getAppMigrationVersion(appId)) await config.doInContext(appId, () => getAppMigrationVersion(appId))
).toBe("125") ).toBe(`${futureTimestamp}_125`)
}) })
it("no context can be initialised within a migration", async () => { it("no context can be initialised within a migration", async () => {
const testMigrations: AppMigration[] = [ const testMigrations: AppMigration[] = [
{ {
id: "123", id: `${futureTimestamp}_123`,
func: async () => { func: async () => {
await context.doInAppMigrationContext("any", () => {}) await context.doInAppMigrationContext("any", () => {})
}, },

View File

@ -8,7 +8,5 @@ export default async (ctx: UserCtx, next: any) => {
return next() return next()
} }
await checkMissingMigrations(appId) return checkMissingMigrations(ctx, next, appId)
return next()
} }

View File

@ -1,3 +1,4 @@
import { Response } from "supertest"
import { App } from "@budibase/types" import { App } from "@budibase/types"
import TestConfiguration from "../TestConfiguration" import TestConfiguration from "../TestConfiguration"
import { TestAPI } from "./base" import { TestAPI } from "./base"
@ -7,12 +8,17 @@ export class ApplicationAPI extends TestAPI {
super(config) super(config)
} }
get = async (appId: string): Promise<App> => { getRaw = async (appId: string): Promise<Response> => {
const result = await this.request const result = await this.request
.get(`/api/applications/${appId}/appPackage`) .get(`/api/applications/${appId}/appPackage`)
.set(this.config.defaultHeaders()) .set(this.config.defaultHeaders())
.expect("Content-Type", /json/) .expect("Content-Type", /json/)
.expect(200) .expect(200)
return result
}
get = async (appId: string): Promise<App> => {
const result = await this.getRaw(appId)
return result.body.application as App return result.body.application as App
} }
} }

View File

@ -0,0 +1,19 @@
export enum Header {
API_KEY = "x-budibase-api-key",
LICENSE_KEY = "x-budibase-license-key",
API_VER = "x-budibase-api-version",
APP_ID = "x-budibase-app-id",
SESSION_ID = "x-budibase-session-id",
TYPE = "x-budibase-type",
PREVIEW_ROLE = "x-budibase-role",
TENANT_ID = "x-budibase-tenant-id",
VERIFICATION_CODE = "x-budibase-verification-code",
RETURN_VERIFICATION_CODE = "x-budibase-return-verification-code",
RESET_PASSWORD_CODE = "x-budibase-reset-password-code",
RETURN_RESET_PASSWORD_CODE = "x-budibase-return-reset-password-code",
TOKEN = "x-budibase-token",
CSRF_TOKEN = "x-csrf-token",
CORRELATION_ID = "x-budibase-correlation-id",
AUTHORIZATION = "authorization",
MIGRATING_APP = "x-budibase-migrating-app",
}

View File

@ -1,3 +1,5 @@
export * from "./api"
export const OperatorOptions = { export const OperatorOptions = {
Equals: { Equals: {
value: "equal", value: "equal",