Increase version to 1.7.4
[pyi2ncommon] / src / cnfvar / binary.py
... / ...
CommitLineData
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"""
22binary: wrappers around binaries that work with the CNF store.
23
24Featuring:
25 - CnfBinary: stateless class that can be used to invoke the different binaries
26 in a Python-friendly way.
27
28.. note:: It is written as a class on purpose for it to be easily extended to
29 support invoking non-local binaries, but methods are all class methods since
30 the class is stateless.
31
32.. seealso:: Overview Diagram linked to from doc main page
33
34.. codeauthor:: Intra2net
35"""
36
37import subprocess
38import logging
39import shlex
40import html
41import re
42
43log = logging.getLogger("pyi2ncommon.cnfvar.binary")
44
45#: default get_cnf binary
46BIN_GET_CNF = "/usr/intranator/bin/get_cnf"
47#: default set_cnf binary
48BIN_SET_CNF = "/usr/intranator/bin/set_cnf"
49#: encoding used by the get_cnf and set_cnf binaries
50ENCODING = "latin1"
51
52
53class CnfBinary:
54 """Provide wrappers around the multiple binaries to handle the CNF store."""
55
56 @classmethod
57 def run_cmd(cls, cmd, cmd_input=None, ignore_errors=False, timeout=60, encoding=ENCODING):
58 """
59 Run commands on the local machine with input via stdin.
60
61 :param cmd: command to run
62 :type cmd: str or list
63 :param str cmd_input: input to give to the command
64 :param bool ignore_errors: whether to ignore the exit code or raise if not zero
65 :param int timeout: amount of seconds to wait for the command to complete
66 :param str encoding: encoding to use (pass latin1 when not working with JSON)
67 :returns: command result
68 :rtype: :py:class:`subprocess.CompletedProcess`
69 """
70 if isinstance(cmd, str):
71 cmd = shlex.split(cmd)
72
73 log.debug("Running binary cmd `%s` with input:\n%s",
74 ' '.join(cmd), cmd_input)
75 retval = subprocess.run(cmd, input=cmd_input, check=not ignore_errors,
76 capture_output=True, encoding=encoding, timeout=timeout)
77 log.debug("Command exited with \n"
78 "\treturncode=%d\n"
79 "\tstderr=%s\n"
80 "\tstdout=%s", retval.returncode, retval.stderr, retval.stdout)
81 return retval
82
83 @classmethod
84 def get_cnf(cls, name=None, instance=None, no_children=False,
85 as_json=False):
86 """
87 Wrapper around the `get_cnf` binary.
88
89 :param str name: optional name of the CNFs to get
90 :param instance: CNF instance
91 :type instance: str or int
92 :param bool no_children: whether to return child CNFs
93 :param bool as_json: whether to return a JSON-formatted string
94 :returns: output of the tool
95 :rtype: str
96
97 .. note:: being a wrapper, this function does not do anything extra
98 like checking if arnied is running or waiting for generate.
99 """
100 if name is None and instance is not None:
101 raise ValueError("cannot pass `instance` without a `name`")
102
103 if isinstance(instance, str):
104 instance = int(instance) # validate
105 elif instance is not None and not isinstance(instance, int):
106 raise TypeError(f"`instance` is of wrong type {type(instance)}")
107
108 # make sure 0 instance cnfvars like e.g. NICs can also be filtered by instance
109 cmd = f"{BIN_GET_CNF} {name or ''} {instance if instance is not None else ''}"
110
111 encoding = ENCODING
112 if no_children:
113 cmd += " --no-childs"
114 if as_json:
115 cmd += " --json"
116 encoding = "utf8"
117
118 # TODO: should error handling be improved so error messages
119 # from the binary are converted into specific exceptions types?
120 output = cls.run_cmd(cmd, encoding=encoding).stdout.strip()
121 return output
122
123 @classmethod
124 def set_cnf(cls, input_str=None, input_file=None, delete=False,
125 delete_file=False, as_json=False, fix_problems=False, **kwargs):
126 """
127 Wrapper around the `set_cnf` binary.
128
129 :param str input_str: string to send as input to the binary
130 :param str input_file: path to a file to pass to the binary
131 :param bool delete: whether to delete the corresponding CNFs
132 :param bool delete_file: whether to delete the file passed as input
133 :param bool as_json: whether to interpret input as JSON
134 :param bool fix_problems: whether to automatically fix errors in the vars
135 :param kwargs: extra arguments to pass to the binary - underscores are
136 replaced by dash, e.g. set_cnf(..., enable_queue=True)
137 becomes `/usr/intranator/bin/set_cnf --enable-queue`
138 :raises: :py:class:`SetCnfException` in case the binary errors out
139
140 .. note:: being a wrapper, this function does not do anything extra
141 like checking if arnied is running or waiting for generate.
142 """
143 if input_str is None and input_file is None:
144 raise ValueError("Need to pass either a string or a file")
145
146 if delete_file is True and input_file is None:
147 raise ValueError("Cannot delete an unspecified file")
148
149 cmd = f"{BIN_SET_CNF}"
150 encoding = ENCODING
151 if as_json:
152 cmd += " --json"
153 encoding = "utf8"
154 if delete:
155 cmd += " -x"
156 if delete_file:
157 cmd += " --delete-file"
158 if fix_problems:
159 cmd += " --fix-problems"
160
161 for k, v in kwargs.items():
162 if v is True:
163 k = "-".join(k.split("_"))
164 cmd += f" --{k}"
165
166 if input_file:
167 cmd += f" {input_file}"
168
169 try:
170 cls.run_cmd(cmd, cmd_input=input_str, encoding=encoding)
171 except subprocess.CalledProcessError as ex:
172 # clean up the error message
173 ex.stderr = cls._clean_set_cnf_error(ex.stderr)
174 raise
175
176 @classmethod
177 def _clean_set_cnf_error(cls, err):
178 """
179 Turn the error from the set_cnf binary into a more readable message.
180
181 :param str err: error message from the binary
182 :returns: the clean error message
183 :rtype: str
184
185 Keep only offending lines and strip out HTML tags.
186 """
187 def get_error_lines(output_lines):
188 for cnfline in output_lines:
189 buffer = ""
190 quoted = False
191 parts = []
192 for c in cnfline:
193 last_chr = buffer[-1] if buffer else None
194 # this is a literal (unescaped) quote
195 if c == "\"" and last_chr != "\\":
196 quoted = not quoted
197
198 if c == " " and not quoted:
199 parts.append(buffer)
200 buffer = ""
201 else:
202 buffer += c
203 parts.append(buffer)
204
205 if parts[-2] == "0":
206 # no errors, ignore
207 continue
208
209 lineno, name, instance, parent, value, exit_code, error = parts
210
211 # the binary outputs HTML strings
212 error = html.unescape(error)
213 # replace line breaks for readability
214 error = re.sub(r"(<br\s*>)+", " ", error)
215 # clean up HTML tags
216 error = re.sub(r"<[^<]+?>", "", error)
217
218 if parent == "-1":
219 message = f"`` {lineno} {name},{instance}: {value} ``"
220 else:
221 message = f"`` {lineno} ({parent}) {name},{instance}: {value} ``"
222 yield f"Error in {message}: {error} (code={exit_code})"
223
224 lines = err.splitlines()
225 if len(lines) == 0:
226 return err
227
228 if "fatal errors in changes" not in lines[0]:
229 return err
230
231 errors = list(get_error_lines(lines[1:]))
232 return "\n".join(errors)