Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 55 additions & 0 deletions docs/database.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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 <value>] [--version <value>] [-f] [-q] [-w] [--wait-timeout <value>]

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=<value> the MySQL version to upgrade to
--wait-timeout=<value> [default: 600s] the duration to wait for the resource to be ready (common units like 'ms',
's', 'm' are accepted).

AUTHENTICATION FLAGS
--token=<value> 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 <database-id> --version 8.0

Upgrade a database to the latest available version, and wait for the upgrade to complete

$ mw database mysql upgrade <database-id> --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=<value> 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
Expand Down
205 changes: 205 additions & 0 deletions src/commands/database/mysql/upgrade.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof Upgrade, void> {
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 <database-id> --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 <database-id> --version latest --wait",
},
];

protected async exec(): Promise<void> {
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(
<Text>
The database <Value>{database.name}</Value> already runs the latest
available version (<Value>{database.version}</Value>). ✅
</Text>,
);
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(
<Success>
The database <Value>{database.name}</Value> was upgraded to version{" "}
<Value>{targetVersion.number}</Value>.
</Success>,
);
return;
}

await p.complete(
<Success>
The upgrade of <Value>{database.name}</Value> to version{" "}
<Value>{targetVersion.number}</Value> has been started.
</Success>,
);
}

private async determineTargetVersion(
p: ProcessRenderer,
candidates: MySqlVersion[],
): Promise<MySqlVersion> {
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<void> {
const step = p.addStep("waiting for the upgrade to complete");

await waitUntil<Database>(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;
}
}
92 changes: 92 additions & 0 deletions src/lib/resources/database/mysql/upgrade.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
30 changes: 30 additions & 0 deletions src/lib/resources/database/mysql/upgrade.ts
Original file line number Diff line number Diff line change
@@ -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
);
}
Loading
Loading