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.
master
Dustin 2025-02-07 19:00:15 -06:00
parent 9a203585a0
commit 9d38a8ac01
5 changed files with 278 additions and 0 deletions

4
.containerignore Normal file
View File

@ -0,0 +1,4 @@
*
!host_provisioner.py
!pyproject.toml
!uv.lock

1
.gitignore vendored
View File

@ -1,3 +1,4 @@
*.egg-info/
__pycache__/
*.py[co]
uv.lock

30
Containerfile Normal file
View File

@ -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"]

201
host_provisioner.py Normal file
View File

@ -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()

42
pyproject.toml Normal file
View File

@ -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