0bfeb260f9873e22aaf62c9b2a52060e0b49fb73
[pyi2ncommon] / src / cnfvar / store.py
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 """
22 store: implementations of CNF stores using varlink and `*et_cnf` binaries.
23
24 Featuring:
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
28
29 .. seealso:: Overview Diagram linked to from doc main page
30
31 .. codeauthor:: Intra2net
32 """
33 import subprocess
34 import logging
35 import time
36 import html
37
38 from .. import arnied_wrapper, arnied_api
39 from . import CnfBinary, Cnf, CnfList
40
41 log = logging.getLogger("pyi2ncommon.cnfvar.store")
42
43
44 class 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
93                   in the same list as the system should handle it nicely
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
142         automatically. However it starts on 1, whereas many variables are not
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
244 class BinaryCnfStore(CnfStore):
245     """Implementation of the CNF store that uses `get_cnf` and `set_cnf`."""
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)
276         output = self._driver.get_cnf(name, instance=instance)
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
293                   in the same list as the system should handle it nicely
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
305         # set_cnf is demanding on lineno's
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
334         # set_cnf is demanding on lineno's
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
357 #: pattern for more verbose error message for :py:class:`CommitException`
358 COMMIT_EXCEPTION_MESSAGE = """\
359 Error committing CNF variables!
360 ----------------------------
361 Input:
362 {cnfvars}
363 ----------------------------
364 Error:
365 {msg}
366 """
367
368
369 class 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         """
380         super().__init__(msg)
381         self.message = COMMIT_EXCEPTION_MESSAGE.format(cnfvars=cnfvars, msg=msg)