9
9
import dataclasses
10
10
import json
11
11
import logging
12
+ import pathlib
12
13
import secrets
13
14
import string
14
15
import typing
15
16
17
+ import jinja2
18
+
16
19
import container
20
+ import server_exceptions
21
+
22
+ if typing .TYPE_CHECKING :
23
+ import relations .database_requires
17
24
18
25
logger = logging .getLogger (__name__ )
19
26
@@ -29,33 +36,56 @@ class RouterUserInformation:
29
36
router_id : str
30
37
31
38
39
+ class ShellDBError (Exception ):
40
+ """`mysqlsh.DBError` raised while executing MySQL Shell script
41
+
42
+ MySQL Shell runs Python code in a separate process from the charm Python code.
43
+ The `mysqlsh.DBError` was caught by the shell code, serialized to JSON, and de-serialized to
44
+ this exception.
45
+ """
46
+
47
+ def __init__ (self , * , message : str , code : int , traceback_message : str ):
48
+ super ().__init__ (message )
49
+ self .code = code
50
+ self .traceback_message = traceback_message
51
+
52
+
32
53
# TODO python3.10 min version: Add `(kw_only=True)`
33
54
@dataclasses .dataclass
34
55
class Shell :
35
56
"""MySQL Shell connected to MySQL cluster"""
36
57
37
58
_container : container .Container
38
- username : str
39
- _password : str
40
- _host : str
41
- _port : str
59
+ _connection_info : "relations.database_requires.CompleteConnectionInformation"
60
+
61
+ @property
62
+ def username (self ):
63
+ return self ._connection_info .username
64
+
65
+ def _run_code (self , code : str ) -> None :
66
+ """Connect to MySQL cluster and run Python code."""
67
+ template = _jinja_env .get_template ("try_except_wrapper.py.jinja" )
68
+ error_file = self ._container .path ("/tmp/mysqlsh_error.json" )
69
+
70
+ def render (connection_info : "relations.database_requires.ConnectionInformation" ):
71
+ return template .render (
72
+ username = connection_info .username ,
73
+ password = connection_info .password ,
74
+ host = connection_info .host ,
75
+ port = connection_info .port ,
76
+ code = code ,
77
+ error_filepath = error_file .relative_to_container ,
78
+ )
42
79
43
- # TODO python3.10 min version: Use `list` instead of `typing.List`
44
- def _run_commands (self , commands : typing .List [str ]) -> str :
45
- """Connect to MySQL cluster and run commands."""
46
80
# Redact password from log
47
- logged_commands = commands .copy ()
48
- logged_commands .insert (
49
- 0 , f"shell.connect('{ self .username } :***@{ self ._host } :{ self ._port } ')"
50
- )
81
+ logged_script = render (self ._connection_info .redacted )
51
82
52
- commands .insert (
53
- 0 , f"shell.connect('{ self .username } :{ self ._password } @{ self ._host } :{ self ._port } ')"
54
- )
55
- temporary_script_file = self ._container .path ("/tmp/script.py" )
56
- temporary_script_file .write_text ("\n " .join (commands ))
83
+ script = render (self ._connection_info )
84
+ temporary_script_file = self ._container .path ("/tmp/mysqlsh_script.py" )
85
+ error_file = self ._container .path ("/tmp/mysqlsh_error.json" )
86
+ temporary_script_file .write_text (script )
57
87
try :
58
- output = self ._container .run_mysql_shell (
88
+ self ._container .run_mysql_shell (
59
89
[
60
90
"--no-wizard" ,
61
91
"--python" ,
@@ -64,21 +94,34 @@ def _run_commands(self, commands: typing.List[str]) -> str:
64
94
]
65
95
)
66
96
except container .CalledProcessError as e :
67
- logger .exception (f"Failed to run { logged_commands = } \n stderr:\n { e .stderr } \n " )
97
+ logger .exception (
98
+ f"Failed to run MySQL Shell script:\n { logged_script } \n \n stderr:\n { e .stderr } \n "
99
+ )
68
100
raise
69
101
finally :
70
102
temporary_script_file .unlink ()
71
- return output
103
+ with error_file .open ("r" ) as file :
104
+ exception = json .load (file )
105
+ error_file .unlink ()
106
+ try :
107
+ if exception :
108
+ raise ShellDBError (** exception )
109
+ except ShellDBError as e :
110
+ if e .code == 2003 :
111
+ logger .exception (server_exceptions .ConnectionError .MESSAGE )
112
+ raise server_exceptions .ConnectionError
113
+ else :
114
+ logger .exception (
115
+ f"Failed to run MySQL Shell script:\n { logged_script } \n \n MySQL client error { e .code } \n MySQL Shell traceback:\n { e .traceback_message } \n "
116
+ )
117
+ raise
72
118
73
119
# TODO python3.10 min version: Use `list` instead of `typing.List`
74
120
def _run_sql (self , sql_statements : typing .List [str ]) -> None :
75
121
"""Connect to MySQL cluster and execute SQL."""
76
- commands = []
77
- for statement in sql_statements :
78
- # Escape double quote (") characters in statement
79
- statement = statement .replace ('"' , r"\"" )
80
- commands .append ('session.run_sql("' + statement + '")' )
81
- self ._run_commands (commands )
122
+ self ._run_code (
123
+ _jinja_env .get_template ("run_sql.py.jinja" ).render (statements = sql_statements )
124
+ )
82
125
83
126
@staticmethod
84
127
def _generate_password () -> str :
@@ -135,14 +178,17 @@ def get_mysql_router_user_for_unit(
135
178
again.
136
179
"""
137
180
logger .debug (f"Getting MySQL Router user for { unit_name = } " )
138
- rows = json . loads (
139
- self ._run_commands (
140
- [
141
- f"result = session.run_sql( \" SELECT USER, ATTRIBUTE->>'$.router_id' FROM INFORMATION_SCHEMA.USER_ATTRIBUTES WHERE ATTRIBUTE->'$.created_by_user'=' { self .username } ' AND ATTRIBUTE->'$.created_by_juju_unit'=' { unit_name } ' \" )" ,
142
- "print(result.fetch_all())" ,
143
- ]
181
+ output_file = self . _container . path ( "/tmp/mysqlsh_output.json" )
182
+ self ._run_code (
183
+ _jinja_env . get_template ( "get_mysql_router_user_for_unit.py.jinja" ). render (
184
+ username = self .username ,
185
+ unit_name = unit_name ,
186
+ output_filepath = output_file . relative_to_container ,
144
187
)
145
188
)
189
+ with output_file .open ("r" ) as file :
190
+ rows = json .load (file )
191
+ output_file .unlink ()
146
192
if not rows :
147
193
logger .debug (f"No MySQL Router user found for { unit_name = } " )
148
194
return
@@ -159,8 +205,10 @@ def remove_router_from_cluster_metadata(self, router_id: str) -> None:
159
205
metadata already exists for the router ID.
160
206
"""
161
207
logger .debug (f"Removing { router_id = } from cluster metadata" )
162
- self ._run_commands (
163
- ["cluster = dba.get_cluster()" , f'cluster.remove_router_metadata("{ router_id } ")' ]
208
+ self ._run_code (
209
+ _jinja_env .get_template ("remove_router_from_cluster_metadata.py.jinja" ).render (
210
+ router_id = router_id
211
+ )
164
212
)
165
213
logger .debug (f"Removed { router_id = } from cluster metadata" )
166
214
@@ -177,12 +225,24 @@ def delete_user(self, username: str, *, must_exist=True) -> None:
177
225
def is_router_in_cluster_set (self , router_id : str ) -> bool :
178
226
"""Check if MySQL Router is part of InnoDB ClusterSet."""
179
227
logger .debug (f"Checking if { router_id = } in cluster set" )
180
- output = json .loads (
181
- self ._run_commands (
182
- ["cluster_set = dba.get_cluster_set()" , "print(cluster_set.list_routers())" ]
228
+ output_file = self ._container .path ("/tmp/mysqlsh_output.json" )
229
+ self ._run_code (
230
+ _jinja_env .get_template ("get_routers_in_cluster_set.py.jinja" ).render (
231
+ output_filepath = output_file .relative_to_container
183
232
)
184
233
)
234
+ with output_file .open ("r" ) as file :
235
+ output = json .load (file )
236
+ output_file .unlink ()
185
237
cluster_set_router_ids = output ["routers" ].keys ()
186
238
logger .debug (f"{ cluster_set_router_ids = } " )
187
239
logger .debug (f"Checked if { router_id in cluster_set_router_ids = } " )
188
240
return router_id in cluster_set_router_ids
241
+
242
+
243
+ _jinja_env = jinja2 .Environment (
244
+ autoescape = False ,
245
+ trim_blocks = True ,
246
+ loader = jinja2 .FileSystemLoader (pathlib .Path (__file__ ).parent / "templates" ),
247
+ undefined = jinja2 .StrictUndefined ,
248
+ )
0 commit comments