99import dataclasses
1010import json
1111import logging
12+ import pathlib
1213import secrets
1314import string
1415import typing
1516
17+ import jinja2
18+
1619import container
20+ import server_exceptions
21+
22+ if typing .TYPE_CHECKING :
23+ import relations .database_requires
1724
1825logger = logging .getLogger (__name__ )
1926
@@ -29,33 +36,56 @@ class RouterUserInformation:
2936 router_id : str
3037
3138
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+
3253# TODO python3.10 min version: Add `(kw_only=True)`
3354@dataclasses .dataclass
3455class Shell :
3556 """MySQL Shell connected to MySQL cluster"""
3657
3758 _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+ )
4279
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."""
4680 # 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 )
5182
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 )
5787 try :
58- output = self ._container .run_mysql_shell (
88+ self ._container .run_mysql_shell (
5989 [
6090 "--no-wizard" ,
6191 "--python" ,
@@ -64,21 +94,34 @@ def _run_commands(self, commands: typing.List[str]) -> str:
6494 ]
6595 )
6696 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+ )
68100 raise
69101 finally :
70102 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
72118
73119 # TODO python3.10 min version: Use `list` instead of `typing.List`
74120 def _run_sql (self , sql_statements : typing .List [str ]) -> None :
75121 """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+ )
82125
83126 @staticmethod
84127 def _generate_password () -> str :
@@ -135,14 +178,17 @@ def get_mysql_router_user_for_unit(
135178 again.
136179 """
137180 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 ,
144187 )
145188 )
189+ with output_file .open ("r" ) as file :
190+ rows = json .load (file )
191+ output_file .unlink ()
146192 if not rows :
147193 logger .debug (f"No MySQL Router user found for { unit_name = } " )
148194 return
@@ -159,8 +205,10 @@ def remove_router_from_cluster_metadata(self, router_id: str) -> None:
159205 metadata already exists for the router ID.
160206 """
161207 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+ )
164212 )
165213 logger .debug (f"Removed { router_id = } from cluster metadata" )
166214
@@ -177,12 +225,24 @@ def delete_user(self, username: str, *, must_exist=True) -> None:
177225 def is_router_in_cluster_set (self , router_id : str ) -> bool :
178226 """Check if MySQL Router is part of InnoDB ClusterSet."""
179227 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
183232 )
184233 )
234+ with output_file .open ("r" ) as file :
235+ output = json .load (file )
236+ output_file .unlink ()
185237 cluster_set_router_ids = output ["routers" ].keys ()
186238 logger .debug (f"{ cluster_set_router_ids = } " )
187239 logger .debug (f"Checked if { router_id in cluster_set_router_ids = } " )
188240 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