312 lines
10 KiB
Python
312 lines
10 KiB
Python
"""Deterministic configuration rendering for the Kubernetes VM lab."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import base64
|
|
import json
|
|
from pathlib import Path
|
|
from typing import Mapping
|
|
|
|
from .config import LabConfig, LabNode
|
|
|
|
|
|
def render_cloud_init(config: LabConfig, node: LabNode, public_key: str) -> str:
|
|
packages = ["ca-certificates", "curl", "qemu-guest-agent"]
|
|
if node.role == "state":
|
|
packages.extend(["docker.io", "docker-compose-v2", "openssl"])
|
|
package_lines = "\n".join(f" - {item}" for item in packages)
|
|
return f"""#cloud-config
|
|
hostname: {node.name}
|
|
manage_etc_hosts: true
|
|
package_update: true
|
|
package_upgrade: false
|
|
packages:
|
|
{package_lines}
|
|
users:
|
|
- default
|
|
- name: {config.ssh_user}
|
|
groups: [adm, sudo]
|
|
shell: /bin/bash
|
|
sudo: ALL=(ALL) NOPASSWD:ALL
|
|
lock_passwd: true
|
|
ssh_authorized_keys:
|
|
- {json.dumps(public_key.strip())}
|
|
ssh_pwauth: false
|
|
disable_root: true
|
|
runcmd:
|
|
- [systemctl, enable, --now, qemu-guest-agent]
|
|
- [sh, -c, "test ! -e /usr/bin/docker || systemctl enable --now docker"]
|
|
final_message: "GovOPlaN lab node is ready"
|
|
"""
|
|
|
|
|
|
def render_network_config(config: LabConfig, node: LabNode) -> str:
|
|
dns = ", ".join(str(item) for item in config.network.dns_servers)
|
|
return f"""version: 2
|
|
ethernets:
|
|
primary:
|
|
match:
|
|
macaddress: {node.mac_address}
|
|
set-name: eth0
|
|
addresses:
|
|
- {node.address}/{config.network.prefix_length}
|
|
routes:
|
|
- to: default
|
|
via: {config.network.gateway}
|
|
nameservers:
|
|
addresses: [{dns}]
|
|
"""
|
|
|
|
|
|
def render_meta_data(config: LabConfig, node: LabNode) -> str:
|
|
return f"instance-id: {config.name}-{node.name}\nlocal-hostname: {node.name}\n"
|
|
|
|
|
|
def render_k3s_config(
|
|
config: LabConfig,
|
|
node: LabNode,
|
|
*,
|
|
cluster_token: str,
|
|
) -> str:
|
|
lines = [
|
|
f'node-name: "{node.name}"',
|
|
f'node-ip: "{node.address}"',
|
|
f'token: "{cluster_token}"',
|
|
]
|
|
if node.role == "control":
|
|
if node == config.primary_control:
|
|
lines.append("cluster-init: true")
|
|
else:
|
|
lines.append(f'server: "https://{config.primary_control.address}:6443"')
|
|
lines.extend(
|
|
[
|
|
'write-kubeconfig-mode: "0600"',
|
|
"secrets-encryption: true",
|
|
"tls-san:",
|
|
f' - "{config.primary_control.address}"',
|
|
"node-taint:",
|
|
' - "node-role.kubernetes.io/control-plane=true:NoSchedule"',
|
|
]
|
|
)
|
|
elif node.role == "worker":
|
|
lines.extend(
|
|
[
|
|
f'server: "https://{config.primary_control.address}:6443"',
|
|
"node-label:",
|
|
' - "govoplan.add-ideas.de/runtime=true"',
|
|
f' - "topology.govoplan.add-ideas.de/failure-domain={node.failure_domain}"',
|
|
]
|
|
)
|
|
else:
|
|
raise ValueError("state nodes do not receive K3s configuration")
|
|
return "\n".join(lines) + "\n"
|
|
|
|
|
|
def render_registry_config(username: str, password: str) -> str:
|
|
if not username and not password:
|
|
return ""
|
|
if not username or not password:
|
|
raise ValueError("registry username and password must be supplied together")
|
|
return (
|
|
'mirrors:\n "git.add-ideas.de":\n'
|
|
' endpoint:\n - "https://git.add-ideas.de"\n'
|
|
'configs:\n "git.add-ideas.de":\n auth:\n'
|
|
f" username: {json.dumps(username)}\n"
|
|
f" password: {json.dumps(password)}\n"
|
|
)
|
|
|
|
|
|
def render_garage_config() -> str:
|
|
return """metadata_dir = "/var/lib/garage/meta"
|
|
data_dir = "/var/lib/garage/data"
|
|
db_engine = "sqlite"
|
|
|
|
replication_factor = 1
|
|
|
|
rpc_bind_addr = "[::]:3901"
|
|
rpc_public_addr = "127.0.0.1:3901"
|
|
|
|
[s3_api]
|
|
s3_region = "garage"
|
|
api_bind_addr = "[::]:3900"
|
|
root_domain = ".s3.garage.localhost"
|
|
|
|
[admin]
|
|
api_bind_addr = "[::]:3903"
|
|
"""
|
|
|
|
|
|
def render_caddyfile() -> str:
|
|
return """:9443 {
|
|
tls /etc/caddy/tls/server.crt /etc/caddy/tls/server.key
|
|
reverse_proxy garage:3900
|
|
}
|
|
"""
|
|
|
|
|
|
def render_state_compose(images: Mapping[str, str]) -> str:
|
|
required = {"postgres", "redis", "garage", "managed_ingress", "test_mail"}
|
|
missing = sorted(required - set(images))
|
|
if missing:
|
|
raise ValueError("release manifest is missing state images: " + ", ".join(missing))
|
|
value = {
|
|
"name": "govoplan-lab-state",
|
|
"services": {
|
|
"postgres": {
|
|
"image": images["postgres"],
|
|
"restart": "unless-stopped",
|
|
"environment": {
|
|
"POSTGRES_DB": "${POSTGRES_DB}",
|
|
"POSTGRES_USER": "${POSTGRES_USER}",
|
|
"POSTGRES_PASSWORD": "${POSTGRES_PASSWORD}",
|
|
},
|
|
"ports": ["5432:5432"],
|
|
"healthcheck": {
|
|
"test": [
|
|
"CMD-SHELL",
|
|
'pg_isready -U "$${POSTGRES_USER}" -d "$${POSTGRES_DB}"',
|
|
],
|
|
"interval": "5s",
|
|
"timeout": "3s",
|
|
"retries": 30,
|
|
},
|
|
"volumes": ["postgres-data:/var/lib/postgresql/data"],
|
|
"networks": ["internal"],
|
|
},
|
|
"redis": {
|
|
"image": images["redis"],
|
|
"restart": "unless-stopped",
|
|
"command": [
|
|
"sh",
|
|
"-ec",
|
|
'exec redis-server --appendonly yes --requirepass "$$REDIS_PASSWORD"',
|
|
],
|
|
"environment": {"REDIS_PASSWORD": "${REDIS_PASSWORD}"},
|
|
"ports": ["6379:6379"],
|
|
"healthcheck": {
|
|
"test": [
|
|
"CMD-SHELL",
|
|
'redis-cli -a "$${REDIS_PASSWORD}" --no-auth-warning ping',
|
|
],
|
|
"interval": "5s",
|
|
"timeout": "3s",
|
|
"retries": 30,
|
|
},
|
|
"volumes": ["redis-data:/data"],
|
|
"networks": ["internal"],
|
|
},
|
|
"garage": {
|
|
"image": images["garage"],
|
|
"restart": "unless-stopped",
|
|
"command": ["/garage", "server", "--single-node", "--default-bucket"],
|
|
"environment": {
|
|
"GARAGE_DEFAULT_ACCESS_KEY": "${GARAGE_DEFAULT_ACCESS_KEY}",
|
|
"GARAGE_DEFAULT_SECRET_KEY": "${GARAGE_DEFAULT_SECRET_KEY}",
|
|
"GARAGE_DEFAULT_BUCKET": "${GARAGE_DEFAULT_BUCKET}",
|
|
"GARAGE_RPC_SECRET": "${GARAGE_RPC_SECRET}",
|
|
"GARAGE_ADMIN_TOKEN": "${GARAGE_ADMIN_TOKEN}",
|
|
"GARAGE_METRICS_TOKEN": "${GARAGE_METRICS_TOKEN}",
|
|
},
|
|
"healthcheck": {
|
|
"test": ["CMD", "/garage", "status"],
|
|
"interval": "10s",
|
|
"timeout": "5s",
|
|
"retries": 30,
|
|
"start_period": "15s",
|
|
},
|
|
"security_opt": ["no-new-privileges:true"],
|
|
"volumes": [
|
|
"./garage.toml:/etc/garage.toml:ro",
|
|
"garage-meta:/var/lib/garage/meta",
|
|
"garage-data:/var/lib/garage/data",
|
|
],
|
|
"networks": ["internal"],
|
|
},
|
|
"s3-tls": {
|
|
"image": images["managed_ingress"],
|
|
"restart": "unless-stopped",
|
|
"depends_on": {"garage": {"condition": "service_healthy"}},
|
|
"ports": ["9443:9443"],
|
|
"volumes": [
|
|
"./Caddyfile:/etc/caddy/Caddyfile:ro",
|
|
"./tls:/etc/caddy/tls:ro",
|
|
],
|
|
"security_opt": ["no-new-privileges:true"],
|
|
"networks": ["internal"],
|
|
},
|
|
"test-mail": {
|
|
"image": images["test_mail"],
|
|
"restart": "unless-stopped",
|
|
"environment": {
|
|
"GREENMAIL_OPTS": (
|
|
"-Dgreenmail.setup.test.smtp -Dgreenmail.setup.test.imap "
|
|
"-Dgreenmail.hostname=0.0.0.0"
|
|
)
|
|
},
|
|
"ports": ["3025:3025", "3143:3143"],
|
|
"networks": ["internal"],
|
|
},
|
|
},
|
|
"volumes": {
|
|
"postgres-data": {},
|
|
"redis-data": {},
|
|
"garage-meta": {},
|
|
"garage-data": {},
|
|
},
|
|
"networks": {"internal": {"driver": "bridge"}},
|
|
}
|
|
return json.dumps(value, indent=2, sort_keys=True) + "\n"
|
|
|
|
|
|
def render_state_environment(values: Mapping[str, str]) -> str:
|
|
return "".join(f"{key}={_env_quote(value)}\n" for key, value in sorted(values.items()))
|
|
|
|
|
|
def render_secret_manifest(
|
|
*, namespace: str, name: str, values: Mapping[str, bytes | str]
|
|
) -> bytes:
|
|
encoded = {
|
|
key: base64.b64encode(value.encode() if isinstance(value, str) else value).decode()
|
|
for key, value in sorted(values.items())
|
|
}
|
|
payload = {
|
|
"apiVersion": "v1",
|
|
"kind": "Secret",
|
|
"metadata": {"name": name, "namespace": namespace},
|
|
"type": "Opaque",
|
|
"data": encoded,
|
|
}
|
|
return (json.dumps(payload, indent=2, sort_keys=True) + "\n").encode()
|
|
|
|
|
|
def render_tls_secret_manifest(
|
|
*, namespace: str, name: str, certificate: bytes, private_key: bytes
|
|
) -> bytes:
|
|
payload = json.loads(
|
|
render_secret_manifest(
|
|
namespace=namespace,
|
|
name=name,
|
|
values={"tls.crt": certificate, "tls.key": private_key},
|
|
)
|
|
)
|
|
payload["type"] = "kubernetes.io/tls"
|
|
return (json.dumps(payload, indent=2, sort_keys=True) + "\n").encode()
|
|
|
|
|
|
def render_hosts(config: LabConfig) -> str:
|
|
return (
|
|
f"{config.primary_control.address} {config.public_host}\n"
|
|
f"{config.state_node.address} {config.s3_host}\n"
|
|
)
|
|
|
|
|
|
def _env_quote(value: str) -> str:
|
|
return json.dumps(value, ensure_ascii=True)
|
|
|
|
|
|
def write_private(path: Path, value: str | bytes) -> None:
|
|
path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
data = value.encode() if isinstance(value, str) else value
|
|
path.write_bytes(data)
|
|
path.chmod(0o600)
|