Revert the default instance number back to zero
[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.
c89cc126
PD
147
148 ..todo:: This method compensates for limitations in production code that
149 might end up fixed up there deprecating our patching here.
d31714a0
SA
150 """
151
152 def _do_commit(self, original_cnfs, arnied_cnfs, fix_problems=False):
153 """
154 Set cnfvars and commit changes.
155
156 :param original_cnfs: list of CNFs to print in case of errors
157 :type original_cnfs: :py:class:`CnfList`
158 :param arnied_cnfs: list of cnfvars to pass to the arnied API
159 :type arnied_cnfs: [:py:class:`arnied_api.CnfVar`]
160 :param bool fix_problems: whether to automatically fix errors in the vars
161 :raises: :py:class:`CommitException` if the arnied API complains
162 """
163 try:
164 ret = self._driver.set_commit_cnf(vars=arnied_cnfs, username=None,
165 nogenerate=False,
166 fix_commit=fix_problems)
167 except arnied_api.CnfCommitError as ex:
168 # fatal errors, we will handle it in our custom exception
169 ret = ex
170
171 errors = []
172 for r in ret.results:
173 # message can contain HTML escape codes
174 msg = html.unescape(r.result_msg)
175
176 # `result_type` is defined as int in the varlink API,
177 # but the meaning of the codes are:
178 # enum result_type { OK=0, WARN=1, FAIL_TEMP=2, FAIL=3 }
179 if r.result_type == 1:
180 log.debug("Warning in `` %s,%s: \"%s\" ``: {msg} (code=%s)",
181 r.name, r.instance, r.data, r.result_type)
182 elif r.result_type > 1:
183 errors.append(f"Error in `` {r.name},{r.instance}: \"{r.data}\" ``"
184 f": {msg}")
185
186 if len(errors) > 0:
187 log.debug("Error sending variables:\n%s", arnied_cnfs)
188 raise CommitException(original_cnfs, "\n".join(errors))
189
190 self._wait_for_generate()
191
192 def _wait_for_generate(self, timeout=300):
193 """
194 Wait for the 'generate' program to end.
195
196 :param int timeout: program run timeout
197 :raises: :py:class:`TimeoutError` if the program did not finish on time
198 """
199 def scheduled_or_running(progname):
200 ret = self._driver.is_scheduled_or_running(progname)
201 return ret.status in [arnied_api.ProgramStatus.Scheduled,
202 arnied_api.ProgramStatus.Running]
203
204 def wait_for_program(progname):
205 log.debug("Waiting for `%s` to be running", progname)
206 for _ in range(10):
207 if scheduled_or_running(progname):
208 # if is running or scheduled, break to wait for completion
209 break
210 time.sleep(1)
211 else:
212 # after trying and retrying, program is not scheduled nor
213 # running, so it is safe to assume it has already executed
214 return
215
216 # program running or scheduled, wait
217 log.debug("Waiting for `%s` to finish", progname)
218 for _ in range(0, timeout):
219 if not scheduled_or_running(progname):
220 # finished executing, bail out
221 return
222 time.sleep(1)
223 raise TimeoutError(f"Program `{progname}` did not end in time")
224
225 wait_for_program("GENERATE")
226 wait_for_program("GENERATE_OFFLINE")
227
228 def _cnf_or_list(self, cnf, operation) -> CnfList:
229 """
230 Validate and wrap a CNF value into a list.
231
232 :param cnf: a single CNF value or a list of values
233 :type cnf: :py:class:`CnfList` or :py:class:`Cnf`
234 :param str operation: name of the operation that is being done
235 :raises: :py:class:`TypeError` if the type of the CNF object
236 is neither a list nor a CNF value
237 :returns: wrapped CNF value
238 :rtype: :py:class:`CnfList`
239 """
240 if isinstance(cnf, Cnf):
241 cnf = CnfList([cnf])
242 elif not isinstance(cnf, CnfList):
243 raise TypeError(f"Cannot {operation} value(s) of type `{type(cnf)}`")
244 return cnf
245
246
247class BinaryCnfStore(CnfStore):
d5d2c1d7 248 """Implementation of the CNF store that uses `get_cnf` and `set_cnf`."""
d31714a0
SA
249
250 #: how much to wait for arnied to report running
251 ARNIED_TIMEOUT = 30
252
253 def __init__(self, backend_driver=CnfBinary):
254 """
255 Initialize this class.
256
257 :param backend_driver: driver to use to talk to the cnfvar backend
258 :type: :py:class:`CnfBinary`
259 """
260 super().__init__(backend_driver=backend_driver)
261 log.debug("Initialized BinaryCnfStore with driver `%s`", type(backend_driver))
262
263 def query(self, name=None, instance=None):
264 """
265 Query the CNF store and return a list of parsed CNFs.
266
267 :param str name: optional name of the CNFs to query
268 :param instance: optional CNF instance
269 :type instance: str or int
270 :returns: list of parsed CNF values
271 :rtype: :py:class:`CnfList`
272
273 Example::
274 store = CnfStore()
275 user_cnfs = store.query("USER")
276 """
277 log.debug("Querying BinaryCnfStore with name=%s and instance=%s",
278 name, instance)
d5d2c1d7 279 output = self._driver.get_cnf(name, instance=instance)
d31714a0
SA
280
281 if len(output) == 0:
282 # otherwise cnfvar raises a generic Malformed exception
283 return CnfList()
284
285 return CnfList.from_cnf_string(output)
286
287 def commit(self, cnf, fix_problems=False):
288 """
289 Update or insert CNF variables from a list.
290
291 :param cnf: CNF instance or list of CNFs to update or insert
292 :type cnf: :py:class:`Cnf` or :py:class:`CnfList`
293 :param bool fix_problems: whether to automatically fix errors in the vars
294
295 .. note:: you can mix variables to insert and variables to update
d5d2c1d7 296 in the same list as the system should handle it nicely
d31714a0
SA
297
298 Example::
299 store = CnfStore()
300 user_cnf = store.query("USER")\
301 .single_with_value("joe")
302 user_cnf.add_child("user_group_member_ref", "3")
303 store.commit(user_cnf)
304 """
305 cnf = self._cnf_or_list(cnf, operation="commit")
306 self._autofix_instances(cnf)
307
d5d2c1d7 308 # set_cnf is demanding on lineno's
d31714a0
SA
309 cnf.renumber()
310 log.debug("Committing variables via binaries:\n%s", cnf)
311
312 self._call_arnied(arnied_wrapper.verify_running, timeout=self.ARNIED_TIMEOUT)
313 try:
314 self._driver.set_cnf(input_str=str(cnf), fix_problems=fix_problems)
315 except subprocess.CalledProcessError as ex:
316 raise CommitException(cnf, ex.stderr) from None
317 self._call_arnied(arnied_wrapper.wait_for_generate)
318
319 def delete(self, cnf, fix_problems=False):
320 """
321 Delete a list of top-level CNF variables.
322
323 :param cnf: a single CNF value or a list of values
324 :type cnf: :py:class:`CnfList` or :py:class:`Cnf`
325 :param bool fix_problems: whether to automatically fix errors in the vars
326
327 Example::
328 store = CnfStore()
329 user_cnfs = store.query("USER")\
330 .where(lambda c: c.value in ["joe", "jane"])
331 store.delete(user_cnfs)
332 """
333 cnf = self._cnf_or_list(cnf, operation="delete")
334 if any((c.parent is not None for c in cnf)):
335 raise RuntimeError("Calling delete is only supported on top-level CNF variables")
336
d5d2c1d7 337 # set_cnf is demanding on lineno's
d31714a0
SA
338 cnf.renumber()
339 log.debug("Deleting variables via binaries:\n%s", cnf)
340
341 self._call_arnied(arnied_wrapper.verify_running, timeout=self.ARNIED_TIMEOUT)
342 try:
343 self._driver.set_cnf(input_str=str(cnf), delete=True, fix_problems=fix_problems)
344 except subprocess.CalledProcessError as ex:
345 raise CommitException(cnf, ex.stderr) from None
346 self._call_arnied(arnied_wrapper.wait_for_generate)
347
348 def _call_arnied(self, fn, *args, **kwargs):
349 """
350 Simple proxy around the arnied to be overwritten in child classes.
351
352 :param fn: function to invoke in the arnied wrapper
353 :type fn: py:function
354 :param args: arguments to be passed to function
355 :param kwargs: named arguments to be passed to function
356 """
357 return fn(*args, **kwargs)
358
359
d5d2c1d7
CH
360#: pattern for more verbose error message for :py:class:`CommitException`
361COMMIT_EXCEPTION_MESSAGE = """\
362Error committing CNF variables!
363----------------------------
364Input:
365{cnfvars}
366----------------------------
367Error:
368{msg}
369"""
370
371
d31714a0
SA
372class CommitException(Exception):
373 """Custom exception for commit errors."""
374
375 def __init__(self, cnfvars, msg):
376 """
377 Initialize this exception.
378
379 :param cnfvars: list of CNF variables that would be committed
380 :type cnfvars: CnfList
381 :param str msg: error message
382 """
d31714a0 383 super().__init__(msg)
d5d2c1d7 384 self.message = COMMIT_EXCEPTION_MESSAGE.format(cnfvars=cnfvars, msg=msg)