1#!/usr/bin/python
2# Note that the shebang above is ignored when run in policy
3# See lib/packages.cf `package_module yum` use of the
4# `interpreter` attribute to use cfengine-selected-python.
5
6import sys
7import os
8import subprocess
9import re
10
11
12rpm_cmd = os.environ.get('CFENGINE_TEST_RPM_CMD', "/bin/rpm")
13rpm_quiet_option = ["--quiet"]
14rpm_output_format = "Name=%{name}\nVersion=%{version}-%{release}\nArchitecture=%{arch}\n"
15
16yum_cmd = os.environ.get('CFENGINE_TEST_YUM_CMD', "/usr/bin/yum")
17yum_options = ["--quiet", "-y"]
18
19NULLFILE = open(os.devnull, 'w')
20
21
22redirection_is_broken_cached = -1
23
24def redirection_is_broken():
25    # Older versions of Python have a bug where it is impossible to redirect
26    # stderr using subprocess, and any attempt at redirecting *anything*, not
27    # necessarily stderr, will result in it being closed instead. This is very
28    # bad, because RPM may then open its RPM database on file descriptor 2
29    # (stderr), and will cause it to output error messages directly into the
30    # database file. Fortunately "stdout=subprocess.PIPE" doesn't have the bug,
31    # and that's good, because it would have been much more tricky to solve.
32    global redirection_is_broken_cached
33    if redirection_is_broken_cached == -1:
34        cmd_line = [sys.executable, sys.argv[0], "internal-test-stderr"]
35        if subprocess.call(cmd_line, stdout=sys.stderr) == 0:
36            redirection_is_broken_cached = 0
37        else:
38            redirection_is_broken_cached = 1
39
40    return redirection_is_broken_cached
41
42
43def subprocess_Popen(cmd, stdout=None, stderr=None):
44    if not redirection_is_broken() or (stdout is None and stderr is None) or stdout == subprocess.PIPE or stderr == subprocess.PIPE:
45        return subprocess.Popen(cmd, stdout=stdout, stderr=stderr)
46
47    old_stdout_fd = -1
48    old_stderr_fd = -1
49
50    if stdout is not None:
51        old_stdout_fd = os.dup(1)
52        os.dup2(stdout.fileno(), 1)
53
54    if stderr is not None:
55        old_stderr_fd = os.dup(2)
56        os.dup2(stderr.fileno(), 2)
57
58    result = subprocess.Popen(cmd)
59
60    if old_stdout_fd >= 0:
61        os.dup2(old_stdout_fd, 1)
62        os.close(old_stdout_fd)
63
64    if old_stderr_fd >= 0:
65        os.dup2(old_stderr_fd, 2)
66        os.close(old_stderr_fd)
67
68    return result
69
70
71def subprocess_call(cmd, stdout=None, stderr=None):
72    process = subprocess_Popen(cmd, stdout, stderr)
73    return process.wait()
74
75
76def get_package_data():
77    pkg_string = ""
78    for line in sys.stdin:
79        if line.startswith("File="):
80            pkg_string = line.split("=", 1)[1].rstrip()
81            # Don't break, we need to exhaust stdin.
82
83    if not pkg_string:
84        return 1
85
86    if pkg_string.startswith("/"):
87        # Absolute file.
88        sys.stdout.write("PackageType=file\n")
89        sys.stdout.flush()
90        return subprocess_call([rpm_cmd, "--qf", rpm_output_format, "-qp", pkg_string])
91    elif re.search("[:,]", pkg_string):
92        # Contains an illegal symbol.
93        sys.stdout.write(line + "ErrorMessage: Package string with illegal format\n")
94        return 1
95    else:
96        sys.stdout.write("PackageType=repo\n")
97        sys.stdout.write("Name=" + pkg_string + "\n")
98        return 0
99
100
101def list_installed():
102    # Ignore everything.
103    sys.stdin.readlines()
104
105    return subprocess_call([rpm_cmd, "-qa", "--qf", rpm_output_format])
106
107
108def list_updates(online):
109    global yum_options
110    for line in sys.stdin:
111        line = line.strip()
112        if line.startswith("options="):
113            option = line[len("options="):]
114            if option.startswith("-"):
115                yum_options.append(option)
116            elif option.startswith("enablerepo=") or option.startswith("disablerepo="):
117                yum_options.append("--" + option)
118
119    online_flag = []
120    if not online:
121        online_flag = ["-C"]
122
123    process = subprocess_Popen([yum_cmd] + yum_options + online_flag + ["check-update"], stdout=subprocess.PIPE)
124    (stdoutdata, _) = process.communicate()
125    # analyze return code from `yum check-update`:
126    # 0 means no updates
127    # 1 means there was an error
128    # 100 means that there are available updates
129    if process.returncode == 1 and not online:
130        # If we get an error when listing local updates, try again using the
131        # online method, so that the cache is generated
132        process = subprocess_Popen([yum_cmd] + yum_options + ["check-update"], stdout=subprocess.PIPE)
133        (stdoutdata, _) = process.communicate()
134    if process.returncode != 100:
135        # either there were no updates or error happened
136        # Nothing to do for us here anyway
137        return process.returncode
138    lastline = ""
139    for line in stdoutdata.decode("utf-8").splitlines():
140        # Combine multiline entries into one line. A line without at least three
141        # space separated fields gets combined with the next line, if that line
142        # starts with a space.
143        if lastline and (len(line) == 0 or not line[0].isspace()):
144            # Line does not start with a space. No combination.
145            lastline = ""
146
147        line = lastline + line
148        match = re.match("^\S+\s+\S+\s+\S+", line)
149        if match is None:
150            # Keep line
151            lastline = line
152            continue
153
154        lastline = ""
155        match = re.match("^(?P<name>\S+)\.(?P<arch>[^.\s]+)\s+(?P<version>\S+)\s+\S+\s*$", line)
156        if match is not None:
157            sys.stdout.write("Name=" + match.group("name") + "\n")
158            sys.stdout.write("Version=" + match.group("version") + "\n")
159            sys.stdout.write("Architecture=" + match.group("arch") + "\n")
160
161    return 0
162
163
164# Returns a pair:
165# List 1: Contains arguments for a single command line.
166# List 2: Contains arguments for multiple command lines (see comments in
167#         repo_install()).
168def one_package_argument(name, arch, version, is_yum_install):
169    args = []
170    archs = []
171    exists = False
172
173    if arch:
174        archs.append(arch)
175
176    if is_yum_install:
177        process = subprocess_Popen([rpm_cmd, "--qf", "%{arch}\n",
178                                    "-q", name], stdout=subprocess.PIPE)
179        existing_archs = [line.decode("utf-8").rstrip() for line in process.stdout]
180        process.wait()
181        if process.returncode == 0 and existing_archs:
182            exists = True
183            if not arch:
184                # Here we have no specified architecture and we are
185                # installing.  If we have existing versions, operate
186                # on those, instead of the platform default.
187                archs += existing_archs
188
189    version_suffix = ""
190    if version:
191        version_suffix = "-" + version
192
193    if archs:
194        args += [name + version_suffix + "." + arch for arch in archs]
195    else:
196        args.append(name + version_suffix)
197
198    if exists and version:
199        return [], args
200    else:
201        return args, []
202
203
204# Returns a pair:
205# List 1: Contains arguments for a single command line.
206# List 2: Contains arguments for multiple command lines (see comments in
207#         repo_install()). This is a list of lists, where the logic is:
208#           list
209#             |             +---- package1:amd64    -+
210#             +- sublist ---+                        +--- Do these together
211#             |             +---- package1:i386     -+
212#             |
213#             |
214#             |             +---- package2:amd64    -+
215#             +- sublist ---+                        +--- And these together
216#                           +---- package2:i386     -+
217def package_arguments_builder(is_yum_install):
218    name = ""
219    version = ""
220    arch = ""
221    single_cmd_args = []    # List of arguments
222    multi_cmd_args = []     # List of lists of arguments
223    old_name = ""
224    for line in sys.stdin:
225        line = line.strip()
226        if line.startswith("options="):
227            option = line[len("options="):]
228            if option.startswith("-"):
229                yum_options.append(option)
230            elif option.startswith("enablerepo=") or option.startswith("disablerepo="):
231                yum_options.append("--" + option)
232        if line.startswith("Name="):
233            if name:
234                # Each new "Name=" triggers a new entry.
235                single_list, multi_list = one_package_argument(name, arch, version, is_yum_install)
236                single_cmd_args += single_list
237                if name == old_name:
238                    # Packages that differ only by architecture should be
239                    # processed together
240                    multi_cmd_args[-1] += multi_list
241                elif multi_list:
242                    # Otherwise we process them individually.
243                    multi_cmd_args += [multi_list]
244
245                version = ""
246                arch = ""
247
248            old_name = name
249            name = line.split("=", 1)[1].rstrip()
250
251        elif line.startswith("Version="):
252            version = line.split("=", 1)[1].rstrip()
253
254        elif line.startswith("Architecture="):
255            arch = line.split("=", 1)[1].rstrip()
256
257    if name:
258        single_list, multi_list = one_package_argument(name, arch, version, is_yum_install)
259        single_cmd_args += single_list
260        if name == old_name:
261            # Packages that differ only by architecture should be
262            # processed together
263            multi_cmd_args[-1] += multi_list
264        elif multi_list:
265            # Otherwise we process them individually.
266            multi_cmd_args += [multi_list]
267
268    return single_cmd_args, multi_cmd_args
269
270
271def repo_install():
272    # Due to how yum works we need to split repo installs into several
273    # components.
274    #
275    # 1. Installation of fresh packages is easy, we add all of them on one
276    #    command line.
277    # 2. Upgrade of existing packages where no version has been specified is
278    #    also easy, we add that to the same command line.
279    # 3. Up/downgrade of existing packages where version is specified is
280    #    tricky, for several reasons:
281    #      a) There is no one yum command that will do both, "install" or
282    #         "upgrade" will only upgrade, and "downgrade" will only downgrade.
283    #      b) There is no way rpm or yum will tell you which version is higher
284    #         than the other, and we know from experience with the old package
285    #         promise implementation that we don't want to try to do such a
286    #         comparison ourselves.
287    #      c) yum has no dry-run mode, so we cannot tell in advance which
288    #         operation will succeed.
289    #      d) yum will not even tell you whether operation succeeded when you
290    #         run it for real
291    #
292    # So here's what we need to do. We start by querying each package to find
293    # out whether that exact version is installed. If it fulfills 1. or 2. we
294    # add it to that single command line.
295    #
296    # If we end up at 3. we need to split the work and do each package
297    # separately. We do:
298    #
299    # 1. Try to upgrade using "yum upgrade".
300    # 2. Query the package again, see if it is the right version now.
301    # 3. If not, try to downgrade using "yum downgrade".
302    # 4. Query the package again, see if it is the right version now.
303    # 5. Final safeguard, try installing using "yum install". This may happen
304    #    in case we have one architecture already, but we are installing a
305    #    second one. In this case only install will work.
306    # 6. (No need to check again, CFEngine will do the final check)
307    #
308    # This is considerably more expensive than what we do for apt, but it's the
309    # only way to cover all bases. In apt it will be one apt call for any number
310    # of packages, with yum it will in the worst case be:
311    #   1 + 5 * number_of_packages
312    # although a more common case will probably be:
313    #   1 + 2 * number_of_packages
314    # since it's unlikely that people will do a whole lot of downgrades
315    # simultaneously.
316
317    ret = 0
318    single_cmd_args, multi_cmd_args = package_arguments_builder(True)
319
320    if single_cmd_args:
321        cmd_line = [yum_cmd] + yum_options + ["install"]
322        cmd_line.extend(single_cmd_args)
323
324        ret = subprocess_call(cmd_line, stdout=NULLFILE)
325
326    if multi_cmd_args:
327        for block in multi_cmd_args:
328            # Try to upgrade.
329            cmd_line = [yum_cmd] + yum_options + ["upgrade"] + block
330            subprocess_call(cmd_line, stdout=NULLFILE)
331
332            # See if it succeeded.
333            success = True
334            for item in block:
335                cmd_line = [rpm_cmd] + rpm_quiet_option + ["-q", item]
336                if subprocess_call(cmd_line, stdout=NULLFILE) != 0:
337                    success = False
338                    break
339
340            if success:
341                continue
342
343            # Try to downgrade.
344            cmd_line = [yum_cmd] + yum_options + ["downgrade"] + block
345            subprocess_call(cmd_line, stdout=NULLFILE)
346
347            # See if it succeeded.
348            success = True
349            for item in block:
350                cmd_line = [rpm_cmd] + rpm_quiet_option + ["-q", item]
351                if subprocess_call(cmd_line, stdout=NULLFILE) != 0:
352                    success = False
353                    break
354
355            if success:
356                continue
357
358            # Try to plain install.
359            cmd_line = [yum_cmd] + yum_options + ["install"] + block
360            subprocess_call(cmd_line, stdout=NULLFILE)
361
362            # No final check. CFEngine will figure out that it's missing
363            # if it failed.
364
365    # ret == 0 doesn't mean we succeeded with everything, but it's expensive to
366    # check, so let CFEngine do that.
367    return ret
368
369
370def remove():
371    cmd_line = [yum_cmd] + yum_options + ["remove"]
372
373    # package_arguments_builder will always return empty second element in case
374    # of removals, so just drop it.         |
375    #                                       V
376    args = package_arguments_builder(False)[0]
377
378    if args:
379        return subprocess_call(cmd_line + args, stdout=NULLFILE)
380    return 0
381
382
383def file_install():
384    cmd_line = [rpm_cmd] + rpm_quiet_option + ["--force", "-U"]
385    found = False
386    for line in sys.stdin:
387        if line.startswith("File="):
388            found = True
389            cmd_line.append(line.split("=", 1)[1].rstrip())
390
391    if not found:
392        return 0
393
394    return subprocess_call(cmd_line, stdout=NULLFILE)
395
396
397def main():
398    if len(sys.argv) < 2:
399        sys.stderr.write("Need to provide argument\n")
400        return 2
401
402    if sys.argv[1] == "internal-test-stderr":
403        # This will cause an exception if stderr is closed.
404        try:
405            os.fstat(2)
406        except OSError:
407            return 1
408        return 0
409
410    elif sys.argv[1] == "supports-api-version":
411        sys.stdout.write("1\n")
412        return 0
413
414    elif sys.argv[1] == "get-package-data":
415        return get_package_data()
416
417    elif sys.argv[1] == "list-installed":
418        return list_installed()
419
420    elif sys.argv[1] == "list-updates":
421        return list_updates(True)
422
423    elif sys.argv[1] == "list-updates-local":
424        return list_updates(False)
425
426    elif sys.argv[1] == "repo-install":
427        return repo_install()
428
429    elif sys.argv[1] == "remove":
430        return remove()
431
432    elif sys.argv[1] == "file-install":
433        return file_install()
434
435    else:
436        sys.stderr.write("Invalid operation\n")
437        return 2
438
439sys.exit(main())
440