301 lines
11 KiB
Python
Executable file
301 lines
11 KiB
Python
Executable file
#!/usr/bin/env python3
|
|
#
|
|
# This script is responsible for creating/updating /etc/puppet/cosmos-modules.conf.
|
|
#
|
|
# If this script exits without creating that file, a default list of modules will be
|
|
# selected (by post-tasks.d/010cosmos-modules, the script that invokes this script).
|
|
#
|
|
# NOTES ABOUT THE IMPLEMENTATION:
|
|
#
|
|
# - Avoid any third party modules. We want this script to be re-usable in all ops-repos.
|
|
# - To make merging easier, try to keep all local alterations in the local_* functions.
|
|
# - Format with black and isort. Line width 120.
|
|
# - You probably ONLY want to change things in the local_get_modules_hook() function.
|
|
#
|
|
|
|
import argparse
|
|
import csv
|
|
import json
|
|
import logging
|
|
import logging.handlers
|
|
import os
|
|
import re
|
|
import socket
|
|
import sys
|
|
from pathlib import Path
|
|
from typing import Dict, NewType, Optional, cast
|
|
|
|
from pkg_resources import parse_version
|
|
|
|
logger = logging.getLogger(__name__) # will be overwritten by _setup_logging()
|
|
|
|
# Set up types for data that is passed around in functions in this script.
|
|
# Need to use Dict (not dict) here since these aren't stripped by strip-hints, and doesn't work on Ubuntu <= 20.04.
|
|
Arguments = NewType("Arguments", argparse.Namespace)
|
|
OSInfo = Dict[str, str]
|
|
HostInfo = Dict[str, Optional[str]]
|
|
Modules = Dict[str, Dict[str, str]]
|
|
|
|
|
|
def parse_args() -> Arguments:
|
|
"""
|
|
Parse the command line arguments
|
|
"""
|
|
parser = argparse.ArgumentParser(
|
|
description="Setup cosmos-modules.conf",
|
|
add_help=True,
|
|
formatter_class=argparse.ArgumentDefaultsHelpFormatter,
|
|
)
|
|
|
|
parser.add_argument("--debug", dest="debug", action="store_true", default=False, help="Enable debug operation")
|
|
parser.add_argument(
|
|
"--filename", dest="filename", type=str, default="/etc/puppet/cosmos-modules.conf", help="Filename to write to"
|
|
)
|
|
|
|
return cast(Arguments, parser.parse_args())
|
|
|
|
|
|
def get_os_info() -> OSInfo:
|
|
"""Load info about the current OS (distro, release etc.)"""
|
|
os_info: OSInfo = {}
|
|
if Path("/etc/os-release").exists():
|
|
os_info.update({k.lower(): v for k, v in _parse_bash_vars("/etc/os-release").items()})
|
|
res = local_os_info_hook(os_info)
|
|
logger.debug(f"OS info:\n{json.dumps(res, sort_keys=True, indent=4)}")
|
|
return res
|
|
|
|
|
|
def get_host_info() -> HostInfo:
|
|
"""Load info about the current host (hostname, fqdn, domain name etc.)"""
|
|
try:
|
|
fqdn = socket.getfqdn()
|
|
hostname = socket.gethostname()
|
|
except OSError:
|
|
host_info = {}
|
|
else:
|
|
_domainname = fqdn[len(hostname + ".") :]
|
|
|
|
host_info: HostInfo = {
|
|
"domainname": _domainname,
|
|
"fqdn": fqdn,
|
|
"hostname": hostname,
|
|
}
|
|
res = local_host_info_hook(host_info)
|
|
logger.debug(f"Host info: {json.dumps(res, sort_keys=True, indent=4)}")
|
|
return res
|
|
|
|
|
|
def _parse_bash_vars(path: str) -> dict[str, str]:
|
|
"""
|
|
Parses a bash script and returns a dictionary representing the
|
|
variables declared in that script.
|
|
|
|
Source: https://dev.to/htv2012/how-to-parse-bash-variables-b4f
|
|
|
|
:param path: The path to the bash script
|
|
:return: Variables as a dictionary
|
|
"""
|
|
with open(path) as stream:
|
|
contents = stream.read().strip()
|
|
|
|
var_declarations = re.findall(r"^[a-zA-Z0-9_]+=.*$", contents, flags=re.MULTILINE)
|
|
reader = csv.reader(var_declarations, delimiter="=")
|
|
bash_vars = dict(reader)
|
|
return bash_vars
|
|
|
|
|
|
def get_modules(os_info: OSInfo, host_info: HostInfo) -> Modules:
|
|
"""Load the list of default modules.
|
|
|
|
This is more or less an inventory of all the modules we have. If you don't want
|
|
to use all modules in your OPS repo, you can filter them in the local hook.
|
|
|
|
If you want to use a different tag for a module on a specific host/os, you can
|
|
do that in the local hook as well.
|
|
"""
|
|
default_modules = """
|
|
# name repo upgrade tag
|
|
apparmor https://github.com/SUNET/puppet-apparmor.git yes sunet-2*
|
|
apt https://github.com/SUNET/puppetlabs-apt.git yes sunet-2*
|
|
augeas https://github.com/SUNET/puppet-augeas.git yes sunet-2*
|
|
bastion https://github.com/SUNET/puppet-bastion.git yes sunet-2*
|
|
concat https://github.com/SUNET/puppetlabs-concat.git yes sunet-2*
|
|
cosmos https://github.com/SUNET/puppet-cosmos.git yes sunet-2*
|
|
dhcp https://github.com/SUNET/puppetlabs-dhcp.git yes sunet_dev-2*
|
|
docker https://github.com/SUNET/garethr-docker.git yes sunet-2*
|
|
hiera-gpg https://github.com/SUNET/hiera-gpg.git yes sunet-2*
|
|
munin https://github.com/SUNET/ssm-munin.git yes sunet-2*
|
|
nagioscfg https://github.com/SUNET/puppet-nagioscfg.git yes sunet-2*
|
|
network https://github.com/SUNET/attachmentgenie-network.git yes sunet-2*
|
|
pound https://github.com/SUNET/puppet-pound.git yes sunet-2*
|
|
pyff https://github.com/samlbits/puppet-pyff.git yes puppet-pyff-*
|
|
python https://github.com/SUNET/puppet-python.git yes sunet-2*
|
|
stdlib https://github.com/SUNET/puppetlabs-stdlib.git yes sunet-2*
|
|
sunet https://github.com/SUNET/puppet-sunet.git yes sunet-2*
|
|
sysctl https://github.com/SUNET/puppet-sysctl.git yes sunet-2*
|
|
ufw https://github.com/SUNET/puppet-module-ufw.git yes sunet-2*
|
|
varnish https://github.com/samlbits/puppet-varnish.git yes puppet-varnish-*
|
|
vcsrepo https://github.com/SUNET/puppetlabs-vcsrepo.git yes sunet-2*
|
|
xinetd https://github.com/SUNET/puppetlabs-xinetd.git yes sunet-2*
|
|
"""
|
|
modules: Modules = {}
|
|
for line in default_modules.splitlines():
|
|
try:
|
|
if not line.strip() or line.strip().startswith("#"):
|
|
continue
|
|
_name, _url, _upgrade, _tag = line.split()
|
|
modules[_name] = {
|
|
"repo": _url,
|
|
"upgrade": _upgrade,
|
|
"tag": _tag,
|
|
}
|
|
except ValueError:
|
|
logger.error(f"Failed to parse line: {repr(line)}")
|
|
raise
|
|
|
|
# Remove the UFW module on Ubuntu >= 22.04 (nftables is used there instead)
|
|
if os_info.get("name") == "Ubuntu":
|
|
ver = os_info.get("version_id")
|
|
if ver:
|
|
if parse_version(ver) >= parse_version("22.04"):
|
|
logger.debug("Removing UFW module for Ubuntu >= 22.04")
|
|
del modules["ufw"]
|
|
else:
|
|
logger.debug("Keeping UFW module for Ubuntu < 22.04")
|
|
else:
|
|
logger.debug("Unknown Ubuntu module version, keeping UFW module")
|
|
|
|
return local_get_modules_hook(os_info, host_info, modules)
|
|
|
|
|
|
def local_os_info_hook(os_info: OSInfo) -> OSInfo:
|
|
"""Local hook to modify os_info in an OPS repo."""
|
|
# Start local changes in this repository
|
|
# End local changes
|
|
return os_info
|
|
|
|
|
|
def local_host_info_hook(host_info: HostInfo) -> HostInfo:
|
|
"""Local hook to modify host_info in an OPS repo."""
|
|
# Start local changes in this repository
|
|
|
|
# Regular expression to tease apart an eduID hostname
|
|
hostname_re = re.compile(
|
|
r"""^
|
|
(\w+) # function ('idp', 'apps', ...)
|
|
-
|
|
(\w+) # site ('tug', 'sthb', ...)
|
|
-
|
|
(\d+) # 1 for staging, 3 for production
|
|
""",
|
|
re.VERBOSE,
|
|
)
|
|
_hostname = host_info.get("hostname")
|
|
if _hostname:
|
|
m = hostname_re.match(_hostname)
|
|
if m:
|
|
_function, _site, _num = m.groups()
|
|
host_info["function"] = _function
|
|
host_info["site"] = _site
|
|
if _num == "1":
|
|
host_info["environment"] = "staging"
|
|
|
|
# End local changes
|
|
return host_info
|
|
|
|
|
|
def local_get_modules_hook(os_info: OSInfo, host_info: HostInfo, modules: Modules) -> Modules:
|
|
"""Local hook to modify default set of modules in an OPS repo."""
|
|
# Start local changes in this repository
|
|
|
|
_eduid_modules = {
|
|
"apparmor",
|
|
"apt",
|
|
"augeas",
|
|
"bastion",
|
|
"concat",
|
|
"docker",
|
|
"munin",
|
|
"stdlib",
|
|
"sunet",
|
|
"ufw",
|
|
}
|
|
# Only keep the modules eduID actually uses
|
|
modules = {k: v for k, v in modules.items() if k in _eduid_modules}
|
|
logger.debug(f"Adding modules: {json.dumps(modules, sort_keys=True, indent=4)}")
|
|
|
|
# Use eduID tag for puppet-sunet
|
|
modules["sunet"]["tag"] = "eduid-stable-2*"
|
|
if host_info.get("environment") == "staging":
|
|
modules["sunet"]["tag"] = "eduid_dev-2*"
|
|
|
|
# use sunet_dev-2* for some modules in staging
|
|
for dev_module in ["munin"]:
|
|
if host_info.get("environment") == "staging" and dev_module in modules:
|
|
modules[dev_module]["tag"] = "sunet_dev-2*"
|
|
|
|
# End local changes
|
|
return modules
|
|
|
|
|
|
def update_cosmos_modules(filename: str, modules: Modules) -> None:
|
|
"""Create/update the cosmos-modules.conf file.
|
|
|
|
First, we check if the file already have the right content. If so, we do nothing.
|
|
"""
|
|
content = "# This file is automatically generated by the setup_cosmos_modules script.\n# Do not edit it manually.\n"
|
|
for k, v in sorted(modules.items()):
|
|
content += f"{k:15} {v['repo']:55} {v['upgrade']:5} {v['tag']}\n"
|
|
_file = Path(filename)
|
|
if _file.exists():
|
|
# Check if the content is already correct, and avoid updating the file if so (so that the timestamp
|
|
# of the file at least indicates when the content was last updated)
|
|
with _file.open("r") as f:
|
|
current = f.read()
|
|
if current == content:
|
|
logger.debug(f"{filename} is up to date")
|
|
return
|
|
|
|
# Create/update the file by writing the content to a temporary file and then renaming it
|
|
_tmp_file = _file.with_suffix(".tmp")
|
|
with _tmp_file.open("w") as f:
|
|
f.write(content)
|
|
_tmp_file.rename(_file)
|
|
logger.debug(f"Updated {filename}")
|
|
|
|
|
|
def _setup_logging(my_name: str, args: Arguments):
|
|
level = logging.INFO
|
|
if args.debug:
|
|
level = logging.DEBUG
|
|
logging.basicConfig(level=level, stream=sys.stderr, format="{asctime} | {levelname:7} | {message}", style="{")
|
|
global logger
|
|
logger = logging.getLogger(my_name)
|
|
# If stderr is not a TTY, change the log level of the StreamHandler (stream = sys.stderr above) to ERROR
|
|
if not sys.stderr.isatty() and not args.debug:
|
|
for this_h in logging.getLogger("").handlers:
|
|
this_h.setLevel(logging.ERROR)
|
|
if args.debug:
|
|
logger.setLevel(logging.DEBUG)
|
|
|
|
|
|
def main(my_name: str, args: Arguments) -> bool:
|
|
_setup_logging(my_name, args)
|
|
|
|
os_info = get_os_info()
|
|
host_info = get_host_info()
|
|
modules = get_modules(os_info, host_info)
|
|
|
|
update_cosmos_modules(args.filename, modules)
|
|
|
|
return True
|
|
|
|
|
|
if __name__ == "__main__":
|
|
my_name = os.path.basename(sys.argv[0])
|
|
args = parse_args()
|
|
res = main(my_name, args=args)
|
|
if res:
|
|
sys.exit(0)
|
|
sys.exit(1)
|