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