From 9d38a8ac01fd1d991b4fd23962d7598477ac3097 Mon Sep 17 00:00:00 2001 From: "Dustin C. Hatch" Date: Fri, 7 Feb 2025 19:00:15 -0600 Subject: [PATCH] Begin host provisioner script The _Host Provisioner_ is a tool that runs an Anisble playbook to initially provision a new machine. It is intended to run as a Kubernetes Job, created by a webhook that is triggered when the machine boots up for the first time. The tool retrieves information about the new machine (its hostname and SSH host keys) from a message queue, published by the same webhook that launched the job. It then clones the configuration policy (optionally from the branch provided in the host info message) and applies the `site.yml` playbook. --- .containerignore | 4 + .gitignore | 1 + Containerfile | 30 +++++++ host_provisioner.py | 201 ++++++++++++++++++++++++++++++++++++++++++++ pyproject.toml | 42 +++++++++ 5 files changed, 278 insertions(+) create mode 100644 .containerignore create mode 100644 Containerfile create mode 100644 host_provisioner.py create mode 100644 pyproject.toml diff --git a/.containerignore b/.containerignore new file mode 100644 index 0000000..da7e246 --- /dev/null +++ b/.containerignore @@ -0,0 +1,4 @@ +* +!host_provisioner.py +!pyproject.toml +!uv.lock diff --git a/.gitignore b/.gitignore index 14c71f0..0a3da27 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ *.egg-info/ __pycache__/ *.py[co] +uv.lock diff --git a/Containerfile b/Containerfile new file mode 100644 index 0000000..3cf9552 --- /dev/null +++ b/Containerfile @@ -0,0 +1,30 @@ +FROM registry.fedoraproject.org/fedora-minimal AS build + +RUN --mount=type=cache,target=/var/cache \ + dnf install -y \ + --setopt persistdir=/var/cache/dnf \ + --setopt install_weak_deps=0 \ + python3 \ + uv \ + && : + +WORKDIR /build + +COPY . . + +ENV UV_PROJECT_ENVIRONMENT=/app +ENV UV_CACHE_DIR=/var/cache/uv +ENV UV_LINK_MODE=copy + +RUN --mount=type=cache,target=/var/cache \ + uv sync --no-editable --no-dev + + +FROM git.pyrocufflink.net/containerimages/ansible + +COPY --from=build /app /app + +ENV PATH=/app/bin:/usr/bin +ENV ANSIBLE_STDOUT_CALLBACK=oneline + +ENTRYPOINT ["hostprovision"] diff --git a/host_provisioner.py b/host_provisioner.py new file mode 100644 index 0000000..0dbd65d --- /dev/null +++ b/host_provisioner.py @@ -0,0 +1,201 @@ +#!/usr/bin/env python3 +import json +import logging +import os +import subprocess +import ssl +import tempfile +from pathlib import Path + +import colorlog +import pika +import pika.credentials +import pika.exceptions +import pydantic + + +log = logging.getLogger('hostprovision') + + +CONFIGPOLICY = 'https://git.pyrocufflink.net/dustin/configpolicy.git' + +HOST_INFO_QUEUE = os.environ.get('HOST_INFO_QUEUE', 'host-provisioner') +QUEUE_TIMEOUT = os.environ.get('QUEUE_TIMEOUT', 10) + + +class HostInfo(pydantic.BaseModel): + hostname: str + sshkeys: str + branch: str = 'master' + + +def amqp_connect() -> pika.BlockingConnection: + if 'AMQP_URL' in os.environ: + params = pika.URLParameters(os.environ['AMQP_URL']) + else: + kwargs = {} + if host := os.environ.get('AMQP_HOST'): + kwargs['host'] = host + if port := os.environ.get('AMQP_PORT'): + kwargs['port'] = int(port) + if vhost := os.environ.get('AMQP_VIRTUAL_HOST'): + kwargs['virtual_host'] = vhost + if username := os.environ.get('AMQP_USERNAME'): + password = os.environ.get('AMQP_PASSWORD', '') + kwargs['credentials'] = pika.PlainCredentials(username, password) + elif os.environ.get('AMQP_EXTERNAL_CREDENTIALS'): + kwargs['credentials'] = pika.credentials.ExternalCredentials() + if ( + 'AMQP_CA_CERT' in os.environ + or 'AMQP_CLIENT_CERT' in os.environ + or 'AMQP_CLIENT_KEY' in os.environ + ): + sslctx = ssl.create_default_context( + cafile=os.environ.get('AMQP_CA_CERT') + ) + if certfile := os.environ.get('AMQP_CLIENT_CERT'): + keyfile = os.environ.get('AMQP_CLIENT_KEY') + keypassword = os.environ.get('AMQP_CLIENT_KEY_PASSWORD') + sslctx.load_cert_chain(certfile, keyfile, keypassword) + kwargs['ssl_options'] = pika.SSLOptions(sslctx, kwargs.get('host')) + params = pika.ConnectionParameters(**kwargs) + return pika.BlockingConnection(params) + + +def apply_playbook(*args: str) -> None: + cmd = ['ansible-playbook', '-u', 'root'] + cmd += args + log.debug('Running command: %s', cmd) + subprocess.run(cmd, check=True, stdin=subprocess.DEVNULL) + + +def clone_configpolicy(branch: str) -> None: + cmd = [ + 'git', + 'clone', + '--depth=1', + CONFIGPOLICY, + '-b', + branch, + '.', + ] + log.info( + 'Cloning configuration policy from %s into %s', + CONFIGPOLICY, + os.getcwd(), + ) + subprocess.run(cmd, check=True, stdin=subprocess.DEVNULL) + cmd = [ + 'git', + 'submodule', + 'update', + '--remote', + '--init', + '--depth=1', + ] + log.info('Updating Git submodules') + subprocess.run(cmd, check=True) + + +def get_host_info() -> HostInfo | None: + log.debug('Connecting to AMQP broker') + conn = amqp_connect() + log.info('Successfully connected to AMQP broker') + chan = conn.channel() + + # Tell the broker to only send a single message + chan.basic_qos(prefetch_count=1) + + try: + timeout = int(QUEUE_TIMEOUT) + except ValueError: + timeout = 10 + + log.debug('Waiting for host info message (timeout: %d seconds)', timeout) + with chan, conn: + for method, properties, body in chan.consume( + HOST_INFO_QUEUE, + inactivity_timeout=timeout, + ): + if method is None: + break + try: + data = json.loads(body) + host_info = HostInfo.model_validate(data) + except ValueError as e: + log.error('Failed to parse host info message: %s', e) + chan.basic_reject(method.delivery_tag) + return None + else: + chan.basic_ack(method.delivery_tag) + return host_info + + +def write_ssh_keys(hostname: str, ssh_keys: str) -> None: + known_hosts = Path('~/.ssh/known_hosts').expanduser() + log.info('Writing SSH host keys for %s to %s', hostname, known_hosts) + if not known_hosts.parent.is_dir(): + known_hosts.parent.mkdir(parents=True) + with known_hosts.open('a', encoding='utf-8') as f: + for line in ssh_keys.splitlines(): + line = line.strip() + if not line: + continue + if not line.startswith(('ssh-', 'ecdsa-')): + log.warning('Ignoring invalid SSH key: %s', line) + continue + f.write(f'{hostname} {line}\n') + + +def main(): + log.setLevel(logging.DEBUG) + logging.getLogger('pika').setLevel(logging.WARNING) + logging.getLogger('pika.adapters').setLevel(logging.CRITICAL) + logging.root.setLevel(logging.INFO) + handler = logging.StreamHandler() + log_colors = dict(colorlog.default_log_colors) + log_colors['DEBUG'] = 'blue' + handler.setFormatter( + colorlog.ColoredFormatter( + '%(log_color)s%(levelname)8s%(reset)s' + ' %(light_black)s%(name)s:%(reset)s %(message)s', + log_colors=log_colors, + ) + ) + handler.setLevel(logging.DEBUG) + logging.root.addHandler(handler) + + try: + host_info = get_host_info() + except (OSError, pika.exceptions.AMQPConnectionError) as e: + log.error('Failed to connect to message broker: %s', e) + raise SystemExit(1) + except pika.exceptions.ChannelClosed as e: + log.error( + 'Failed to get host info from message queue: %s (code %s)', + e.reply_text, + e.reply_code, + ) + raise SystemExit(1) + if not host_info: + log.error('No host info received from queue') + raise SystemExit(1) + + log.info('Provisioning host %s', host_info.hostname) + try: + if host_info.sshkeys: + write_ssh_keys(host_info.hostname, host_info.sshkeys) + with tempfile.TemporaryDirectory(prefix='host-provision.') as d: + log.debug('Using working directory %s', d) + os.chdir(d) + clone_configpolicy(host_info.branch) + apply_playbook('site.yml', '-l', host_info.hostname) + except Exception as e: + log.error('Provisioning failed: %s', e) + raise SystemExit(1) + else: + log.info('Successfully provisioned host %s', host_info.hostname) + + +if __name__ == '__main__': + main() diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..9c03f90 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,42 @@ +[project] +name = 'host-provisioner' +authors = [ + {name = 'Dustin C. Hatch', email = 'dustin@hatch.name'}, +] +description = 'Host provisioner tool' +requires-python = '>=3.12' +license = {text = '0BSD'} +classifiers = [ + 'License :: OSI Approved :: Zero-Clause BSD (0BSD)', + 'Programming Language :: Python :: 3', +] +dependencies = [ + "colorlog>=6.9.0", + "pika>=1.3.2", + "pydantic>=2.10.6", +] +dynamic = ['version'] + +[project.scripts] +hostprovision = 'host_provisioner:main' + +[build-system] +requires = ['setuptools', 'setuptools-scm'] +build-backend = 'setuptools.build_meta' + +[tool.pyright] +venvPath = '.' +venv = '.venv' + +[tool.black] +line-length = 79 +skip-string-normalization = true + +[tool.isort] +line_length = 79 +ensure_newline_before_comments = true +force_grid_wrap = 0 +include_trailing_comma = true +lines_after_imports = 2 +multi_line_output = 3 +use_parentheses = true