1
0
Fork 0

host/online: Start Ansible job to provision host

The _POST /host/online_ webhook now creates a Kubernetes Job to run the
host provisioner.  The Job resource is defined in a YAML document, and
will be created in the Kubernetes namespace specified by the
`ANSIBLE_JOB_NAMESPACE` environment variable (defaults to `ansible`).
master
Dustin 2025-02-03 19:38:42 -06:00
parent 361f334908
commit 34dbcdece6
2 changed files with 79 additions and 12 deletions

View File

@ -7,8 +7,9 @@ import json
import logging
import os
import re
from pathlib import Path
from types import TracebackType
from typing import Optional, Self, Type
from typing import Any, Optional, Self, Type
import fastapi
import httpx
@ -16,6 +17,7 @@ import pika
import pika.channel
import pydantic
import pyrfc6266
import ruamel.yaml
from fastapi import Form
from pika.adapters.asyncio_connection import AsyncioConnection
@ -60,6 +62,21 @@ PAPERLESS_URL = os.environ.get(
'http://paperless-ngx',
)
ANSIBLE_JOB_YAML = Path(os.environ.get('ANSIBLE_JOB_YAML', 'ansible-job.yaml'))
ANSIBLE_JOB_NAMESPACE = os.environ.get('ANSIBLE_JOB_NAMESPACE', 'ansible')
KUBERNETES_TOKEN_PATH = Path(
os.environ.get(
'KUBERNETES_TOKEN_PATH',
'/run/secrets/kubernetes.io/serviceaccount/token',
)
)
KUBERNETES_CA_CERT = Path(
os.environ.get(
'KUBERNETES_CA_CERT',
'/run/secrets/kubernetes.io/serviceaccount/ca.crt',
)
)
class FireflyIIITransactionSplit(pydantic.BaseModel):
type: str
@ -112,17 +129,15 @@ class HttpxClientMixin:
self._client = self._get_client()
return self._client
def _get_client(self) -> httpx.AsyncClient:
return httpx.AsyncClient(
headers={
'User-Agent': f'{DIST["Name"]}/{DIST["Version"]}',
},
)
def _get_client(self, **kwargs) -> httpx.AsyncClient:
headers = kwargs.setdefault('headers', {})
headers['User-Agent'] = f'{DIST["Name"]}/{DIST["Version"]}'
return httpx.AsyncClient(**kwargs)
class Firefly(HttpxClientMixin):
def _get_client(self) -> httpx.AsyncClient:
client = super()._get_client()
def _get_client(self, **kwargs) -> httpx.AsyncClient:
client = super()._get_client(**kwargs)
if token_file := os.environ.get('FIREFLY_AUTH_TOKEN'):
try:
f = open(token_file, encoding='utf-8')
@ -166,8 +181,8 @@ class Firefly(HttpxClientMixin):
class Paperless(HttpxClientMixin):
def _get_client(self) -> httpx.AsyncClient:
client = super()._get_client()
def _get_client(self, **kwargs) -> httpx.AsyncClient:
client = super()._get_client(**kwargs)
if token_file := os.environ.get('PAPERLESS_AUTH_TOKEN'):
try:
f = open(token_file, encoding='utf-8')
@ -374,6 +389,34 @@ class AMQPContext:
self._channel = None
class Kubernetes(HttpxClientMixin):
@functools.cached_property
def base_url(self) -> str:
https = True
port = os.environ.get('KUBERNETES_SERVICE_PORT_HTTPS')
if not port:
https = False
port = os.environ.get('KUBERNETES_SERVICE_PORT', 8001)
host = os.environ.get('KUBERNETES_SERVICE_HOST', '127.0.0.1')
url = f'{"https" if https else "http"}://{host}:{port}'
log.info('Using Kubernetes URL: %s', url)
return url
@functools.cached_property
def token(self) -> str:
return KUBERNETES_TOKEN_PATH.read_text().strip()
def _get_client(self, **kwargs) -> httpx.AsyncClient:
if KUBERNETES_CA_CERT.exists():
kwargs.setdefault('verify', str(KUBERNETES_CA_CERT))
client = super()._get_client(**kwargs)
try:
client.headers['Authorization'] = f'Bearer {self.token}'
except (OSError, UnicodeDecodeError) as e:
log.warning('Failed to read k8s auth token: %s', e)
return client
class Context:
def __init__(self):
@ -500,7 +543,24 @@ def rfc2047_base64encode(
return f"=?UTF-8?B?{encoded}?="
async def start_ansible_job(): ...
def load_job_yaml(path: Optional[Path] = None) -> dict[str, Any]:
if path is None:
path = ANSIBLE_JOB_YAML
yaml = ruamel.yaml.YAML()
with path.open(encoding='utf-8') as f:
return yaml.load(f)
async def start_ansible_job():
async with Kubernetes() as kube:
url = (
f'{kube.base_url}/apis/batch/v1/namespaces/'
f'{ANSIBLE_JOB_NAMESPACE}/jobs'
)
job = load_job_yaml()
r = await kube.client.post(url, json=job)
if r.status_code > 299:
raise Exception(r.read())
async def publish_host_info(hostname: str, sshkeys: str):
@ -526,6 +586,12 @@ async def handle_host_online(hostname: str, sshkeys: str):
except Exception:
log.exception('Failed to publish host info:')
return
try:
await start_ansible_job()
except asyncio.CancelledError:
raise
except Exception:
log.exception('Failed to start Ansible job:')
app = fastapi.FastAPI(

View File

@ -16,6 +16,7 @@ dependencies = [
"pika>=1.3.2",
"pyrfc6266~=1.0.2",
"python-multipart>=0.0.20",
"ruamel-yaml>=0.18.10",
]
dynamic = ["version"]