#!/usr/bin/env python3 import argparse import configparser import os import re import subprocess import sys import tempfile import yaml BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) sys.path.insert(0, BASE_DIR) from scripts.lib.puppet_cache import setup_puppet_modules from scripts.lib.zulip_tools import assert_running_as_root, parse_os_release assert_running_as_root() parser = argparse.ArgumentParser(description="Run Puppet") parser.add_argument( "--force", "-f", action="store_true", help="Do not prompt with proposed changes" ) parser.add_argument("--noop", action="store_true", help="Do not apply the changes") parser.add_argument("--config", default="/etc/zulip/zulip.conf", help="Alternate zulip.conf path") args, extra_args = parser.parse_known_args() config = configparser.RawConfigParser() config.read(args.config) setup_puppet_modules() distro_info = parse_os_release() puppet_config = """ Exec { path => "/usr/sbin:/usr/bin:/sbin:/bin" } """ for pclass in re.split(r"\s*,\s*", config.get("machine", "puppet_classes")): if " " in pclass: print( f"The `machine.puppet_classes` setting in {args.config} must be comma-separated, not space-separated!" ) sys.exit(1) puppet_config += f"include {pclass}\n" # We use the Puppet configuration from the same Zulip checkout as this script scripts_path = os.path.join(BASE_DIR, "scripts") puppet_module_path = os.path.join(BASE_DIR, "puppet") puppet_cmd = [ "puppet", "apply", f"--modulepath={puppet_module_path}:/srv/zulip-puppet-cache/current", "-e", puppet_config, ] if args.noop: puppet_cmd += ["--noop"] puppet_cmd += extra_args # Set the scripts path to be a factor so it can be used by Puppet code puppet_env = os.environ.copy() puppet_env["FACTER_zulip_conf_path"] = args.config puppet_env["FACTER_zulip_scripts_path"] = scripts_path def noop_would_change(puppet_cmd: list[str]) -> bool: # --noop does not work with --detailed-exitcodes; see # https://tickets.puppetlabs.com/browse/PUP-686 try: lastrun_file = tempfile.NamedTemporaryFile() subprocess.check_call( # puppet_cmd may already contain --noop, but it is safe to # supply twice [*puppet_cmd, "--noop", "--lastrunfile", lastrun_file.name], env=puppet_env, ) with open(lastrun_file.name) as lastrun: lastrun_data = yaml.safe_load(lastrun) resources = lastrun_data.get("resources", {}) if resources.get("failed", 0) != 0: sys.exit(2) return resources.get("out_of_sync", 0) != 0 except subprocess.CalledProcessError: sys.exit(2) finally: lastrun_file.close() if not args.noop and not args.force: if not noop_would_change([*puppet_cmd, "--show_diff"]): sys.exit(0) do_apply = None while do_apply != "y": sys.stdout.write("Apply changes? [y/N] ") sys.stdout.flush() do_apply = sys.stdin.readline().strip().lower() if do_apply in ("", "n"): sys.exit(0) if args.noop and args.force: if noop_would_change(puppet_cmd): sys.exit(1) else: sys.exit(0) ret = subprocess.call([*puppet_cmd, "--detailed-exitcodes"], env=puppet_env) # ret = 0 => no changes, no errors # ret = 2 => changes, no errors # ret = 4 => no changes, yes errors # ret = 6 => changes, yes errors if ret not in (0, 2): sys.exit(2)