mysql-shell-client 0.6.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,524 @@
1
+ # Copyright 2025 Canonical Ltd.
2
+ # See LICENSE file for licensing details.
3
+
4
+ import json
5
+ import logging
6
+ from typing import Any, Mapping, Sequence
7
+
8
+ from ..builders import StringQueryQuoter
9
+ from ..executors import BaseExecutor
10
+ from ..executors.errors import ExecutionError
11
+ from ..models.account import Role, User
12
+ from ..models.instance import InstanceRole, InstanceState
13
+ from ..models.statement import VariableScope
14
+
15
+ logger = logging.getLogger()
16
+
17
+ _Attrs = Mapping[str, str] | None
18
+
19
+
20
+ class MySQLInstanceClient:
21
+ """Class to encapsulate all instance operations using MySQL Shell."""
22
+
23
+ def __init__(self, executor: BaseExecutor, quoter: StringQueryQuoter):
24
+ """Initialize the class."""
25
+ self._executor = executor
26
+ self._quoter = quoter
27
+
28
+ def check_work_ongoing(self, name_pattern: str) -> bool:
29
+ """Checks whether an instance work is ongoing."""
30
+ query = (
31
+ "SELECT work_completed, work_estimated "
32
+ "FROM performance_schema.events_stages_current "
33
+ "WHERE event_name LIKE {name_pattern}"
34
+ )
35
+ query = query.format(
36
+ name_pattern=self._quoter.quote_value(name_pattern),
37
+ )
38
+
39
+ try:
40
+ rows = self._executor.execute_sql(query)
41
+ except ExecutionError:
42
+ logger.error(f"Failed to check work for events with {name_pattern=}")
43
+ raise
44
+ else:
45
+ return any(row["work_completed"] < row["work_estimated"] for row in rows)
46
+
47
+ def create_instance_role(self, role: Role, roles: list[str] = None) -> None:
48
+ """Creates a new instance role."""
49
+ if not roles:
50
+ granting_query = ""
51
+ else:
52
+ granting_query = "GRANT {roles} TO {rolename}@{hostname}"
53
+ granting_query = granting_query.format(
54
+ rolename=self._quoter.quote_value(role.rolename),
55
+ hostname=self._quoter.quote_value(role.hostname),
56
+ roles=", ".join(self._quoter.quote_value(r) for r in roles),
57
+ )
58
+
59
+ creation_query = "CREATE ROLE {rolename}@{hostname}"
60
+ creation_query = creation_query.format(
61
+ rolename=self._quoter.quote_value(role.rolename),
62
+ hostname=self._quoter.quote_value(role.hostname),
63
+ )
64
+
65
+ queries = ";".join((
66
+ creation_query,
67
+ granting_query,
68
+ ))
69
+
70
+ try:
71
+ self._executor.execute_sql(queries)
72
+ except ExecutionError:
73
+ logger.error(f"Failed to create instance role {role.rolename}.{role.hostname}")
74
+ raise
75
+
76
+ def create_instance_user(self, user: User, password: str, roles: list[str] = None) -> None:
77
+ """Creates an instance user with the provided attributes."""
78
+ if not roles:
79
+ granting_query = ""
80
+ else:
81
+ granting_query = "GRANT {roles} TO {username}@{hostname}"
82
+ granting_query = granting_query.format(
83
+ username=self._quoter.quote_value(user.username),
84
+ hostname=self._quoter.quote_value(user.hostname),
85
+ roles=", ".join(self._quoter.quote_value(r) for r in roles),
86
+ )
87
+
88
+ creation_query = (
89
+ "CREATE USER {username}@{hostname} IDENTIFIED BY {password} ATTRIBUTE {attrs}"
90
+ )
91
+ creation_query = creation_query.format(
92
+ username=self._quoter.quote_value(user.username),
93
+ hostname=self._quoter.quote_value(user.hostname),
94
+ password=self._quoter.quote_value(password),
95
+ attrs=self._quoter.quote_value(user.serialize_attrs()),
96
+ )
97
+
98
+ queries = ";".join((
99
+ creation_query,
100
+ granting_query,
101
+ ))
102
+
103
+ try:
104
+ self._executor.execute_sql(queries)
105
+ except ExecutionError:
106
+ logger.error(f"Failed to create instance user {user.username}.{user.hostname}")
107
+ raise
108
+
109
+ def delete_instance_user(self, user: User) -> None:
110
+ """Deletes an instance user if it exists."""
111
+ query = "DROP USER IF EXISTS {username}@{hostname}"
112
+ query = query.format(
113
+ username=self._quoter.quote_value(user.username),
114
+ hostname=self._quoter.quote_value(user.hostname),
115
+ )
116
+
117
+ try:
118
+ self._executor.execute_sql(query)
119
+ except ExecutionError:
120
+ logger.error(f"Failed to delete instance user {user.username}.{user.hostname}")
121
+ raise
122
+
123
+ def delete_instance_users(self, users: list[User]) -> None:
124
+ """Deletes the instance users provided."""
125
+ query = "DROP USER IF EXISTS {username}@{hostname}"
126
+ queries = []
127
+
128
+ for user in users:
129
+ queries.append(
130
+ query.format(
131
+ username=self._quoter.quote_value(user.username),
132
+ hostname=self._quoter.quote_value(user.hostname),
133
+ )
134
+ )
135
+
136
+ queries = ";".join(queries)
137
+
138
+ try:
139
+ self._executor.execute_sql(queries)
140
+ except ExecutionError:
141
+ logger.error("Failed to delete instance users")
142
+ raise
143
+
144
+ def update_instance_user(self, user: User, password: str = None, attrs: _Attrs = None) -> None:
145
+ """Updates an instance user with the provided password and / or attributes."""
146
+ if not password and not attrs:
147
+ raise ValueError("Either password or attrs must be provided")
148
+
149
+ query = "ALTER USER {username}@{hostname}"
150
+ query = query.format(
151
+ username=self._quoter.quote_value(user.username),
152
+ hostname=self._quoter.quote_value(user.hostname),
153
+ )
154
+
155
+ if password:
156
+ query += f" IDENTIFIED BY {self._quoter.quote_value(password)}"
157
+ if attrs:
158
+ query += f" ATTRIBUTE {self._quoter.quote_value(json.dumps(attrs))}"
159
+
160
+ try:
161
+ self._executor.execute_sql(query)
162
+ except ExecutionError:
163
+ logger.error(f"Failed to update instance user {user.username}.{user.hostname}")
164
+ raise
165
+
166
+ def get_cluster_instance_label(self) -> str | None:
167
+ """Gets the instance label within the cluster."""
168
+ query = (
169
+ "SELECT instance_name "
170
+ "FROM mysql_innodb_cluster_metadata.instances "
171
+ "WHERE mysql_server_uuid = @@server_uuid"
172
+ )
173
+
174
+ try:
175
+ rows = self._executor.execute_sql(query)
176
+ except ExecutionError:
177
+ logger.error("Failed to get cluster instance label")
178
+ raise
179
+
180
+ if not rows:
181
+ return None
182
+
183
+ return rows[0]["instance_name"]
184
+
185
+ def get_cluster_instance_labels(self, cluster_name: str) -> list[str]:
186
+ """Gets the instance labels within the cluster."""
187
+ query = (
188
+ "SELECT instance_name "
189
+ "FROM mysql_innodb_cluster_metadata.instances "
190
+ "WHERE cluster_id IN ( "
191
+ " SELECT cluster_id "
192
+ " FROM mysql_innodb_cluster_metadata.clusters "
193
+ " WHERE cluster_name = {cluster_name} "
194
+ ")"
195
+ )
196
+ query = query.format(
197
+ cluster_name=self._quoter.quote_value(cluster_name),
198
+ )
199
+
200
+ try:
201
+ rows = self._executor.execute_sql(query)
202
+ except ExecutionError:
203
+ logger.error(f"Failed to get cluster instance labels with {cluster_name=}")
204
+ raise
205
+ else:
206
+ return [row["instance_name"] for row in rows]
207
+
208
+ def get_cluster_labels(self) -> list[str]:
209
+ """Gets the cluster labels."""
210
+ query = "SELECT cluster_name FROM mysql_innodb_cluster_metadata.clusters"
211
+
212
+ try:
213
+ rows = self._executor.execute_sql(query)
214
+ except ExecutionError:
215
+ logger.error("Failed to get cluster labels")
216
+ raise
217
+ else:
218
+ return [row["cluster_name"] for row in rows]
219
+
220
+ def get_instance_replication_state(self) -> InstanceState | None:
221
+ """Gets the instance replication state."""
222
+ query = (
223
+ "SELECT member_state "
224
+ "FROM performance_schema.replication_group_members "
225
+ "WHERE member_id = @@server_uuid"
226
+ )
227
+
228
+ try:
229
+ rows = self._executor.execute_sql(query)
230
+ except ExecutionError:
231
+ logger.error("Failed to get instance replication state")
232
+ raise
233
+
234
+ if not rows:
235
+ return None
236
+
237
+ state = rows[0]["member_state"]
238
+ state = InstanceState(state) if state else None
239
+ return state
240
+
241
+ def get_instance_replication_role(self) -> InstanceRole | None:
242
+ """Gets the instance replication role."""
243
+ query = (
244
+ "SELECT member_role "
245
+ "FROM performance_schema.replication_group_members "
246
+ "WHERE member_id = @@server_uuid"
247
+ )
248
+
249
+ try:
250
+ rows = self._executor.execute_sql(query)
251
+ except ExecutionError:
252
+ logger.error("Failed to get instance replication role")
253
+ raise
254
+
255
+ if not rows:
256
+ return None
257
+
258
+ role = rows[0]["member_role"]
259
+ role = InstanceRole(role) if role else None
260
+ return role
261
+
262
+ def get_instance_variable(self, scope: VariableScope, name: str) -> Any | None:
263
+ """Gets an instance variable by scope and name."""
264
+ if scope in (VariableScope.PERSIST, VariableScope.PERSIST_ONLY):
265
+ raise ValueError("Invalid scope")
266
+
267
+ quoted_name = self._quoter.quote_identifier(name)
268
+
269
+ query = "SELECT @@{scope}.{name} AS {alias}"
270
+ query = query.format(
271
+ scope=scope.value,
272
+ name=quoted_name,
273
+ alias=quoted_name,
274
+ )
275
+
276
+ try:
277
+ rows = self._executor.execute_sql(query)
278
+ except ExecutionError:
279
+ logger.error(f"Failed to get instance variable {scope}.{name}")
280
+ raise
281
+
282
+ if not rows:
283
+ return None
284
+
285
+ return rows[0][name]
286
+
287
+ def set_instance_variable(self, scope: VariableScope, name: str, value: Any) -> None:
288
+ """Sets an instance variable by scope and name."""
289
+ quoted_name = self._quoter.quote_identifier(name)
290
+ quoted_value = self._quoter.quote_value(value) if isinstance(value, str) else value
291
+
292
+ query = "SET @@{scope}.{name} = {value}"
293
+ query = query.format(
294
+ scope=scope.value,
295
+ name=quoted_name,
296
+ value=quoted_value,
297
+ )
298
+
299
+ try:
300
+ self._executor.execute_sql(query)
301
+ except ExecutionError:
302
+ logger.error(f"Failed to set instance variable {scope}.{name}")
303
+ raise
304
+
305
+ def get_instance_version(self) -> str | None:
306
+ """Gets the instance version value."""
307
+ version = self.get_instance_variable(VariableScope.GLOBAL, "version")
308
+ if not version:
309
+ return None
310
+
311
+ return version.split("-")[0]
312
+
313
+ def install_instance_plugin(self, name: str, path: str) -> None:
314
+ """Installs an instance plugin by name and path."""
315
+ query = "INSTALL PLUGIN {plugin_name} SONAME {plugin_path}"
316
+ query = query.format(
317
+ plugin_name=self._quoter.quote_identifier(name),
318
+ plugin_path=self._quoter.quote_value(path),
319
+ )
320
+
321
+ try:
322
+ self._executor.execute_sql(query)
323
+ except ExecutionError:
324
+ logger.error(f"Failed to install instance plugin with {name=} and {path=}")
325
+ raise
326
+
327
+ def uninstall_instance_plugin(self, name: str) -> None:
328
+ """Uninstalls an instance plugin by name."""
329
+ query = "UNINSTALL PLUGIN {plugin_name}"
330
+ query = query.format(
331
+ plugin_name=self._quoter.quote_identifier(name),
332
+ )
333
+
334
+ try:
335
+ self._executor.execute_sql(query)
336
+ except ExecutionError:
337
+ logger.error(f"Failed to uninstall instance plugin with {name=}")
338
+ raise
339
+
340
+ def reload_instance_certs(self) -> None:
341
+ """Reloads TLS certificates."""
342
+ query = "ALTER INSTANCE RELOAD TLS"
343
+
344
+ try:
345
+ self._executor.execute_sql(query)
346
+ except ExecutionError:
347
+ logger.error("Failed to reload instance TLS certificates")
348
+ raise
349
+
350
+ def search_instance_replication_members(
351
+ self,
352
+ roles: Sequence[InstanceRole] | None = None,
353
+ states: Sequence[InstanceState] | None = None,
354
+ ) -> list[str]:
355
+ """Searches the instance replication member IDs by role and/or state."""
356
+ if not roles:
357
+ roles = list(InstanceRole)
358
+ if not states:
359
+ states = list(InstanceState)
360
+
361
+ query = (
362
+ "SELECT member_id "
363
+ "FROM performance_schema.replication_group_members "
364
+ "WHERE member_role IN ({roles}) AND member_state IN ({states})"
365
+ )
366
+ query = query.format(
367
+ roles=", ".join([self._quoter.quote_value(role) for role in roles]),
368
+ states=", ".join([self._quoter.quote_value(state) for state in states]),
369
+ )
370
+
371
+ try:
372
+ rows = self._executor.execute_sql(query)
373
+ except ExecutionError:
374
+ logger.error("Failed to search instance replication members")
375
+ raise
376
+ else:
377
+ return [row["member_id"] for row in rows]
378
+
379
+ def search_instance_connection_processes(self, name_pattern: str) -> list[int]:
380
+ """Searches the instance connection process IDs by name pattern."""
381
+ query = (
382
+ "SELECT processlist_id "
383
+ "FROM performance_schema.threads "
384
+ "WHERE "
385
+ " processlist_id != CONNECTION_ID() AND "
386
+ " connection_type IS NOT NULL AND "
387
+ " name LIKE {name_pattern}"
388
+ )
389
+ query = query.format(
390
+ name_pattern=self._quoter.quote_value(name_pattern),
391
+ )
392
+
393
+ try:
394
+ rows = self._executor.execute_sql(query)
395
+ except ExecutionError:
396
+ logger.error(f"Failed to search instance connections with {name_pattern=}")
397
+ raise
398
+ else:
399
+ return [row["processlist_id"] for row in rows]
400
+
401
+ def search_instance_databases(self, name_pattern: str) -> list[str]:
402
+ """Searches the instance databases by name pattern."""
403
+ query = (
404
+ "SELECT schema_name "
405
+ "FROM information_schema.schemata "
406
+ "WHERE schema_name LIKE {name_pattern}"
407
+ )
408
+ query = query.format(
409
+ name_pattern=self._quoter.quote_value(name_pattern),
410
+ )
411
+
412
+ try:
413
+ rows = self._executor.execute_sql(query)
414
+ except ExecutionError:
415
+ logger.error(f"Failed to search instance databases with {name_pattern=}")
416
+ raise
417
+ else:
418
+ return [row["SCHEMA_NAME"] for row in rows]
419
+
420
+ def search_instance_plugins(self, name_pattern: str) -> list[str]:
421
+ """Searches the instance plugins by name pattern."""
422
+ # fmt: off
423
+ query = (
424
+ "SELECT name "
425
+ "FROM mysql.plugin "
426
+ "WHERE name LIKE {name_pattern}"
427
+ )
428
+ # fmt: on
429
+
430
+ query = query.format(
431
+ name_pattern=self._quoter.quote_value(name_pattern),
432
+ )
433
+
434
+ try:
435
+ rows = self._executor.execute_sql(query)
436
+ except ExecutionError:
437
+ logger.error(f"Failed to search instance plugins with {name_pattern=}")
438
+ raise
439
+ else:
440
+ return [row["name"] for row in rows]
441
+
442
+ def search_instance_roles(self, name_pattern: str) -> list[Role]:
443
+ """Searches the instance roles by name pattern."""
444
+ query = (
445
+ "SELECT user, host "
446
+ "FROM mysql.user "
447
+ "WHERE user LIKE {name_pattern} AND authentication_string=''"
448
+ )
449
+ query = query.format(
450
+ name_pattern=self._quoter.quote_value(name_pattern),
451
+ )
452
+
453
+ try:
454
+ rows = self._executor.execute_sql(query)
455
+ except ExecutionError:
456
+ logger.error(f"Failed to search instance roles with {name_pattern=}")
457
+ raise
458
+ else:
459
+ return [Role.from_row(row["user"], row["host"]) for row in rows]
460
+
461
+ def search_instance_users(self, name_pattern: str, attrs: _Attrs = None) -> list[User]:
462
+ """Searches the instance users by name pattern and attributes."""
463
+ attr_filter = "attribute LIKE {string}"
464
+ attr_substr = '%"{key}": "{val}"%'
465
+
466
+ if not attrs:
467
+ strings = ["%"]
468
+ filters = [attr_filter.format(string=self._quoter.quote_value(s)) for s in strings]
469
+ else:
470
+ strings = [attr_substr.format(key=key, val=val) for key, val in attrs.items()]
471
+ filters = [attr_filter.format(string=self._quoter.quote_value(s)) for s in strings]
472
+
473
+ query = (
474
+ "SELECT user, host, attribute "
475
+ "FROM information_schema.user_attributes "
476
+ "WHERE user LIKE {name_pattern} AND {attr_filters}"
477
+ )
478
+ query = query.format(
479
+ name_pattern=self._quoter.quote_value(name_pattern),
480
+ attr_filters=" AND ".join(filters),
481
+ )
482
+
483
+ try:
484
+ rows = self._executor.execute_sql(query)
485
+ except ExecutionError:
486
+ logger.error(f"Failed to search instance users with {name_pattern=}")
487
+ raise
488
+ else:
489
+ return [User.from_row(row["USER"], row["HOST"], row["ATTRIBUTE"]) for row in rows]
490
+
491
+ def start_instance_replication(self) -> None:
492
+ """Starts instance group replication."""
493
+ query = "START GROUP_REPLICATION"
494
+
495
+ try:
496
+ self._executor.execute_sql(query)
497
+ except ExecutionError:
498
+ logger.error("Failed to start instance replication")
499
+ raise
500
+
501
+ def stop_instance_replication(self) -> None:
502
+ """Stops instance group replication."""
503
+ query = "STOP GROUP_REPLICATION"
504
+
505
+ try:
506
+ self._executor.execute_sql(query)
507
+ except ExecutionError:
508
+ logger.error("Failed to stop instance replication")
509
+ raise
510
+
511
+ def stop_instance_processes(self, process_ids: Sequence[int]) -> None:
512
+ """Kills the instances processes by ID."""
513
+ if not process_ids:
514
+ return
515
+
516
+ query = "KILL CONNECTION {id}"
517
+ queries = [query.format(id=self._quoter.quote_value(pid)) for pid in process_ids]
518
+ queries = ";".join(queries)
519
+
520
+ try:
521
+ self._executor.execute_sql(queries)
522
+ except ExecutionError:
523
+ logger.error("Failed to kill instance processes")
524
+ raise
@@ -0,0 +1,5 @@
1
+ # Copyright 2025 Canonical Ltd.
2
+ # See LICENSE file for licensing details.
3
+
4
+ from .base import BaseExecutor
5
+ from .local import LocalExecutor
@@ -0,0 +1,36 @@
1
+ # Copyright 2025 Canonical Ltd.
2
+ # See LICENSE file for licensing details.
3
+
4
+ from abc import ABC, abstractmethod
5
+ from typing import Sequence
6
+
7
+ from ..models import ConnectionDetails
8
+
9
+
10
+ class BaseExecutor(ABC):
11
+ """Base class for all MySQL Shell executors."""
12
+
13
+ def __init__(self, conn_details: ConnectionDetails, shell_path: str):
14
+ """Initialize the executor."""
15
+ self._conn_details = conn_details
16
+ self._shell_path = shell_path
17
+
18
+ @property
19
+ def connection_details(self) -> ConnectionDetails:
20
+ """Return the connection details."""
21
+ return self._conn_details
22
+
23
+ @abstractmethod
24
+ def check_connection(self) -> None:
25
+ """Check the connection."""
26
+ raise NotImplementedError()
27
+
28
+ @abstractmethod
29
+ def execute_py(self, script: str, *, timeout: int | None = None) -> str:
30
+ """Execute a Python script."""
31
+ raise NotImplementedError()
32
+
33
+ @abstractmethod
34
+ def execute_sql(self, script: str, *, timeout: int | None = None) -> Sequence[dict]:
35
+ """Execute a SQL script."""
36
+ raise NotImplementedError()
@@ -0,0 +1,4 @@
1
+ # Copyright 2025 Canonical Ltd.
2
+ # See LICENSE file for licensing details.
3
+
4
+ from .runtime import ExecutionError
@@ -0,0 +1,15 @@
1
+ # Copyright 2025 Canonical Ltd.
2
+ # See LICENSE file for licensing details.
3
+
4
+ from typing import Any
5
+
6
+
7
+ class ExecutionError(RuntimeError):
8
+ """MySQL shell execution error."""
9
+
10
+ def __init__(self, message: Any | None = None):
11
+ """Initialize the error."""
12
+ if isinstance(message, dict):
13
+ message = message.get("message")
14
+
15
+ super().__init__(message)