Wait for arnied in addition to generate to preserve backward compatibility
[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         # TODO: implement `self._wait_for_arnied()` which should busy-loop with
56         # the arnied varlink socket and handle "Disconnected" errors, then perhaps
57         # drop the old binary cnf store method from the arnied wrapper
58         log.debug(f"Initialized cnf store with driver `{backend_driver.__name__}`")
59
60     def query(self, name=None, instance=None):
61         """
62         Query the CNF store and return a list of parsed CNFs.
63
64         :param str name: optional name of the CNFs to query
65         :param instance: optional CNF instance
66         :type instance: str or int
67         :returns: list of parsed CNF values
68         :rtype: :py:class:`CnfList`
69
70         Example::
71             store = CnfStore()
72             user_cnfs = store.query("USER")
73         """
74         log.debug("Querying CnfStore with name=%s and instance=%s via arnied API",
75                   name, instance)
76
77         # the API only expects an object if there is a name (and it's not case-insensitive)
78         query = arnied_api.GetCnfQuery(name.upper(), instance) if name else None
79         api_ret = self._driver.get_cnf(query)
80
81         # NOTE: logging all output here would result in huge lines when querying
82         # all variables via `store.query()`
83         log.debug("Arnied API returned %d cnfvars", len(api_ret.vars))
84         return CnfList.from_api_structure(api_ret.vars)
85
86     def commit(self, cnf, fix_problems=False):
87         """
88         Update or insert CNF variables from a list.
89
90         :param cnf: CNF instance or list of CNFs to update or insert
91         :type cnf: :py:class:`Cnf` or :py:class:`CnfList`
92         :raises: :py:class:`CommitException` if the arnied API complains
93         :param bool fix_problems: whether to automatically fix errors in the vars
94
95         .. note:: you can mix variables to insert and variables to update
96                   in the same list as the system should handle it nicely
97
98         Example::
99             store = CnfStore()
100             user_cnf = store.query("USER")\
101                             .single_with_value("joe")
102             user_cnf.add_child("user_group_member_ref", "3")
103             store.commit(user_cnf)
104         """
105         cnf = self._cnf_or_list(cnf, operation="commit")
106         self._autofix_instances(cnf)
107         log.debug("Committing variables via arnied API:\n%s", cnf)
108
109         arnied_cnfs = cnf.to_api_structure()
110         self._do_commit(cnf, arnied_cnfs, fix_problems=fix_problems)
111
112     def delete(self, cnf, fix_problems=False):
113         """
114         Delete a list of top-level CNF variables.
115
116         :param cnf: a single CNF value or a list of values
117         :type cnf: :py:class:`CnfList` or :py:class:`Cnf`
118         :param bool fix_problems: whether to automatically fix errors in the vars
119
120         Example::
121             store = CnfStore()
122             user_cnfs = store.query("USER")\
123                              .where(lambda c: c.value in ["joe", "jane"])
124             store.delete(user_cnfs)
125         """
126         cnf = self._cnf_or_list(cnf, operation="delete")
127         if any((c.parent is not None for c in cnf)):
128             raise RuntimeError("Calling delete is only supported on top-level CNF variables")
129         log.debug("Deleting variables via arnied API:\n%s", cnf)
130
131         arnied_cnfs = cnf.to_api_structure()
132         for c in arnied_cnfs:
133             c.deleted = True
134             c.children.clear()
135         self._do_commit(cnf, arnied_cnfs, fix_problems=fix_problems)
136
137     def _autofix_instances(self, cnfs):
138         """
139         Auto-assign a valid instance value to all top-level vars in a list.
140
141         :param cnfs: list of cnfvars to fix
142         :type cnfs: :py:class`CnfList`
143
144         When the instance of a cnfvar is -1, the backend initializes it
145         automatically. However, it starts on 1, whereas many variables are not
146         allowed to start on one (those that are meant to be unique, f.e.).
147         This method can be used in child classes to use an alternative scheme,
148         however for performance reasons the base API class uses the default and
149         relies on the cnfvar backend to do this job.
150
151         ..todo:: This method compensates for limitations in production code that
152             might end up fixed up there deprecating our patching here.
153         """
154
155     def _do_commit(self, original_cnfs, arnied_cnfs, fix_problems=False):
156         """
157         Set cnfvars and commit changes.
158
159         :param original_cnfs: list of CNFs to print in case of errors
160         :type original_cnfs: :py:class:`CnfList`
161         :param arnied_cnfs: list of cnfvars to pass to the arnied API
162         :type arnied_cnfs: [:py:class:`arnied_api.CnfVar`]
163         :param bool fix_problems: whether to automatically fix errors in the vars
164         :raises: :py:class:`CommitException` if the arnied API complains
165         """
166         try:
167             ret = self._driver.set_commit_cnf(vars=arnied_cnfs, username=None,
168                                               nogenerate=False,
169                                               fix_commit=fix_problems)
170         except arnied_api.CnfCommitError as ex:
171             # fatal errors, we will handle it in our custom exception
172             ret = ex
173
174         errors = []
175         for r in ret.results:
176             # message can contain HTML escape codes
177             msg = html.unescape(r.result_msg)
178
179             # `result_type` is defined as int in the varlink API,
180             # but the meaning of the codes are:
181             # enum result_type { OK=0, WARN=1, FAIL_TEMP=2, FAIL=3 }
182             if r.result_type == 1:
183                 log.debug("Warning in `` %s,%s: \"%s\" ``: {msg} (code=%s)",
184                           r.name, r.instance, r.data, r.result_type)
185             elif r.result_type > 1:
186                 errors.append(f"Error in `` {r.name},{r.instance}: \"{r.data}\" ``"
187                               f": {msg}")
188
189         if len(errors) > 0:
190             log.debug("Error sending variables:\n%s", arnied_cnfs)
191             raise CommitException(original_cnfs, "\n".join(errors))
192
193         self._wait_for_generate()
194
195     def _wait_for_generate(self, timeout=300):
196         """
197         Wait for the 'generate' program to end.
198
199         :param int timeout: program run timeout
200         :raises: :py:class:`TimeoutError` if the program did not finish on time
201         """
202         def scheduled_or_running(progname):
203             ret = self._driver.is_scheduled_or_running(progname)
204             return ret.status in [arnied_api.ProgramStatus.Scheduled,
205                                   arnied_api.ProgramStatus.Running]
206
207         def wait_for_program(progname):
208             log.debug("Waiting for `%s` to be running", progname)
209             for _ in range(10):
210                 if scheduled_or_running(progname):
211                     # if is running or scheduled, break to wait for completion
212                     break
213                 time.sleep(1)
214             else:
215                 # after trying and retrying, program is not scheduled nor
216                 # running, so it is safe to assume it has already executed
217                 return
218
219             # program running or scheduled, wait
220             log.debug("Waiting for `%s` to finish", progname)
221             for _ in range(0, timeout):
222                 if not scheduled_or_running(progname):
223                     # finished executing, bail out
224                     return
225                 time.sleep(1)
226             raise TimeoutError(f"Program `{progname}` did not end in time")
227
228         wait_for_program("GENERATE")
229         wait_for_program("GENERATE_OFFLINE")
230
231     def _cnf_or_list(self, cnf, operation) -> CnfList:
232         """
233         Validate and wrap a CNF value into a list.
234
235         :param cnf: a single CNF value or a list of values
236         :type cnf: :py:class:`CnfList` or :py:class:`Cnf`
237         :param str operation: name of the operation that is being done
238         :raises: :py:class:`TypeError` if the type of the CNF object
239                  is neither a list nor a CNF value
240         :returns: wrapped CNF value
241         :rtype: :py:class:`CnfList`
242         """
243         if isinstance(cnf, Cnf):
244             cnf = CnfList([cnf])
245         elif not isinstance(cnf, CnfList):
246             raise TypeError(f"Cannot {operation} value(s) of type `{type(cnf)}`")
247         return cnf
248
249
250 class BinaryCnfStore(CnfStore):
251     """Implementation of the CNF store that uses `get_cnf` and `set_cnf`."""
252
253     #: how much to wait for arnied to report running
254     ARNIED_TIMEOUT = 30
255
256     def __init__(self, backend_driver=CnfBinary):
257         """
258         Initialize this class.
259
260         :param backend_driver: driver to use to talk to the cnfvar backend
261         :type: :py:class:`CnfBinary`
262         """
263         super().__init__(backend_driver=backend_driver)
264         # We assume that any external events happening to arnied require reinitializing
265         # the cnf store which is bound to the lifespan of a single arnied process.
266         self._call_arnied(arnied_wrapper.verify_running, timeout=self.ARNIED_TIMEOUT)
267         log.debug(f"Initialized binary cnf store with driver `{backend_driver.__name__}`")
268
269     def query(self, name=None, instance=None):
270         """
271         Query the CNF store and return a list of parsed CNFs.
272
273         :param str name: optional name of the CNFs to query
274         :param instance: optional CNF instance
275         :type instance: str or int
276         :returns: list of parsed CNF values
277         :rtype: :py:class:`CnfList`
278
279         Example::
280             store = CnfStore()
281             user_cnfs = store.query("USER")
282         """
283         log.debug("Querying BinaryCnfStore with name=%s and instance=%s",
284                   name, instance)
285         output = self._driver.get_cnf(name, instance=instance)
286
287         if len(output) == 0:
288             # otherwise cnfvar raises a generic Malformed exception
289             return CnfList()
290
291         return CnfList.from_cnf_string(output)
292
293     def commit(self, cnf, fix_problems=False):
294         """
295         Update or insert CNF variables from a list.
296
297         :param cnf: CNF instance or list of CNFs to update or insert
298         :type cnf: :py:class:`Cnf` or :py:class:`CnfList`
299         :param bool fix_problems: whether to automatically fix errors in the vars
300
301         .. note:: you can mix variables to insert and variables to update
302                   in the same list as the system should handle it nicely
303
304         Example::
305             store = CnfStore()
306             user_cnf = store.query("USER")\
307                             .single_with_value("joe")
308             user_cnf.add_child("user_group_member_ref", "3")
309             store.commit(user_cnf)
310         """
311         cnf = self._cnf_or_list(cnf, operation="commit")
312         self._autofix_instances(cnf)
313
314         # set_cnf is demanding on lineno's
315         cnf.renumber()
316         log.debug("Committing variables via binaries:\n%s", cnf)
317
318         try:
319             self._driver.set_cnf(input_str=str(cnf), fix_problems=fix_problems)
320         except subprocess.CalledProcessError as ex:
321             raise CommitException(cnf, ex.stderr) from None
322         self._call_arnied(arnied_wrapper.wait_for_generate)
323
324     def delete(self, cnf, fix_problems=False):
325         """
326         Delete a list of top-level CNF variables.
327
328         :param cnf: a single CNF value or a list of values
329         :type cnf: :py:class:`CnfList` or :py:class:`Cnf`
330         :param bool fix_problems: whether to automatically fix errors in the vars
331
332         Example::
333             store = CnfStore()
334             user_cnfs = store.query("USER")\
335                              .where(lambda c: c.value in ["joe", "jane"])
336             store.delete(user_cnfs)
337         """
338         cnf = self._cnf_or_list(cnf, operation="delete")
339         if any((c.parent is not None for c in cnf)):
340             raise RuntimeError("Calling delete is only supported on top-level CNF variables")
341
342         # set_cnf is demanding on lineno's
343         cnf.renumber()
344         log.debug("Deleting variables via binaries:\n%s", cnf)
345
346         try:
347             self._driver.set_cnf(input_str=str(cnf), delete=True, fix_problems=fix_problems)
348         except subprocess.CalledProcessError as ex:
349             raise CommitException(cnf, ex.stderr) from None
350         self._call_arnied(arnied_wrapper.wait_for_generate)
351
352     def _call_arnied(self, fn, *args, **kwargs):
353         """
354         Simple proxy around the arnied to be overwritten in child classes.
355
356         :param fn: function to invoke in the arnied wrapper
357         :type fn: py:function
358         :param args: arguments to be passed to function
359         :param kwargs: named arguments to be passed to function
360         """
361         return fn(*args, **kwargs)
362
363
364 #: pattern for more verbose error message for :py:class:`CommitException`
365 COMMIT_EXCEPTION_MESSAGE = """\
366 Error committing CNF variables!
367 ----------------------------
368 Input:
369 {cnfvars}
370 ----------------------------
371 Error:
372 {msg}
373 """
374
375
376 class CommitException(Exception):
377     """Custom exception for commit errors."""
378
379     def __init__(self, cnfvars, msg):
380         """
381         Initialize this exception.
382
383         :param cnfvars: list of CNF variables that would be committed
384         :type cnfvars: CnfList
385         :param str msg: error message
386         """
387         super().__init__(msg)
388         self.message = COMMIT_EXCEPTION_MESSAGE.format(cnfvars=cnfvars, msg=msg)