Wait for arnied in addition to generate to preserve backward compatibility
[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
3e582fbf
PD
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
cbf00196 58 log.debug(f"Initialized cnf store with driver `{backend_driver.__name__}`")
d31714a0
SA
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
d5d2c1d7 96 in the same list as the system should handle it nicely
d31714a0
SA
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
7628bc48 145 automatically. However, it starts on 1, whereas many variables are not
d31714a0
SA
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.
c89cc126
PD
150
151 ..todo:: This method compensates for limitations in production code that
152 might end up fixed up there deprecating our patching here.
d31714a0
SA
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
250class BinaryCnfStore(CnfStore):
d5d2c1d7 251 """Implementation of the CNF store that uses `get_cnf` and `set_cnf`."""
d31714a0
SA
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)
3e582fbf
PD
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)
cbf00196 267 log.debug(f"Initialized binary cnf store with driver `{backend_driver.__name__}`")
d31714a0
SA
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)
d5d2c1d7 285 output = self._driver.get_cnf(name, instance=instance)
d31714a0
SA
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
d5d2c1d7 302 in the same list as the system should handle it nicely
d31714a0
SA
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
d5d2c1d7 314 # set_cnf is demanding on lineno's
d31714a0
SA
315 cnf.renumber()
316 log.debug("Committing variables via binaries:\n%s", cnf)
317
d31714a0
SA
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
d5d2c1d7 342 # set_cnf is demanding on lineno's
d31714a0
SA
343 cnf.renumber()
344 log.debug("Deleting variables via binaries:\n%s", cnf)
345
d31714a0
SA
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
d5d2c1d7
CH
364#: pattern for more verbose error message for :py:class:`CommitException`
365COMMIT_EXCEPTION_MESSAGE = """\
366Error committing CNF variables!
367----------------------------
368Input:
369{cnfvars}
370----------------------------
371Error:
372{msg}
373"""
374
375
d31714a0
SA
376class 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 """
d31714a0 387 super().__init__(msg)
d5d2c1d7 388 self.message = COMMIT_EXCEPTION_MESSAGE.format(cnfvars=cnfvars, msg=msg)