22import datetime as dt
33import hashlib
44import logging
5+ import re
6+ import math
57
68import click
79import psycopg2
1820Q_ALTER_CONN_LIMIT = 'ALTER ROLE "{}" WITH CONNECTION LIMIT {}; -- Previous value: {}'
1921Q_ALTER_PASSWORD = "ALTER ROLE \" {}\" WITH ENCRYPTED PASSWORD '{}';"
2022Q_REMOVE_PASSWORD = "ALTER ROLE \" {}\" WITH PASSWORD NULL;"
21- Q_ALTER_ROLE = 'ALTER ROLE "{}" WITH {};'
23+ Q_ALTER_ROLE_WITH = 'ALTER ROLE "{}" WITH {};'
24+ Q_ALTER_ROLE_SET = 'ALTER ROLE "{}" SET {}={}; -- Previous value: {}'
25+ Q_ALTER_ROLE_RESET = 'ALTER ROLE "{}" RESET {}; -- Previous value: {}'
2226Q_ALTER_VALID_UNTIL = "ALTER ROLE \" {}\" WITH VALID UNTIL '{}'; -- Previous value: {}"
2327Q_CREATE_ROLE = 'CREATE ROLE "{}";'
2428
@@ -65,17 +69,19 @@ def analyze_attributes(spec, cursor, verbose):
6569 show_eta = False , item_show_func = common .item_show_func ) as all_roles :
6670 all_sql_to_run = []
6771 password_all_sql_to_run = []
68- for rolename , spec_config in all_roles :
72+ for rolename , spec_settings in all_roles :
6973 logger .debug ('Starting to analyze role {}' .format (rolename ))
7074
71- spec_config = spec_config or {}
72- spec_attributes = spec_config .get ('attributes' , [])
75+ spec_settings = spec_settings or {}
76+ spec_attributes = spec_settings .get ('attributes' , [])
7377
7478 for keyword , attribute in (('can_login' , 'LOGIN' ), ('is_superuser' , 'SUPERUSER' )):
75- is_desired = spec_config .get (keyword , False )
79+ is_desired = spec_settings .get (keyword , False )
7680 spec_attributes .append (attribute if is_desired else 'NO' + attribute )
7781
78- roleconf = AttributeAnalyzer (rolename , spec_attributes , dbcontext )
82+ spec_configs = spec_settings .get ('config' , {})
83+
84+ roleconf = AttributeAnalyzer (rolename , spec_attributes , spec_configs , dbcontext )
7985 roleconf .analyze ()
8086 all_sql_to_run += roleconf .sql_to_run
8187 password_all_sql_to_run += roleconf .password_sql_to_run
@@ -102,13 +108,15 @@ class AttributeAnalyzer(object):
102108 make it match the provided spec attributes. Note that spec_attributes is a list whereas
103109 current_attributes is a dict. """
104110
105- def __init__ (self , rolename , spec_attributes , dbcontext ):
111+ def __init__ (self , rolename , spec_attributes , spec_configs , dbcontext ):
106112 self .sql_to_run = []
107113 self .rolename = common .check_name (rolename )
108114 logger .debug ('self.rolename set to {}' .format (self .rolename ))
109115 self .spec_attributes = spec_attributes
116+ self .spec_configs = spec_configs
110117
111118 self .current_attributes = dbcontext .get_role_attributes (rolename )
119+ self .current_configs = dbcontext .get_role_configs (rolename )
112120
113121 # We keep track of password-related SQL separately as we don't want running this to
114122 # go into the main SQL stream since that could leak password
@@ -119,7 +127,10 @@ def analyze(self):
119127 self .create_role ()
120128
121129 desired_attributes = self .coalesce_attributes ()
130+
122131 self .set_all_attributes (desired_attributes )
132+ self .set_all_configs (self .spec_configs )
133+
123134 return self .sql_to_run
124135
125136 def create_role (self ):
@@ -193,6 +204,13 @@ def get_attribute_value(self, attribute):
193204 logger .debug ('Returning attribute "{}": "{}"' .format (attribute , value ))
194205 return value
195206
207+ def get_config_value (self , config ):
208+ """ Take a config (e.g. `statement_timeout`) and look up that value in our dbcontext
209+ """
210+ value = self .current_configs .get (config , "" )
211+ logger .debug ('Returning config "{}": "{}"' .format (config , value ))
212+ return value
213+
196214 def is_same_password (self , value ):
197215 """ Convert the input value into a postgres rolname-salted md5 hash and compare
198216 it with the currently stored hash """
@@ -221,7 +239,7 @@ def set_all_attributes(self, attributes):
221239
222240 elif current_value != desired_value and attribute != 'rolpassword' :
223241 self .set_attribute_value (attribute , desired_value , current_value )
224-
242+
225243 def set_attribute_value (self , attribute , desired_value , current_value ):
226244 if attribute == 'rolconnlimit' :
227245 query = Q_ALTER_CONN_LIMIT .format (self .rolename , desired_value , current_value )
@@ -231,10 +249,22 @@ def set_attribute_value(self, attribute, desired_value, current_value):
231249 base_keyword = COLUMN_NAME_TO_KEYWORD [attribute ]
232250 # prepend 'NO' if desired_value is False
233251 keyword = base_keyword if desired_value else 'NO' + base_keyword
234- query = Q_ALTER_ROLE .format (self .rolename , keyword )
252+ query = Q_ALTER_ROLE_WITH .format (self .rolename , keyword )
235253
236254 self .sql_to_run .append (query )
237255
256+ def set_all_configs (self , configs ):
257+ for config , desired_value in configs .items ():
258+ current_value = self .get_config_value (config )
259+
260+ if self .parse_config_value (current_value ) != self .parse_config_value (desired_value ):
261+ self .sql_to_run .append (Q_ALTER_ROLE_SET .format (self .rolename , config , desired_value , current_value ))
262+
263+ if self .current_configs :
264+ for current_config , current_value in self .current_configs .items ():
265+ if current_config not in configs .keys ():
266+ self .sql_to_run .append (Q_ALTER_ROLE_RESET .format (self .rolename , current_config , current_value ))
267+
238268 def set_password (self , desired_value ):
239269 if desired_value is None :
240270 actual_query = Q_REMOVE_PASSWORD .format (self .rolename )
@@ -244,3 +274,70 @@ def set_password(self, desired_value):
244274
245275 sanitized_query = Q_ALTER_PASSWORD .format (self .rolename , '******' )
246276 self .sql_to_run .append ('--' + sanitized_query )
277+
278+ @staticmethod
279+ def parse_config_value (value ):
280+ """
281+ Parse a config (as in postgresql.conf setting) value and return it’s normalized value.
282+
283+ If the value matches a well-known unit it is rounded to a multiple of the
284+ next smaller unit (if there is one) then it is converted to kB for memory
285+ units and to ms for time units
286+
287+ If the value doesn’t match a well known unit it is returned as an int or a
288+ float if conversion is possible or as-is otherwise.
289+
290+ See: https://www.postgresql.org/docs/current/config-setting.html
291+ """
292+
293+ if not isinstance (value , str ):
294+ return value
295+
296+ try :
297+ return int (value )
298+ except ValueError :
299+ pass
300+
301+ try :
302+ return float (value )
303+ except ValueError :
304+ pass
305+
306+ # Valid memory units are B (bytes), kB (kilobytes), MB (megabytes), GB (gigabytes), and TB (terabytes).
307+ # The multiplier for memory units is 1024, not 1000.
308+ m = re .search ("(?P<quantity>[\-\+]?[0-9]+(\.[0-9]+)?)\s*(?P<unit>B|kB|MB|GB|TB)" , value )
309+ if m :
310+ quantity = float (m .group ("quantity" ))
311+ unit = m .group ("unit" )
312+
313+ if unit == "B" :
314+ return quantity / 1024
315+ if unit == "kB" :
316+ return math .floor (quantity * 1024 ) / 1024
317+ if unit == "MB" :
318+ return math .floor (quantity * 1024 )
319+ if unit == "GB" :
320+ return math .floor (quantity * 1024 ) * 1024
321+ if unit == "TB" :
322+ return math .floor (quantity * 1024 ) * 1024 ** 2
323+
324+ # Valid time units are us (microseconds), ms (milliseconds), s (seconds), min (minutes), h (hours), and d (days).
325+ m = re .search ("(?P<quantity>.+)\s*(?P<unit>us|ms|s|min|h|d)" , value )
326+ if m :
327+ quantity = float (m .group ("quantity" ))
328+ unit = m .group ("unit" )
329+
330+ if unit == "us" :
331+ return quantity / 1000
332+ if unit == "ms" :
333+ return math .floor (quantity * 1000 ) / 1000
334+ if unit == "s" :
335+ return math .floor (quantity * 1000 )
336+ if unit == "min" :
337+ return math .floor (quantity * 60 ) * 1000
338+ if unit == "h" :
339+ return math .floor (quantity * 60 ) * 60 * 1000
340+ if unit == "d" :
341+ return math .floort (quantity * 24 ) * 60 * 60 * 1000
342+
343+ return value
0 commit comments