diff --git a/docs/database.md b/docs/database.md index 60bc72e74..af22efdfe 100644 --- a/docs/database.md +++ b/docs/database.md @@ -14,6 +14,7 @@ Manage databases (like MySQL and Redis) in your projects * [`mw database mysql phpmyadmin DATABASE-ID`](#mw-database-mysql-phpmyadmin-database-id) * [`mw database mysql port-forward DATABASE-ID`](#mw-database-mysql-port-forward-database-id) * [`mw database mysql shell DATABASE-ID`](#mw-database-mysql-shell-database-id) +* [`mw database mysql upgrade DATABASE-ID`](#mw-database-mysql-upgrade-database-id) * [`mw database mysql user create`](#mw-database-mysql-user-create) * [`mw database mysql user delete USER-ID`](#mw-database-mysql-user-delete-user-id) * [`mw database mysql user get ID`](#mw-database-mysql-user-get-id) @@ -570,6 +571,60 @@ FLAG DESCRIPTIONS ``` +## `mw database mysql upgrade DATABASE-ID` + +Upgrade a MySQL database to a newer version + +``` +USAGE + $ mw database mysql upgrade DATABASE-ID [--token ] [--version ] [-f] [-q] [-w] [--wait-timeout ] + +ARGUMENTS + DATABASE-ID The ID or name of the database + +FLAGS + -f, --force do not ask for confirmation + -q, --quiet suppress process output and only display a machine-readable summary + -w, --wait wait for the resource to be ready. + --version= the MySQL version to upgrade to + --wait-timeout= [default: 600s] the duration to wait for the resource to be ready (common units like 'ms', + 's', 'm' are accepted). + +AUTHENTICATION FLAGS + --token= API token to use for authentication (overrides environment and config file). NOTE: watch out that + tokens passed via this flag might be logged in your shell history. + +DESCRIPTION + Upgrade a MySQL database to a newer version + + MySQL does not support downgrades, so only versions newer than the one the database is currently running can be + selected. If the target version is omitted, it will be prompted for interactively. + + Upgrading a database is disruptive; the database will be unavailable while the upgrade is running. Consider creating a + backup ("mw backup create") before upgrading. + +EXAMPLES + Upgrade a database to a specific version + + $ mw database mysql upgrade --version 8.0 + + Upgrade a database to the latest available version, and wait for the upgrade to complete + + $ mw database mysql upgrade --version latest --wait + +FLAG DESCRIPTIONS + -q, --quiet suppress process output and only display a machine-readable summary + + This flag controls if you want to see the process output or only a summary. When using mw non-interactively (e.g. in + scripts), you can use this flag to easily get the IDs of created resources for further processing. + + --version= the MySQL version to upgrade to + + Use the "database mysql versions" command to list available versions. If set to "latest", the most recent version + available for this database will be used. +``` + + ## `mw database mysql user create` Create a new MySQL user diff --git a/src/commands/database/mysql/upgrade.tsx b/src/commands/database/mysql/upgrade.tsx new file mode 100644 index 000000000..4f940e858 --- /dev/null +++ b/src/commands/database/mysql/upgrade.tsx @@ -0,0 +1,205 @@ +import { ExecRenderBaseCommand } from "../../../lib/basecommands/ExecRenderBaseCommand.js"; +import { + mysqlArgs, + withMySQLId, +} from "../../../lib/resources/database/mysql/flags.js"; +import { + getUpgradeCandidatesForVersion, + compareVersions, +} from "../../../lib/resources/database/mysql/versions.js"; +import { isUpgradeComplete } from "../../../lib/resources/database/mysql/upgrade.js"; +import { + makeProcessRenderer, + processFlags, +} from "../../../rendering/process/process_flags.js"; +import { ProcessRenderer } from "../../../rendering/process/process.js"; +import { Success } from "../../../rendering/react/components/Success.js"; +import { Value } from "../../../rendering/react/components/Value.js"; +import { waitFlags, waitUntil } from "../../../lib/wait.js"; +import { Flags, ux } from "@oclif/core"; +import { Text } from "ink"; +import { ReactNode } from "react"; +import { MittwaldAPIV2 } from "@mittwald/api-client"; +import { assertStatus } from "@mittwald/api-client-commons"; + +type Database = MittwaldAPIV2.Components.Schemas.DatabaseMySqlDatabase; +type MySqlVersion = MittwaldAPIV2.Components.Schemas.DatabaseMySqlVersion; + +export class Upgrade extends ExecRenderBaseCommand { + static summary = "Upgrade a MySQL database to a newer version"; + static description = `\ +MySQL does not support downgrades, so only versions newer than the one the database is currently running can be selected. If the target version is omitted, it will be prompted for interactively. + +Upgrading a database is disruptive; the database will be unavailable while the upgrade is running. Consider creating a backup ("mw backup create") before upgrading.`; + + static args = { ...mysqlArgs }; + static flags = { + version: Flags.string({ + summary: "the MySQL version to upgrade to", + description: + 'Use the "database mysql versions" command to list available versions. If set to "latest", the most recent version available for this database will be used.', + }), + force: Flags.boolean({ + char: "f", + summary: "do not ask for confirmation", + }), + ...processFlags, + ...waitFlags, + }; + + static examples = [ + { + description: "Upgrade a database to a specific version", + command: + "<%= config.bin %> database mysql upgrade --version 8.0", + }, + { + description: + "Upgrade a database to the latest available version, and wait for the upgrade to complete", + command: + "<%= config.bin %> database mysql upgrade --version latest --wait", + }, + ]; + + protected async exec(): Promise { + const p = makeProcessRenderer(this.flags, "Upgrading a MySQL database"); + const mysqlDatabaseId = await withMySQLId( + this.apiClient, + this.flags, + this.args, + ); + + const database = await p.runStep("fetching database", async () => { + const response = await this.apiClient.database.getMysqlDatabase({ + mysqlDatabaseId, + }); + assertStatus(response, 200); + return response.data; + }); + + const candidates = await p.runStep("fetching available versions", () => + getUpgradeCandidatesForVersion(this.apiClient, database.version), + ); + + if (candidates.length === 0) { + await p.complete( + + The database {database.name} already runs the latest + available version ({database.version}). ✅ + , + ); + return; + } + + const targetVersion = await this.determineTargetVersion(p, candidates); + + if (!this.flags.force) { + const confirmed = await p.addConfirmation( + `Confirm upgrading ${database.name} (${database.description}) from version ${database.version} to ${targetVersion.number}`, + ); + + if (!confirmed) { + p.addInfo("Upgrade will not be triggered."); + await p.complete(<>); + ux.exit(1); + } + } + + await p.runStep("triggering upgrade", async () => { + const response = await this.apiClient.database.patchMysqlDatabase({ + mysqlDatabaseId, + data: { version: targetVersion.number }, + }); + assertStatus(response, 204); + }); + + if (this.flags.wait) { + await this.waitUntilUpgraded( + p, + mysqlDatabaseId, + targetVersion, + database.statusSetAt, + ); + await p.complete( + + The database {database.name} was upgraded to version{" "} + {targetVersion.number}. + , + ); + return; + } + + await p.complete( + + The upgrade of {database.name} to version{" "} + {targetVersion.number} has been started. + , + ); + } + + private async determineTargetVersion( + p: ProcessRenderer, + candidates: MySqlVersion[], + ): Promise { + const requested = this.flags.version; + + if (requested === "latest") { + return candidates.sort(compareVersions)[candidates.length - 1]; + } + + if (requested) { + const match = candidates.find((c) => c.number === requested); + if (match) { + return match; + } + + p.error( + new Error( + `"${requested}" is not a valid upgrade target for this database; available versions are: ${candidates + .map((c) => c.number) + .join(", ")}`, + ), + ); + ux.exit(1); + } + + return await p.addSelect( + "Please select the version to upgrade to", + candidates.map((c) => ({ value: c, label: c.number })), + ); + } + + private async waitUntilUpgraded( + p: ProcessRenderer, + mysqlDatabaseId: string, + targetVersion: MySqlVersion, + statusSetAtBeforeUpgrade: string, + ): Promise { + const step = p.addStep("waiting for the upgrade to complete"); + + await waitUntil(async () => { + const response = await this.apiClient.database.getMysqlDatabase({ + mysqlDatabaseId, + }); + + if ( + response.status === 200 && + isUpgradeComplete( + response.data, + targetVersion.number, + statusSetAtBeforeUpgrade, + ) + ) { + return response.data; + } + + return null; + }, this.flags["wait-timeout"]); + + step.complete(); + } + + protected render(): ReactNode { + return true; + } +} diff --git a/src/lib/resources/database/mysql/upgrade.test.ts b/src/lib/resources/database/mysql/upgrade.test.ts new file mode 100644 index 000000000..9c78ac9de --- /dev/null +++ b/src/lib/resources/database/mysql/upgrade.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, it } from "@jest/globals"; +import { isUpgradeComplete } from "./upgrade.js"; +import { MittwaldAPIV2 } from "@mittwald/api-client"; + +type Database = MittwaldAPIV2.Components.Schemas.DatabaseMySqlDatabase; +type DatabaseStatus = MittwaldAPIV2.Components.Schemas.DatabaseDatabaseStatus; + +const statusSetAtBefore = "2024-01-01T00:00:00Z"; + +function database(overrides: { + version: string; + status: DatabaseStatus; + statusSetAt: string; +}): Database { + return { + id: "83e0cb85-dcf7-4968-8646-87a63980ae91", + name: "mysql_demo", + description: "Demo DB", + projectId: "p1", + isReady: overrides.status === "ready", + isShared: false, + hostname: "h", + externalHostname: "eh", + createdAt: statusSetAtBefore, + updatedAt: statusSetAtBefore, + storageUsageInBytes: 1, + storageUsageInBytesSetAt: statusSetAtBefore, + characterSettings: { + characterSet: "utf8mb4", + collation: "utf8mb4_unicode_ci", + }, + ...overrides, + }; +} + +describe("isUpgradeComplete", () => { + it("is not complete while the status still reflects the state from before the upgrade", () => { + // The API updates `version` to the desired value as soon as the patch is + // accepted, while the status briefly still reads "ready" from before the + // upgrade. Treating this as complete would return before the upgrade even + // starts. + const stale = database({ + version: "8.4", + status: "ready", + statusSetAt: statusSetAtBefore, + }); + + expect(isUpgradeComplete(stale, "8.4", statusSetAtBefore)).toBe(false); + }); + + it("is not complete while the database is pending", () => { + const pending = database({ + version: "8.4", + status: "pending", + statusSetAt: "2024-01-01T00:00:05Z", + }); + + expect(isUpgradeComplete(pending, "8.4", statusSetAtBefore)).toBe(false); + }); + + it("is complete once the database is ready again with a newer status timestamp", () => { + const ready = database({ + version: "8.4", + status: "ready", + statusSetAt: "2024-01-01T00:00:20Z", + }); + + expect(isUpgradeComplete(ready, "8.4", statusSetAtBefore)).toBe(true); + }); + + it("is not complete when the database is ready but reports another version", () => { + const otherVersion = database({ + version: "8.0", + status: "ready", + statusSetAt: "2024-01-01T00:00:20Z", + }); + + expect(isUpgradeComplete(otherVersion, "8.4", statusSetAtBefore)).toBe( + false, + ); + }); + + it("is not complete when the upgrade failed", () => { + const failed = database({ + version: "8.4", + status: "error", + statusSetAt: "2024-01-01T00:00:20Z", + }); + + expect(isUpgradeComplete(failed, "8.4", statusSetAtBefore)).toBe(false); + }); +}); diff --git a/src/lib/resources/database/mysql/upgrade.ts b/src/lib/resources/database/mysql/upgrade.ts new file mode 100644 index 000000000..591b101d3 --- /dev/null +++ b/src/lib/resources/database/mysql/upgrade.ts @@ -0,0 +1,30 @@ +import { MittwaldAPIV2 } from "@mittwald/api-client"; + +type Database = MittwaldAPIV2.Components.Schemas.DatabaseMySqlDatabase; + +/** + * Determines whether an upgrade triggered by patching the database's version + * has finished. + * + * `version` holds the _desired_ version and is updated as soon as the patch is + * accepted, so it does not indicate that the upgrade has finished. The status + * also still reads "ready" for a moment after patching, which means waiting for + * "ready" alone would return before the upgrade even starts. + * + * `statusSetAt` records when the status was last set, so requiring it to differ + * from the value observed before patching proves the database has changed state + * at least once since. Both timestamps come from the API, so comparing them is + * not affected by clock skew on the client. + */ +export function isUpgradeComplete( + database: Database, + targetVersion: string, + statusSetAtBeforeUpgrade: string, +): boolean { + return ( + database.statusSetAt !== statusSetAtBeforeUpgrade && + database.version === targetVersion && + database.status === "ready" && + database.isReady + ); +} diff --git a/src/lib/resources/database/mysql/versions.test.ts b/src/lib/resources/database/mysql/versions.test.ts new file mode 100644 index 000000000..e940f6317 --- /dev/null +++ b/src/lib/resources/database/mysql/versions.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from "@jest/globals"; +import { getUpgradeCandidates } from "./versions.js"; +import { MittwaldAPIV2 } from "@mittwald/api-client"; + +type MySqlVersion = MittwaldAPIV2.Components.Schemas.DatabaseMySqlVersion; + +function version(number: string, disabled = false): MySqlVersion { + return { id: `id-${number}`, name: `MySQL ${number}`, number, disabled }; +} + +describe("getUpgradeCandidates", () => { + const versions = [ + version("5.7"), + version("8.0"), + version("8.4"), + version("10.11"), + ]; + + it("only offers versions newer than the current one", () => { + expect(getUpgradeCandidates(versions, "8.0").map((v) => v.number)).toEqual([ + "8.4", + "10.11", + ]); + }); + + it("compares versions numerically rather than lexically", () => { + expect(getUpgradeCandidates(versions, "8.4").map((v) => v.number)).toEqual([ + "10.11", + ]); + }); + + it("omits disabled versions", () => { + const withDisabled = [version("8.0"), version("8.4", true)]; + + expect( + getUpgradeCandidates(withDisabled, "5.7").map((v) => v.number), + ).toEqual(["8.0"]); + }); + + it("returns nothing when the current version is the latest", () => { + expect(getUpgradeCandidates(versions, "10.11")).toEqual([]); + }); + + it("does not offer the current version itself", () => { + expect( + getUpgradeCandidates(versions, "5.7").map((v) => v.number), + ).not.toContain("5.7"); + }); +}); diff --git a/src/lib/resources/database/mysql/versions.ts b/src/lib/resources/database/mysql/versions.ts new file mode 100644 index 000000000..b49075464 --- /dev/null +++ b/src/lib/resources/database/mysql/versions.ts @@ -0,0 +1,47 @@ +import { MittwaldAPIV2, MittwaldAPIV2Client } from "@mittwald/api-client"; +import { assertStatus } from "@mittwald/api-client-commons"; +import semver from "semver"; + +type MySqlVersion = MittwaldAPIV2.Components.Schemas.DatabaseMySqlVersion; + +/** + * MySQL versions are given in `.` format, which is not valid + * semantic versioning; `semver.coerce` pads them to `..0`. + */ +function toSemver(version: string): semver.SemVer { + const coerced = semver.coerce(version); + if (!coerced) { + throw new Error(`could not parse MySQL version "${version}"`); + } + return coerced; +} + +export function compareVersions(a: MySqlVersion, b: MySqlVersion): number { + return semver.compare(toSemver(a.number), toSemver(b.number)); +} + +/** + * Determines the versions a database running `currentVersion` may be upgraded + * to. MySQL does not support downgrades, so only newer versions are considered; + * versions that are disabled are not offered for new upgrades. + */ +export function getUpgradeCandidates( + versions: MySqlVersion[], + currentVersion: string, +): MySqlVersion[] { + const current = toSemver(currentVersion); + + return versions + .filter((v) => !v.disabled && semver.gt(toSemver(v.number), current)) + .sort(compareVersions); +} + +export async function getUpgradeCandidatesForVersion( + apiClient: MittwaldAPIV2Client, + currentVersion: string, +): Promise { + const response = await apiClient.database.listMysqlVersions({}); + assertStatus(response, 200); + + return getUpgradeCandidates(response.data, currentVersion); +}