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