mirror of
https://github.com/jupyterhub/the-littlest-jupyterhub.git
synced 2025-12-18 21:54:05 +08:00
199 lines
6.6 KiB
Python
199 lines
6.6 KiB
Python
"""Traefik installation and setup"""
|
|
import hashlib
|
|
import io
|
|
import logging
|
|
import os
|
|
import tarfile
|
|
from glob import glob
|
|
from pathlib import Path
|
|
from subprocess import run
|
|
|
|
import backoff
|
|
import requests
|
|
import toml
|
|
from jinja2 import Template
|
|
|
|
from tljh.configurer import _merge_dictionaries, load_config
|
|
|
|
from .config import CONFIG_DIR
|
|
|
|
logger = logging.getLogger("tljh")
|
|
|
|
machine = os.uname().machine
|
|
if machine == "aarch64":
|
|
plat = "linux_arm64"
|
|
elif machine == "x86_64":
|
|
plat = "linux_amd64"
|
|
else:
|
|
plat = None
|
|
|
|
# Traefik releases: https://github.com/traefik/traefik/releases
|
|
traefik_version = "2.10.1"
|
|
|
|
# record sha256 hashes for supported platforms here
|
|
# checksums are published in the checksums.txt of each release
|
|
checksums = {
|
|
"linux_amd64": "8d9bce0e6a5bf40b5399dbb1d5e3e5c57b9f9f04dd56a2dd57cb0713130bc824",
|
|
"linux_arm64": "260a574105e44901f8c9c562055936d81fbd9c96a21daaa575502dc69bfe390a",
|
|
}
|
|
|
|
_tljh_path = Path(__file__).parent.resolve()
|
|
|
|
|
|
def checksum_file(path_or_file):
|
|
"""Compute the sha256 checksum of a path"""
|
|
hasher = hashlib.sha256()
|
|
if hasattr(path_or_file, "read"):
|
|
f = path_or_file
|
|
else:
|
|
f = open(path_or_file, "rb")
|
|
with f:
|
|
for chunk in iter(lambda: f.read(4096), b""):
|
|
hasher.update(chunk)
|
|
return hasher.hexdigest()
|
|
|
|
|
|
def fatal_error(e):
|
|
# Retry only when connection is reset or we think we didn't download entire file
|
|
return str(e) != "ContentTooShort" and not isinstance(e, ConnectionResetError)
|
|
|
|
|
|
def check_traefik_version(traefik_bin):
|
|
"""Check the traefik version from `traefik version` output"""
|
|
|
|
try:
|
|
version_out = run(
|
|
[traefik_bin, "version"],
|
|
capture_output=True,
|
|
text=True,
|
|
).stdout
|
|
except (FileNotFoundError, OSError) as e:
|
|
logger.debug(f"Failed to get traefik version: {e}")
|
|
return False
|
|
for line in version_out.splitlines():
|
|
before, _, after = line.partition(":")
|
|
key = before.strip()
|
|
if key.lower() == "version":
|
|
version = after.strip()
|
|
if version == traefik_version:
|
|
logger.debug(f"Found {traefik_bin} {version}")
|
|
return True
|
|
else:
|
|
logger.info(
|
|
f"Found {traefik_bin} version {version} != {traefik_version}"
|
|
)
|
|
return False
|
|
|
|
logger.debug(f"Failed to extract traefik version from: {version_out}")
|
|
return False
|
|
|
|
|
|
@backoff.on_exception(backoff.expo, Exception, max_tries=2, giveup=fatal_error)
|
|
def ensure_traefik_binary(prefix):
|
|
"""Download and install the traefik binary to a location identified by a prefix path such as '/opt/tljh/hub/'"""
|
|
if plat is None:
|
|
raise OSError(
|
|
f"Error. Platform: {os.uname().sysname} / {machine} Not supported."
|
|
)
|
|
traefik_bin_dir = os.path.join(prefix, "bin")
|
|
traefik_bin = os.path.join(prefix, "bin", "traefik")
|
|
if os.path.exists(traefik_bin):
|
|
if check_traefik_version(traefik_bin):
|
|
return
|
|
else:
|
|
os.remove(traefik_bin)
|
|
|
|
traefik_url = (
|
|
"https://github.com/traefik/traefik/releases"
|
|
f"/download/v{traefik_version}/traefik_v{traefik_version}_{plat}.tar.gz"
|
|
)
|
|
|
|
logger.info(f"Downloading traefik {traefik_version} from {traefik_url}...")
|
|
# download the file
|
|
response = requests.get(traefik_url)
|
|
response.raise_for_status()
|
|
if response.status_code == 206:
|
|
raise Exception("ContentTooShort")
|
|
|
|
# verify that we got what we expected
|
|
checksum = checksum_file(io.BytesIO(response.content))
|
|
if checksum != checksums[plat]:
|
|
raise OSError(f"Checksum failed {traefik_url}: {checksum} != {checksums[plat]}")
|
|
|
|
with tarfile.open(fileobj=io.BytesIO(response.content)) as tf:
|
|
tf.extract("traefik", path=traefik_bin_dir)
|
|
os.chmod(traefik_bin, 0o755)
|
|
|
|
|
|
def load_extra_config(extra_config_dir):
|
|
extra_configs = sorted(glob(os.path.join(extra_config_dir, "*.toml")))
|
|
# Load the toml list of files into dicts and merge them
|
|
config = toml.load(extra_configs)
|
|
return config
|
|
|
|
|
|
def ensure_traefik_config(state_dir):
|
|
"""Render the traefik.toml config file"""
|
|
traefik_std_config_file = os.path.join(state_dir, "traefik.toml")
|
|
traefik_extra_config_dir = os.path.join(CONFIG_DIR, "traefik_config.d")
|
|
traefik_dynamic_config_dir = os.path.join(state_dir, "rules")
|
|
traefik_dynamic_config_file = os.path.join(
|
|
traefik_dynamic_config_dir, "dynamic.toml"
|
|
)
|
|
|
|
config = load_config()
|
|
config["traefik_dynamic_config_dir"] = traefik_dynamic_config_dir
|
|
|
|
https = config["https"]
|
|
letsencrypt = https["letsencrypt"]
|
|
tls = https["tls"]
|
|
# validate https config
|
|
if https["enabled"]:
|
|
if not tls["cert"] and not letsencrypt["email"]:
|
|
raise ValueError(
|
|
"To enable https, you must set tls.cert+key or letsencrypt.email+domains"
|
|
)
|
|
if (letsencrypt["email"] and not letsencrypt["domains"]) or (
|
|
letsencrypt["domains"] and not letsencrypt["email"]
|
|
):
|
|
raise ValueError("Both email and domains must be set for letsencrypt")
|
|
|
|
with (_tljh_path / "traefik.toml.tpl").open() as f:
|
|
template = Template(f.read())
|
|
std_config = template.render(config)
|
|
|
|
with (_tljh_path / "traefik-dynamic.toml.tpl").open() as f:
|
|
dynamic_template = Template(f.read())
|
|
dynamic_config = dynamic_template.render(config)
|
|
|
|
# Ensure traefik extra static config dir exists and is private
|
|
os.makedirs(traefik_extra_config_dir, mode=0o700, exist_ok=True)
|
|
|
|
# Ensure traefik dynamic config dir exists and is private
|
|
os.makedirs(traefik_dynamic_config_dir, mode=0o700, exist_ok=True)
|
|
|
|
try:
|
|
# Load standard config file merge it with the extra config files into a dict
|
|
extra_config = load_extra_config(traefik_extra_config_dir)
|
|
new_toml = _merge_dictionaries(toml.loads(std_config), extra_config)
|
|
except FileNotFoundError:
|
|
new_toml = toml.loads(std_config)
|
|
|
|
# Dump the dict into a toml-formatted string and write it to file
|
|
with open(traefik_std_config_file, "w") as f:
|
|
os.fchmod(f.fileno(), 0o600)
|
|
toml.dump(new_toml, f)
|
|
|
|
with open(os.path.join(traefik_dynamic_config_dir, "dynamic.toml"), "w") as f:
|
|
os.fchmod(f.fileno(), 0o600)
|
|
# validate toml syntax before writing
|
|
toml.loads(dynamic_config)
|
|
f.write(dynamic_config)
|
|
|
|
with open(os.path.join(traefik_dynamic_config_dir, "rules.toml"), "w") as f:
|
|
os.fchmod(f.fileno(), 0o600)
|
|
|
|
# ensure acme.json exists and is private
|
|
with open(os.path.join(state_dir, "acme.json"), "a") as f:
|
|
os.fchmod(f.fileno(), 0o600)
|