#!/usr/bin/env python3 import argparse from concurrent import futures import pathlib import shlex import shutil import subprocess import textwrap import yaml """ This script performs Puppet catalog compilation without a central server. It receives the following arguments: * directory: a working directory. The script expects to find some data, like variables to use in the compilation process, facts, etc. The script also generates intermediate files and output there. * modulepath: path to your modules directory * manifest: path to your site directory * host: the hosts to compile catalogs to The script expects the following content on the working directory: directory/ global_vars/*.json: these JSON files will be available to all hosts host_vars/{host}/*.json: these JSON files will be available in each host facts/{host}.json: output from "facter -y" for each host And produces the following files: directory/ output/ {host}/ catalog.json: the compiled catalog for the host modules: a copy of the module directory Just ship the {host} directory to each host and run: $ puppet apply --catalog .../catalog.json --modulepath=.../modules/ Check the apply_catalog Ansible role for example usage. As we have the catalogs, we can manipulate them. See pseudo_resource_exporter.py for an example hack. We can simulate exported resources without PuppetDB. """ def build_hiera(directory, build_host_dir, host): hiera_data_dir = build_host_dir / "data" hiera_data_dir.mkdir() hiera = { "version": 5, "hierarchy": [] } global_vars_dir = directory / "global_vars" for global_var in global_vars_dir.glob("*.json"): shutil.copy(global_var, hiera_data_dir / global_var.name) hiera["hierarchy"].append({ "name": global_var.name.removesuffix(".json"), "path": global_var.name, "data_hash": "json_data", }) host_vars_dir = directory / "host_vars" / host for host_var in host_vars_dir.glob("*.json"): shutil.copy(host_var, hiera_data_dir / host_var.name) hiera["hierarchy"].append({ "name": host_var.name.removesuffix(".json"), "path": host_var.name, "data_hash": "json_data", }) hiera_path = build_host_dir / "hiera.yaml" with open(hiera_path, "w") as f: yaml.dump(hiera, f) return hiera_path def build_facts(directory, build_host_dir, host): source_facts_dir = directory / "facts" with open(source_facts_dir / f"{host}.yaml") as f: facts_yaml_content = f.read() dest_facts_dir = build_host_dir / "yaml" / "facts" dest_facts_dir.mkdir(parents=True) with open(dest_facts_dir / f"{host}.yaml", "w") as f: f.write("--- !ruby/object:Puppet::Node::Facts\nvalues:\n") f.write(textwrap.indent(facts_yaml_content, " ")) def compile_catalog(directory, build_dir, modulepath, manifest, output_dir, host): build_host_dir = build_dir / host build_host_dir.mkdir() hiera_path = build_hiera(directory, build_host_dir, host) build_facts(directory, build_host_dir, host) cmd = [ "puppet", "catalog", "compile", f"--modulepath={modulepath}", f"--hiera_config={hiera_path}", f"--manifest={manifest}", "--terminus", "compiler", "--vardir", build_host_dir, "--facts_terminus", "yaml", host ] print(shlex.join(map(str, cmd))) catalog_compile = subprocess.run( cmd, check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, encoding="utf8" ) assert not catalog_compile.stderr, catalog_compile.stderr catalog_stdout = catalog_compile.stdout _, catalog = catalog_stdout.split("\n", 1) host_output_dir = output_dir / host host_output_dir.mkdir() with open(host_output_dir / "catalog.json", "w") as f: f.write(catalog) shutil.copytree(modulepath, host_output_dir / "modules") def up(directory: pathlib.Path, modulepath, manifest, hosts: list[str]): build_dir = directory / "build" build_dir.mkdir() output_dir = build_dir / "output" output_dir.mkdir() def _compile_catalog(host): compile_catalog( directory=directory, build_dir=build_dir, modulepath=modulepath, manifest=manifest, output_dir=output_dir, host=host) # list because exceptions do not happen unless you iterate over the result list(futures.ThreadPoolExecutor().map(_compile_catalog, hosts)) def main(): parser = argparse.ArgumentParser() parser.add_argument("directory") parser.add_argument("modulepath") parser.add_argument("manifest") parser.add_argument("hosts", nargs="+", metavar="host") args = parser.parse_args() up( directory=pathlib.Path(args.directory), modulepath=args.modulepath, manifest=args.manifest, hosts=args.hosts ) if __name__ == "__main__": main()