Increase version to 1.7.4
[pyi2ncommon] / src / cnfvar / binary.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"""
22binary: wrappers around binaries that work with the CNF store.
23
d5d2c1d7
CH
24Featuring:
25 - CnfBinary: stateless class that can be used to invoke the different binaries
26 in a Python-friendly way.
d31714a0
SA
27
28.. note:: It is written as a class on purpose for it to be easily extended to
d5d2c1d7
CH
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
d31714a0
SA
33
34.. codeauthor:: Intra2net
35"""
36
37import subprocess
38import logging
39import shlex
40import html
41import re
42
d5d2c1d7 43log = logging.getLogger("pyi2ncommon.cnfvar.binary")
d31714a0
SA
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
d5d2c1d7 53class CnfBinary:
d31714a0
SA
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
d5d2c1d7 98 like checking if arnied is running or waiting for generate.
d31714a0
SA
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
daf0bfad
PD
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 ''}"
d31714a0
SA
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()
d31714a0
SA
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
d5d2c1d7 141 like checking if arnied is running or waiting for generate.
d31714a0
SA
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)