Clean up, remove compat with py < 3.6
[pyi2ncommon] / src / cnfvar / store.py
CommitLineData
d31714a0
SA
1# The software in this package is distributed under the GNU General
2# Public License version 2 (with a special exception described below).
3#
4# A copy of GNU General Public License (GPL) is included in this distribution,
5# in the file COPYING.GPL.
6#
7# As a special exception, if other files instantiate templates or use macros
8# or inline functions from this file, or you compile this file and link it
9# with other works to produce a work based on this file, this file
10# does not by itself cause the resulting work to be covered
11# by the GNU General Public License.
12#
13# However the source code for this file must still be made available
14# in accordance with section (3) of the GNU General Public License.
15#
16# This exception does not invalidate any other reasons why a work based
17# on this file might be covered by the GNU General Public License.
18#
19# Copyright (c) 2016-2022 Intra2net AG <info@intra2net.com>
20
21"""
d5d2c1d7 22store: implementations of CNF stores using varlink and `*et_cnf` binaries.
d31714a0 23
d5d2c1d7
CH
24Featuring:
25 - CnfStore: the main store class that is implemented using the varlink API
26 - BinaryCnfStore: alternative store class implementation using the `set_cnf`
27 and `get_cnf` binaries
d31714a0 28
d5d2c1d7 29.. seealso:: Overview Diagram linked to from doc main page
d31714a0
SA
30
31.. codeauthor:: Intra2net
32"""
33import subprocess
34import logging
35import time
36import html
37
38from .. import arnied_wrapper, arnied_api
39from . import CnfBinary, Cnf, CnfList
40
41log = logging.getLogger("pyi2ncommon.cnfvar.store")
42
43
44class CnfStore:
45 """Main CNF store class that uses the varlink API."""
46
47 def __init__(self, backend_driver=arnied_api.Arnied):
48 """
49 Initialize this class.
50
51 :param backend_driver: driver to use to talk to the cnfvar backend
52 :type: :py:class:`arnied_api.Arnied`
53 """
54 self._driver = backend_driver
55 log.debug("Initialized CnfStore with driver `%s`", type(backend_driver))
56
57 def query(self, name=None, instance=None):
58 """
59 Query the CNF store and return a list of parsed CNFs.
60
61 :param str name: optional name of the CNFs to query
62 :param instance: optional CNF instance
63 :type instance: str or int
64 :returns: list of parsed CNF values
65 :rtype: :py:class:`CnfList`
66
67 Example::
68 store = CnfStore()
69 user_cnfs = store.query("USER")
70 """
71 log.debug("Querying CnfStore with name=%s and instance=%s via arnied API",
72 name, instance)
73
74 # the API only expects an object if there is a name (and it's not case-insensitive)
75 query = arnied_api.GetCnfQuery(name.upper(), instance) if name else None
76 api_ret = self._driver.get_cnf(query)
77
78 # NOTE: logging all output here would result in huge lines when querying
79 # all variables via `store.query()`
80 log.debug("Arnied API returned %d cnfvars", len(api_ret.vars))
81 return CnfList.from_api_structure(api_ret.vars)
82
83 def commit(self, cnf, fix_problems=False):
84 """
85 Update or insert CNF variables from a list.
86
87 :param cnf: CNF instance or list of CNFs to update or insert
88 :type cnf: :py:class:`Cnf` or :py:class:`CnfList`
89 :raises: :py:class:`CommitException` if the arnied API complains
90 :param bool fix_problems: whether to automatically fix errors in the vars
91
92 .. note:: you can mix variables to insert and variables to update
d5d2c1d7 93 in the same list as the system should handle it nicely
d31714a0
SA
94
95 Example::
96 store = CnfStore()
97 user_cnf = store.query("USER")\
98 .single_with_value("joe")
99 user_cnf.add_child("user_group_member_ref", "3")
100 store.commit(user_cnf)
101 """
102 cnf = self._cnf_or_list(cnf, operation="commit")
103 self._autofix_instances(cnf)
104 log.debug("Committing variables via arnied API:\n%s", cnf)
105
106 arnied_cnfs = cnf.to_api_structure()
107 self._do_commit(cnf, arnied_cnfs, fix_problems=fix_problems)
108
109 def delete(self, cnf, fix_problems=False):
110 """
111 Delete a list of top-level CNF variables.
112
113 :param cnf: a single CNF value or a list of values
114 :type cnf: :py:class:`CnfList` or :py:class:`Cnf`
115 :param bool fix_problems: whether to automatically fix errors in the vars
116
117 Example::
118 store = CnfStore()
119 user_cnfs = store.query("USER")\
120 .where(lambda c: c.value in ["joe", "jane"])
121 store.delete(user_cnfs)
122 """
123 cnf = self._cnf_or_list(cnf, operation="delete")
124 if any((c.parent is not None for c in cnf)):
125 raise RuntimeError("Calling delete is only supported on top-level CNF variables")
126 log.debug("Deleting variables via arnied API:\n%s", cnf)
127
128 arnied_cnfs = cnf.to_api_structure()
129 for c in arnied_cnfs:
130 c.deleted = True
131 c.children.clear()
132 self._do_commit(cnf, arnied_cnfs, fix_problems=fix_problems)
133
134 def _autofix_instances(self, cnfs):
135 """
136 Auto-assign a valid instance value to all top-level vars in a list.
137
138 :param cnfs: list of cnfvars to fix
139 :type cnfs: :py:class`CnfList`
140
141 When the instance of a cnfvar is -1, the backend initializes it
7628bc48 142 automatically. However, it starts on 1, whereas many variables are not
d31714a0
SA
143 allowed to start on one (those that are meant to be unique, f.e.).
144 This method can be used in child classes to use an alternative scheme,
145 however for performance reasons the base API class uses the default and
146 relies on the cnfvar backend to do this job.
147 """
148
149 def _do_commit(self, original_cnfs, arnied_cnfs, fix_problems=False):
150 """
151 Set cnfvars and commit changes.
152
153 :param original_cnfs: list of CNFs to print in case of errors
154 :type original_cnfs: :py:class:`CnfList`
155 :param arnied_cnfs: list of cnfvars to pass to the arnied API
156 :type arnied_cnfs: [:py:class:`arnied_api.CnfVar`]
157 :param bool fix_problems: whether to automatically fix errors in the vars
158 :raises: :py:class:`CommitException` if the arnied API complains
159 """
160 try:
161 ret = self._driver.set_commit_cnf(vars=arnied_cnfs, username=None,
162 nogenerate=False,
163 fix_commit=fix_problems)
164 except arnied_api.CnfCommitError as ex:
165 # fatal errors, we will handle it in our custom exception
166 ret = ex
167
168 errors = []
169 for r in ret.results:
170 # message can contain HTML escape codes
171 msg = html.unescape(r.result_msg)
172
173 # `result_type` is defined as int in the varlink API,
174 # but the meaning of the codes are:
175 # enum result_type { OK=0, WARN=1, FAIL_TEMP=2, FAIL=3 }
176 if r.result_type == 1:
177 log.debug("Warning in `` %s,%s: \"%s\" ``: {msg} (code=%s)",
178 r.name, r.instance, r.data, r.result_type)
179 elif r.result_type > 1:
180 errors.append(f"Error in `` {r.name},{r.instance}: \"{r.data}\" ``"
181 f": {msg}")
182
183 if len(errors) > 0:
184 log.debug("Error sending variables:\n%s", arnied_cnfs)
185 raise CommitException(original_cnfs, "\n".join(errors))
186
187 self._wait_for_generate()
188
189 def _wait_for_generate(self, timeout=300):
190 """
191 Wait for the 'generate' program to end.
192
193 :param int timeout: program run timeout
194 :raises: :py:class:`TimeoutError` if the program did not finish on time
195 """
196 def scheduled_or_running(progname):
197 ret = self._driver.is_scheduled_or_running(progname)
198 return ret.status in [arnied_api.ProgramStatus.Scheduled,
199 arnied_api.ProgramStatus.Running]
200
201 def wait_for_program(progname):
202 log.debug("Waiting for `%s` to be running", progname)
203 for _ in range(10):
204 if scheduled_or_running(progname):
205 # if is running or scheduled, break to wait for completion
206 break
207 time.sleep(1)
208 else:
209 # after trying and retrying, program is not scheduled nor
210 # running, so it is safe to assume it has already executed
211 return
212
213 # program running or scheduled, wait
214 log.debug("Waiting for `%s` to finish", progname)
215 for _ in range(0, timeout):
216 if not scheduled_or_running(progname):
217 # finished executing, bail out
218 return
219 time.sleep(1)
220 raise TimeoutError(f"Program `{progname}` did not end in time")
221
222 wait_for_program("GENERATE")
223 wait_for_program("GENERATE_OFFLINE")
224
225 def _cnf_or_list(self, cnf, operation) -> CnfList:
226 """
227 Validate and wrap a CNF value into a list.
228
229 :param cnf: a single CNF value or a list of values
230 :type cnf: :py:class:`CnfList` or :py:class:`Cnf`
231 :param str operation: name of the operation that is being done
232 :raises: :py:class:`TypeError` if the type of the CNF object
233 is neither a list nor a CNF value
234 :returns: wrapped CNF value
235 :rtype: :py:class:`CnfList`
236 """
237 if isinstance(cnf, Cnf):
238 cnf = CnfList([cnf])
239 elif not isinstance(cnf, CnfList):
240 raise TypeError(f"Cannot {operation} value(s) of type `{type(cnf)}`")
241 return cnf
242
243
244class BinaryCnfStore(CnfStore):
d5d2c1d7 245 """Implementation of the CNF store that uses `get_cnf` and `set_cnf`."""
d31714a0
SA
246
247 #: how much to wait for arnied to report running
248 ARNIED_TIMEOUT = 30
249
250 def __init__(self, backend_driver=CnfBinary):
251 """
252 Initialize this class.
253
254 :param backend_driver: driver to use to talk to the cnfvar backend
255 :type: :py:class:`CnfBinary`
256 """
257 super().__init__(backend_driver=backend_driver)
258 log.debug("Initialized BinaryCnfStore with driver `%s`", type(backend_driver))
259
260 def query(self, name=None, instance=None):
261 """
262 Query the CNF store and return a list of parsed CNFs.
263
264 :param str name: optional name of the CNFs to query
265 :param instance: optional CNF instance
266 :type instance: str or int
267 :returns: list of parsed CNF values
268 :rtype: :py:class:`CnfList`
269
270 Example::
271 store = CnfStore()
272 user_cnfs = store.query("USER")
273 """
274 log.debug("Querying BinaryCnfStore with name=%s and instance=%s",
275 name, instance)
d5d2c1d7 276 output = self._driver.get_cnf(name, instance=instance)
d31714a0
SA
277
278 if len(output) == 0:
279 # otherwise cnfvar raises a generic Malformed exception
280 return CnfList()
281
282 return CnfList.from_cnf_string(output)
283
284 def commit(self, cnf, fix_problems=False):
285 """
286 Update or insert CNF variables from a list.
287
288 :param cnf: CNF instance or list of CNFs to update or insert
289 :type cnf: :py:class:`Cnf` or :py:class:`CnfList`
290 :param bool fix_problems: whether to automatically fix errors in the vars
291
292 .. note:: you can mix variables to insert and variables to update
d5d2c1d7 293 in the same list as the system should handle it nicely
d31714a0
SA
294
295 Example::
296 store = CnfStore()
297 user_cnf = store.query("USER")\
298 .single_with_value("joe")
299 user_cnf.add_child("user_group_member_ref", "3")
300 store.commit(user_cnf)
301 """
302 cnf = self._cnf_or_list(cnf, operation="commit")
303 self._autofix_instances(cnf)
304
d5d2c1d7 305 # set_cnf is demanding on lineno's
d31714a0
SA
306 cnf.renumber()
307 log.debug("Committing variables via binaries:\n%s", cnf)
308
309 self._call_arnied(arnied_wrapper.verify_running, timeout=self.ARNIED_TIMEOUT)
310 try:
311 self._driver.set_cnf(input_str=str(cnf), fix_problems=fix_problems)
312 except subprocess.CalledProcessError as ex:
313 raise CommitException(cnf, ex.stderr) from None
314 self._call_arnied(arnied_wrapper.wait_for_generate)
315
316 def delete(self, cnf, fix_problems=False):
317 """
318 Delete a list of top-level CNF variables.
319
320 :param cnf: a single CNF value or a list of values
321 :type cnf: :py:class:`CnfList` or :py:class:`Cnf`
322 :param bool fix_problems: whether to automatically fix errors in the vars
323
324 Example::
325 store = CnfStore()
326 user_cnfs = store.query("USER")\
327 .where(lambda c: c.value in ["joe", "jane"])
328 store.delete(user_cnfs)
329 """
330 cnf = self._cnf_or_list(cnf, operation="delete")
331 if any((c.parent is not None for c in cnf)):
332 raise RuntimeError("Calling delete is only supported on top-level CNF variables")
333
d5d2c1d7 334 # set_cnf is demanding on lineno's
d31714a0
SA
335 cnf.renumber()
336 log.debug("Deleting variables via binaries:\n%s", cnf)
337
338 self._call_arnied(arnied_wrapper.verify_running, timeout=self.ARNIED_TIMEOUT)
339 try:
340 self._driver.set_cnf(input_str=str(cnf), delete=True, fix_problems=fix_problems)
341 except subprocess.CalledProcessError as ex:
342 raise CommitException(cnf, ex.stderr) from None
343 self._call_arnied(arnied_wrapper.wait_for_generate)
344
345 def _call_arnied(self, fn, *args, **kwargs):
346 """
347 Simple proxy around the arnied to be overwritten in child classes.
348
349 :param fn: function to invoke in the arnied wrapper
350 :type fn: py:function
351 :param args: arguments to be passed to function
352 :param kwargs: named arguments to be passed to function
353 """
354 return fn(*args, **kwargs)
355
356
d5d2c1d7
CH
357#: pattern for more verbose error message for :py:class:`CommitException`
358COMMIT_EXCEPTION_MESSAGE = """\
359Error committing CNF variables!
360----------------------------
361Input:
362{cnfvars}
363----------------------------
364Error:
365{msg}
366"""
367
368
d31714a0
SA
369class CommitException(Exception):
370 """Custom exception for commit errors."""
371
372 def __init__(self, cnfvars, msg):
373 """
374 Initialize this exception.
375
376 :param cnfvars: list of CNF variables that would be committed
377 :type cnfvars: CnfList
378 :param str msg: error message
379 """
d31714a0 380 super().__init__(msg)
d5d2c1d7 381 self.message = COMMIT_EXCEPTION_MESSAGE.format(cnfvars=cnfvars, msg=msg)