Skip to content

Commit

Permalink
Merge pull request #279 from mittwald/bugfix/mysql-create-retry
Browse files Browse the repository at this point in the history
Deal with eventual consistency in mysql database API calls
  • Loading branch information
martin-helmich authored Mar 6, 2024
2 parents 8133afb + 0257ad4 commit f647c04
Show file tree
Hide file tree
Showing 21 changed files with 307 additions and 118 deletions.
Binary file not shown.
Binary file not shown.
12 changes: 6 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2280,7 +2280,7 @@ USAGE
$ mw database mysql delete DATABASE-ID [-q] [-f]
ARGUMENTS
DATABASE-ID The ID of the database (when a project context is set, you can also use the name)
DATABASE-ID The ID or name of the database
FLAGS
-f, --force Do not ask for confirmation
Expand All @@ -2305,7 +2305,7 @@ USAGE
$ mw database mysql dump DATABASE-ID -o <value> [-q] [-p <value>] [--ssh-user <value>] [--temporary-user] [--gzip]
ARGUMENTS
DATABASE-ID The ID of the database (when a project context is set, you can also use the name)
DATABASE-ID The ID or name of the database
FLAGS
-o, --output=<value> (required) the output file to write the dump to ("-" for stdout)
Expand Down Expand Up @@ -2365,7 +2365,7 @@ USAGE
$ mw database mysql get DATABASE-ID [-o json|yaml | | ]
ARGUMENTS
DATABASE-ID The ID of the database (when a project context is set, you can also use the name)
DATABASE-ID The ID or name of the database
FLAGS
-o, --output=<option> output in a more machine friendly format
Expand Down Expand Up @@ -2415,7 +2415,7 @@ USAGE
$ mw database mysql phpmyadmin DATABASE-ID
ARGUMENTS
DATABASE-ID The ID of the database (when a project context is set, you can also use the name)
DATABASE-ID The ID or name of the database
```

## `mw database mysql port-forward DATABASE-ID`
Expand All @@ -2427,7 +2427,7 @@ USAGE
$ mw database mysql port-forward DATABASE-ID [-q] [--ssh-user <value>] [--port <value>]
ARGUMENTS
DATABASE-ID The ID of the database (when a project context is set, you can also use the name)
DATABASE-ID The ID or name of the database
FLAGS
-q, --quiet suppress process output and only display a machine-readable summary.
Expand Down Expand Up @@ -2457,7 +2457,7 @@ USAGE
$ mw database mysql shell DATABASE-ID [-q] [-p <value>]
ARGUMENTS
DATABASE-ID The ID of the database (when a project context is set, you can also use the name)
DATABASE-ID The ID or name of the database
FLAGS
-p, --mysql-password=<value> the password to use for the MySQL user (env: MYSQL_PWD)
Expand Down
11 changes: 5 additions & 6 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,25 +32,24 @@
"post:generate": "yarn run -T compile && yarn run -T compile:cjs",
"test": "yarn test:format && yarn test:licenses && yarn test:unit",
"test:format": "yarn lint && yarn format --check",
"test:unit": "mocha --forbid-only \"src/**/*.test.ts\"",
"test:licenses": "yarn license-check --summary --unknown --failOn 'UNLICENSED;UNKNOWN'",
"test:readme": "yarn generate:readme && git diff --exit-code README.md"
"test:readme": "yarn generate:readme && git diff --exit-code README.md",
"test:unit": "mocha --forbid-only \"src/**/*.test.ts\""
},
"files": [
".deps",
"dist/**/*.{js,d.ts}",
"bin"
"bin",
"dist/**/*.{js,d.ts}"
],
"dependencies": {
"@mittwald/api-client": "^4.9.0",
"@mittwald/api-client-commons": "^4.2.2",
"@mittwald/react-use-promise": "^2.1.2",
"@oclif/core": "^3.18.1",
"@oclif/plugin-autocomplete": "^3.0.3",
"@oclif/plugin-help": "^6.0.5",
"@oclif/plugin-update": "^4.1.3",
"@oclif/plugin-warn-if-update-available": "^3.0.2",
"axios": "^1.5.0",
"axios-retry": "^4.0.0",
"chalk": "^5.3.0",
"date-fns": "^3.2.0",
"humanize-string": "^3.0.0",
Expand Down
6 changes: 6 additions & 0 deletions src/BaseCommand.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import { Command } from "@oclif/core";
import * as fs from "fs/promises";
import * as path from "path";
import { MittwaldAPIV2Client } from "@mittwald/api-client";
import { configureAxiosRetry } from "./lib/api_retry.js";
import { configureConsistencyHandling } from "./lib/api_consistency.js";

export abstract class BaseCommand extends Command {
protected authenticationRequired = true;
Expand All @@ -17,9 +19,13 @@ export abstract class BaseCommand extends Command {
`Could not get token from either config file (${this.getTokenFilename()}) or environment`,
);
}

this.apiClient = MittwaldAPIV2Client.newWithToken(token);
this.apiClient.axios.defaults.headers["User-Agent"] =
`mittwald-cli/${this.config.version}`;

configureAxiosRetry(this.apiClient.axios);
configureConsistencyHandling(this.apiClient.axios);
}
}

Expand Down
109 changes: 109 additions & 0 deletions src/commands/database/mysql/create.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import { expect, test } from "@oclif/test";

describe("database:mysql:create", () => {
const projectId = "339d6458-839f-4809-a03d-78700069690c";
const databaseId = "83e0cb85-dcf7-4968-8646-87a63980ae91";
const userId = "a8c1eb2a-aa4d-4daf-8e21-9d91d56559ca";
const password = "secret";
const description = "Test";

const createFlags = [
"database mysql create",
"--project-id",
projectId,
"--version",
"8.0",
"--description",
description,
"--user-password",
password,
];

test
.nock("https://api.mittwald.de", (api) => {
api.get(`/v2/projects/${projectId}`).reply(200, {
id: projectId,
});
api
.post(`/v2/projects/${projectId}/mysql-databases`, {
database: {
projectId,
description,
version: "8.0",
characterSettings: {
collation: "utf8mb4_unicode_ci",
characterSet: "utf8mb4",
},
},
user: {
password,
externalAccess: false,
accessLevel: "full",
},
})
.reply(201, { id: databaseId, userId });

api.get(`/v2/mysql-databases/${databaseId}`).reply(200, {
id: databaseId,
name: "mysql_xxxxxx",
});

api.get(`/v2/mysql-users/${userId}`).reply(200, {
id: userId,
name: "dbu_xxxxxx",
});
})
.env({ MITTWALD_API_TOKEN: "foo" })
.stdout()
.command(createFlags)
.it("creates a database and prints database and user name", (ctx) => {
// cannot match on exact output, because linebreaks
expect(ctx.stdout).to.contain("The database mysql_xxxxxx");
expect(ctx.stdout).to.contain("the user dbu_xxxxxx");
});

test
.nock("https://api.mittwald.de", (api) => {
api.get(`/v2/projects/${projectId}`).reply(200, {
id: projectId,
});
api
.post(`/v2/projects/${projectId}/mysql-databases`, {
database: {
projectId,
description: "Test",
version: "8.0",
characterSettings: {
collation: "utf8mb4_unicode_ci",
characterSet: "utf8mb4",
},
},
user: {
password: "secret",
externalAccess: false,
accessLevel: "full",
},
})
.reply(201, { id: databaseId, userId });

api.get(`/v2/mysql-databases/${databaseId}`).reply(200, {
id: databaseId,
name: "mysql_xxxxxx",
});

api.get(`/v2/mysql-users/${userId}`).times(3).reply(403);

api.get(`/v2/mysql-users/${userId}`).reply(200, {
id: userId,
name: "dbu_xxxxxx",
});
})
.env({ MITTWALD_API_TOKEN: "foo" })
.stdout()
.command(createFlags)
.it("retries fetching user until successful", (ctx) => {
// cannot match on exact output, because linebreaks
expect(ctx.stdout).to.contain("The database mysql_xxxxxx");
expect(ctx.stdout).to.contain("the user dbu_xxxxxx");
});
});
110 changes: 73 additions & 37 deletions src/commands/database/mysql/create.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@ import { Text } from "ink";
import { assertStatus } from "@mittwald/api-client-commons";
import { Success } from "../../../rendering/react/components/Success.js";
import { Value } from "../../../rendering/react/components/Value.js";
import type { MittwaldAPIV2 } from "@mittwald/api-client";

type Database = MittwaldAPIV2.Components.Schemas.DatabaseMySqlDatabase;
type User = MittwaldAPIV2.Components.Schemas.DatabaseMySqlUser;

type Result = {
databaseId: string;
Expand Down Expand Up @@ -71,59 +75,76 @@ export class Create extends ExecRenderBaseCommand<typeof Create, Result> {

const password = await this.getPassword(p);

const db = await p.runStep("creating MySQL database", async () => {
const r = await this.apiClient.database.createMysqlDatabase({
projectId,
data: {
database: {
projectId,
description,
version,
characterSettings: {
collation,
characterSet,
},
},
user: {
password,
externalAccess,
accessLevel: accessLevel as "full" | "readonly",
},
const db = await this.createMySQLDatabase(
p,
projectId,
{
description,
version,
characterSettings: {
collation,
characterSet,
},
});

assertStatus(r, 201);
return r.data;
});
},
{
password,
externalAccess,
accessLevel: accessLevel as "full" | "readonly",
},
);

const database = await p.runStep("fetching database", async () => {
const r = await this.apiClient.database.getMysqlDatabase({
const response = await this.apiClient.database.getMysqlDatabase({
mysqlDatabaseId: db.id,
});
assertStatus(r, 200);

return r.data;
assertStatus(response, 200);
return response.data;
});

const user = await p.runStep("fetching user", async () => {
const r = await this.apiClient.database.getMysqlUser({
const response = await this.apiClient.database.getMysqlUser({
mysqlUserId: db.userId,
});
assertStatus(r, 200);

return r.data;
assertStatus(response, 200);
return response.data;
});

p.complete(
<Success>
The database <Value>{database.name}</Value> and the user{" "}
<Value>{user.name}</Value> were successfully created.
</Success>,
);
await p.complete(<DatabaseCreateSuccess database={database} user={user} />);

return { databaseId: db.id, userId: db.userId };
}

private async createMySQLDatabase(
p: ProcessRenderer,
projectId: string,
database: {
description: string;
version: string;
characterSettings: {
collation: string;
characterSet: string;
};
},
user: {
password: string;
externalAccess: boolean;
accessLevel: "full" | "readonly";
},
) {
return await p.runStep("creating MySQL database", async () => {
const r = await this.apiClient.database.createMysqlDatabase({
projectId,
data: {
database: { projectId, ...database },
user,
},
});

assertStatus(r, 201);
return r.data;
});
}

private async getPassword(p: ProcessRenderer): Promise<string> {
if (this.flags["user-password"]) {
return this.flags["user-password"];
Expand All @@ -138,3 +159,18 @@ export class Create extends ExecRenderBaseCommand<typeof Create, Result> {
}
}
}

function DatabaseCreateSuccess({
database,
user,
}: {
database: Database;
user: User;
}) {
return (
<Success>
The database <Value>{database.name}</Value> and the user{" "}
<Value>{user.name}</Value> were successfully created.
</Success>
);
}
1 change: 0 additions & 1 deletion src/commands/database/mysql/delete.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@ export default class Delete extends DeleteBaseCommand<typeof Delete> {
this.apiClient,
this.flags,
this.args,
this.config,
);
const response = await this.apiClient.database.deleteMysqlDatabase({
mysqlDatabaseId,
Expand Down
7 changes: 1 addition & 6 deletions src/commands/database/mysql/dump.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -58,12 +58,7 @@ export class Dump extends ExecRenderBaseCommand<
static args = { ...mysqlArgs };

protected async exec(): Promise<Record<string, never>> {
const databaseId = await withMySQLId(
this.apiClient,
this.flags,
this.args,
this.config,
);
const databaseId = await withMySQLId(this.apiClient, this.flags, this.args);
const p = makeProcessRenderer(this.flags, "Dumping a MySQL database");

const connectionDetails = await getConnectionDetailsWithPassword(
Expand Down
1 change: 0 additions & 1 deletion src/commands/database/mysql/get.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,6 @@ export abstract class Get extends GetBaseCommand<typeof Get, APIResponse> {
this.apiClient,
this.flags,
this.args,
this.config,
);
return await this.apiClient.database.getMysqlDatabase({
mysqlDatabaseId,
Expand Down
Loading

0 comments on commit f647c04

Please sign in to comment.