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
parent
9a203585a0
commit
9d38a8ac01
|
@ -0,0 +1,4 @@
|
|||
*
|
||||
!host_provisioner.py
|
||||
!pyproject.toml
|
||||
!uv.lock
|
|
@ -1,3 +1,4 @@
|
|||
*.egg-info/
|
||||
__pycache__/
|
||||
*.py[co]
|
||||
uv.lock
|
||||
|
|
|
@ -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"]
|
|
@ -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()
|
|
@ -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
|
Loading…
Reference in New Issue