scripts -> modules

This commit is contained in:
Zsolt Ero
2024-08-29 16:33:59 +02:00
parent 7196e15837
commit 66d0bdc515
54 changed files with 65 additions and 52 deletions

View File

@@ -0,0 +1,83 @@
import shutil
import subprocess
import requests
from http_host_lib.config import config
from http_host_lib.utils import download_file_aria2, download_if_size_differs
def download_assets():
"""
Downloads and extracts assets
"""
download_and_extract_asset_tar_gz('fonts')
download_and_extract_asset_tar_gz('styles')
download_and_extract_asset_tar_gz('natural_earth')
download_sprites()
def download_and_extract_asset_tar_gz(asset_kind):
"""
Download and extract asset.tgz if the file size differ or not available locally
"""
print(f'Downloading asset {asset_kind}')
asset_dir = config.assets_dir / asset_kind
asset_dir.mkdir(exist_ok=True, parents=True)
url = f'https://assets.openfreemap.com/{asset_kind}/ofm.tar.gz'
local_file = asset_dir / 'ofm.tar.gz'
if not download_if_size_differs(url, local_file):
print(f' skipping asset: {asset_kind}')
return
ofm_dir = asset_dir / 'ofm'
ofm_dir_bak = asset_dir / 'ofm.bak'
shutil.rmtree(ofm_dir_bak, ignore_errors=True)
if ofm_dir.exists():
ofm_dir.rename(ofm_dir_bak)
subprocess.run(
['tar', '-xzf', local_file, '-C', asset_dir],
check=True,
)
print(f' downloaded asset: {asset_kind}')
def download_sprites():
"""
Sprites are special assets, as we have to keep the old versions indefinitely
"""
print('Downloading sprites')
sprites_dir = config.assets_dir / 'sprites'
sprites_dir.mkdir(exist_ok=True, parents=True)
r = requests.get('https://assets.openfreemap.com/files.txt', timeout=30)
r.raise_for_status()
sprites_remote = [l for l in r.text.splitlines() if l.startswith('sprites/')]
for sprite in sprites_remote:
sprite_name = sprite.split('/')[1].replace('.tar.gz', '')
if (sprites_dir / sprite_name).is_dir():
print(f' skipping sprite version: {sprite_name}')
continue
url = f'https://assets.openfreemap.com/sprites/{sprite_name}.tar.gz'
local_file = sprites_dir / 'temp.tar.gz'
download_file_aria2(url, local_file)
subprocess.run(
['tar', '-xzf', local_file, '-C', sprites_dir],
check=True,
)
local_file.unlink()
print(f' downloaded sprite version: {sprite_name}')

View File

@@ -0,0 +1,84 @@
import shutil
import subprocess
import sys
import requests
from http_host_lib.config import config
from http_host_lib.utils import download_file_aria2, get_remote_file_size
def download_area_version(area: str, version: str):
"""
Downloads and uncompresses tiles.btrfs files from the btrfs bucket
"""
if area not in config.areas:
sys.exit(f' please specify area: {config.areas}')
versions = get_versions_for_area(area)
if version == 'latest':
selected_version = versions[-1]
else:
if version not in versions:
available_versions_str = '\n'.join(versions)
sys.exit(
f'Requested version is not available.\nAvailable versions for {area}:\n{available_versions_str}'
)
selected_version = version
return download_and_extract_btrfs(area, selected_version)
def get_versions_for_area(area: str) -> list:
r = requests.get('https://btrfs.openfreemap.com/dirs.txt', timeout=30)
r.raise_for_status()
versions = [v.split('/')[2] for v in r.text.splitlines() if v.startswith(f'areas/{area}/')]
return sorted(versions)
def download_and_extract_btrfs(area: str, version: str) -> bool:
"""
returns True if download successful, False if skipped
"""
print(f'downloading and extracting btrfs for: {area} {version}')
version_dir = config.runs_dir / area / version
btrfs_file = version_dir / 'tiles.btrfs'
if btrfs_file.exists():
print(' file exists, skipping download')
return False
temp_dir = config.runs_dir / '_tmp'
shutil.rmtree(temp_dir, ignore_errors=True)
temp_dir.mkdir(parents=True)
url = f'https://btrfs.openfreemap.com/areas/{area}/{version}/tiles.btrfs.gz'
# check disk space
disk_free = shutil.disk_usage(temp_dir).free
file_size = get_remote_file_size(url)
if not file_size:
raise ValueError('Cannot get remote file size')
needed_space = file_size * 3
if disk_free < needed_space:
raise ValueError(f'Not enough disk space. Needed: {needed_space}, free space: {disk_free}')
target_file = temp_dir / 'tiles.btrfs.gz'
download_file_aria2(url, target_file)
print('Uncompressing...')
subprocess.run(['unpigz', temp_dir / 'tiles.btrfs.gz'], check=True)
btrfs_src = temp_dir / 'tiles.btrfs'
shutil.rmtree(version_dir, ignore_errors=True)
version_dir.mkdir(parents=True)
btrfs_src.rename(btrfs_file)
shutil.rmtree(temp_dir)
return True

View File

@@ -0,0 +1,29 @@
import json
from pathlib import Path
class Configuration:
areas = ['planet', 'monaco']
http_host_dir = Path('/data/ofm/http_host')
http_host_bin = http_host_dir / 'bin'
http_host_scripts_dir = http_host_bin / 'scripts'
runs_dir = http_host_dir / 'runs'
assets_dir = http_host_dir / 'assets'
mnt_dir = Path('/mnt/ofm')
ofm_config_dir = Path('/data/ofm/config')
certs_dir = Path('/data/nginx/certs')
nginx_confs = Path(__file__).parent / 'nginx_confs'
try:
with open(ofm_config_dir / 'http_host.json') as fp:
host_config = json.load(fp)
except Exception:
host_config = {}
config = Configuration()

View File

@@ -0,0 +1,87 @@
import subprocess
import sys
from pathlib import Path
from http_host_lib.config import config
from http_host_lib.utils import assert_linux, assert_sudo
def auto_mount_unmount():
"""
Mounts/unmounts the btrfs images from /data/ofm/http_host/runs automatically.
When finished, /mnt/ofm dir will have all the present tiles.btrfs files mounted in a read-only way.
"""
print('running mount')
assert_linux()
assert_sudo()
if not config.runs_dir.exists():
sys.exit(' download-btrfs needs to be run first')
clean_up_mounts(config.mnt_dir)
create_fstab()
print(' running mount -a')
subprocess.run(['mount', '-a'], check=True)
clean_up_mounts(config.mnt_dir)
def create_fstab():
fstab_new = []
for area in ['planet', 'monaco']:
area_dir = (config.runs_dir / area).resolve()
if not area_dir.exists():
continue
versions = sorted(area_dir.iterdir())
for version in versions:
version_str = version.name
btrfs_file = area_dir / version_str / 'tiles.btrfs'
if not btrfs_file.is_file():
continue
mnt_folder = config.mnt_dir / f'{area}-{version_str}'
mnt_folder.mkdir(exist_ok=True, parents=True)
fstab_new.append(f'{btrfs_file} {mnt_folder} btrfs loop,ro 0 0\n')
print(f' created fstab entry for {btrfs_file} -> {mnt_folder}')
with open('/etc/fstab') as fp:
fstab_orig = [l for l in fp.readlines() if f'{config.mnt_dir}/' not in l]
with open('/etc/fstab', 'w') as fp:
fp.writelines(fstab_orig + fstab_new)
def clean_up_mounts(mnt_dir):
if not mnt_dir.exists():
return
print(' cleaning up mounts')
# handle deleted files
p = subprocess.run(['mount'], capture_output=True, text=True, check=True)
lines = [l for l in p.stdout.splitlines() if f'{mnt_dir}/' in l and '(deleted)' in l]
for l in lines:
mnt_path = Path(l.split('(deleted) on ')[1].split(' type btrfs')[0])
print(f' removing deleted mount {mnt_path}')
assert mnt_path.exists()
subprocess.run(['umount', mnt_path], check=True)
mnt_path.rmdir()
# clean all mounts not in current fstab
with open('/etc/fstab') as fp:
fstab_str = fp.read()
for subdir in mnt_dir.iterdir():
if f'{subdir} ' in fstab_str:
continue
print(f' removing old mount {subdir}')
subprocess.run(['umount', subdir], check=True)
subdir.rmdir()

View File

@@ -0,0 +1,235 @@
import shutil
import subprocess
import sys
from pathlib import Path
from http_host_lib.config import config
from http_host_lib.utils import python_venv_executable
def write_nginx_config():
curl_text_mix = ''
domain_le = config.host_config['domain_le']
domain_ledns = config.host_config['domain_ledns']
# remove old configs and certs
for file in Path('/data/nginx/sites').glob('ofm_*.conf'):
file.unlink()
for file in Path('/data/nginx/certs').glob('ofm_*'):
file.unlink()
# processing Round Robin DNS config
if domain_ledns:
if not (config.ofm_config_dir / 'rclone.conf').is_file():
sys.exit('rclone.conf missing')
# download the ledns certificate from bucket using rclone
write_ledns_reader_script(domain_ledns)
subprocess.run(['bash', config.http_host_bin / 'ledns_reader.sh'], check=True)
curl_text_mix += create_nginx_conf(
template_path=config.nginx_confs / 'ledns.conf',
local='ofm_ledns',
domain=domain_ledns,
)
# processing Let's Encrypt config
if domain_le:
le_cert = config.certs_dir / 'ofm_le.cert'
le_key = config.certs_dir / 'ofm_le.key'
if not le_cert.is_file() or not le_key.is_file():
shutil.copyfile(Path('/etc/nginx/ssl/dummy.crt'), le_cert)
shutil.copyfile(Path('/etc/nginx/ssl/dummy.key'), le_key)
curl_text_mix += create_nginx_conf(
template_path=config.nginx_confs / 'le.conf',
local='ofm_le',
domain=domain_le,
)
subprocess.run(['nginx', '-t'], check=True)
subprocess.run(['systemctl', 'reload', 'nginx'], check=True)
subprocess.run(
[
'certbot',
'certonly',
'--webroot',
'--webroot-path=/data/nginx/acme-challenges',
'--noninteractive',
'-m',
config.host_config['le_email'],
'--agree-tos',
'--cert-name=ofm_le',
# '--staging',
'--deploy-hook',
'nginx -t && service nginx reload',
'-d',
domain_le,
],
check=True,
)
# link certs to nginx dir
le_cert.unlink()
le_key.unlink()
etc_cert = Path('/etc/letsencrypt/live/ofm_le/fullchain.pem')
etc_key = Path('/etc/letsencrypt/live/ofm_le/privkey.pem')
assert etc_cert.is_file()
assert etc_key.is_file()
le_cert.symlink_to(etc_cert)
le_key.symlink_to(etc_key)
subprocess.run(['nginx', '-t'], check=True)
subprocess.run(['systemctl', 'reload', 'nginx'], check=True)
print(curl_text_mix)
def create_nginx_conf(*, template_path, local, domain):
location_str, curl_text = create_location_blocks(local=local, domain=domain)
with open(template_path) as fp:
template = fp.read()
template = template.replace('__LOCATION_BLOCKS__', location_str)
template = template.replace('__LOCAL__', local)
template = template.replace('__DOMAIN__', domain)
curl_text = curl_text.replace('__LOCAL__', local)
curl_text = curl_text.replace('__DOMAIN__', domain)
with open(f'/data/nginx/sites/{local}.conf', 'w') as fp:
fp.write(template)
print(f' nginx config written: {domain} {local}')
return curl_text
def create_location_blocks(*, local, domain):
location_str = ''
curl_text = ''
for subdir in config.mnt_dir.iterdir():
if not subdir.is_dir():
continue
area, version = subdir.name.split('-')
location_str += create_version_location(
area=area, version=version, subdir=subdir, local=local, domain=domain
)
if not curl_text:
curl_text = (
'\ntest with:\n'
f'curl -H "Host: __LOCAL__" -I http://localhost/{area}/{version}/14/8529/5975.pbf\n'
f'curl -I https://__DOMAIN__/{area}/{version}/14/8529/5975.pbf'
)
location_str += create_latest_locations(local=local, domain=domain)
with open(config.nginx_confs / 'location_static.conf') as fp:
location_str += '\n' + fp.read()
return location_str, curl_text
def create_version_location(
*, area: str, version: str, subdir: Path, local: str, domain: str
) -> str:
run_dir = config.runs_dir / area / version
if not run_dir.is_dir():
print(f" {run_dir} doesn't exists, skipping")
return ''
tilejson_path = run_dir / f'tilejson-{local}.json'
metadata_path = subdir / 'metadata.json'
if not metadata_path.is_file():
print(f" {metadata_path} doesn't exists, skipping")
return ''
url_prefix = f'https://{domain}/{area}/{version}'
subprocess.run(
[
python_venv_executable(),
config.http_host_scripts_dir / 'metadata_to_tilejson.py',
'--minify',
metadata_path,
tilejson_path,
url_prefix,
],
check=True,
)
return f"""
location = /{area}/{version} {{ # no trailing slash
alias {tilejson_path}; # no trailing slash
expires 1w;
default_type application/json;
add_header 'Access-Control-Allow-Origin' '*' always;
add_header Cache-Control public;
}}
location /{area}/{version}/ {{ # trailing slash
alias {subdir}/tiles/; # trailing slash
try_files $uri @empty_tile;
add_header Content-Encoding gzip;
expires 10y;
types {{
application/vnd.mapbox-vector-tile pbf;
}}
add_header 'Access-Control-Allow-Origin' '*' always;
add_header Cache-Control public;
}}
"""
def create_latest_locations(*, local: str, domain: str) -> str:
location_str = ''
local_version_files = config.ofm_config_dir.glob('tileset_version_*.txt')
for file in local_version_files:
area = file.stem.split('_')[-1]
with open(file) as fp:
version = fp.read().strip()
print(f' setting latest version for {area}: {version}')
run_dir = config.runs_dir / area / version
tilejson_path = run_dir / f'tilejson-{local}.json'
assert tilejson_path.is_file()
location_str += f"""
location = /{area} {{ # no trailing slash
alias {tilejson_path}; # no trailing slash
expires 1d;
default_type application/json;
add_header 'Access-Control-Allow-Origin' '*' always;
add_header Cache-Control public;
}}
"""
return location_str
def write_ledns_reader_script(domain_ledns):
script = f"""
#!/usr/bin/env bash
export RCLONE_CONFIG=/data/ofm/config/rclone.conf
rclone copyto -v "remote:ofm-private/ledns/{domain_ledns}/ofm_ledns.cert" /data/nginx/certs/ofm_ledns.cert
rclone copyto -v "remote:ofm-private/ledns/{domain_ledns}/ofm_ledns.key" /data/nginx/certs/ofm_ledns.key
""".strip()
with open(config.http_host_bin / 'ledns_reader.sh', 'w') as fp:
fp.write(script)

View File

@@ -0,0 +1,44 @@
server {
server_name __LOCAL__ __DOMAIN__;
# ssl: https://ssl-config.mozilla.org / intermediate config
listen 80;
listen 443 ssl;
listen [::]:443 ssl;
http2 on;
ssl_certificate /data/nginx/certs/ofm_le.cert;
ssl_certificate_key /data/nginx/certs/ofm_le.key;
ssl_session_timeout 1d;
ssl_session_cache shared:MozSSL:10m; # about 40000 sessions
ssl_session_tickets off;
ssl_dhparam /etc/nginx/ffdhe2048.txt;
# intermediate configuration
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384:DHE-RSA-CHACHA20-POLY1305;
ssl_prefer_server_ciphers off;
# access log disabled by default
access_log /data/ofm/http_host/logs_nginx/le-access.jsonl access_json buffer=32k;
#access_log off;
error_log /data/ofm/http_host/logs_nginx/le-error.log;
location ^~ /.well-known/acme-challenge/ {
# trailing slash
root /data/nginx/acme-challenges;
try_files $uri =404;
}
__LOCATION_BLOCKS__
# catch-all block to deny all other requests
location / {
deny all;
error_log /data/ofm/http_host/logs_nginx/__LOCAL__-error.log error;
}
}

View File

@@ -0,0 +1,38 @@
server {
server_name __LOCAL__ __DOMAIN__;
# ssl: https://ssl-config.mozilla.org / intermediate config
listen 80;
listen 443 ssl;
listen [::]:443 ssl;
http2 on;
ssl_certificate /data/nginx/certs/ofm_ledns.cert;
ssl_certificate_key /data/nginx/certs/ofm_ledns.key;
ssl_session_timeout 1d;
ssl_session_cache shared:MozSSL:10m; # about 40000 sessions
ssl_session_tickets off;
ssl_dhparam /etc/nginx/ffdhe2048.txt;
# intermediate configuration
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384:DHE-RSA-CHACHA20-POLY1305;
ssl_prefer_server_ciphers off;
# access log disabled by default
access_log /data/ofm/http_host/logs_nginx/ledns-access.jsonl access_json buffer=32k;
#access_log off;
error_log /data/ofm/http_host/logs_nginx/ledns-error.log;
__LOCATION_BLOCKS__
# catch-all block to deny all other requests
location / {
deny all;
error_log /data/ofm/http_host/logs_nginx/__LOCAL__-error.log error;
}
}

View File

@@ -0,0 +1,64 @@
location /fonts/ {
# trailing slash
alias /data/ofm/http_host/assets/fonts/ofm/; # trailing slash
try_files $uri =404;
expires 1w;
add_header 'Access-Control-Allow-Origin' '*' always;
add_header Cache-Control public;
}
location /styles/ {
# trailing slash
alias /data/ofm/http_host/assets/styles/ofm/; # trailing slash
try_files $uri.json =404;
expires 1d;
default_type application/json;
add_header 'Access-Control-Allow-Origin' '*' always;
add_header Cache-Control public;
}
location /natural_earth/ {
# trailing slash
alias /data/ofm/http_host/assets/natural_earth/ofm/; # trailing slash
try_files $uri =404;
expires 10y;
add_header 'Access-Control-Allow-Origin' '*' always;
add_header Cache-Control public;
}
location /sprites/ {
# trailing slash
alias /data/ofm/http_host/assets/sprites/; # trailing slash
try_files $uri =404;
expires 10y;
add_header 'Access-Control-Allow-Origin' '*' always;
add_header Cache-Control public;
}
# we need to handle missing tiles as valid request returning empty string
location @empty_tile {
return 200 '';
expires 10y;
default_type application/vnd.mapbox-vector-tile;
add_header 'Access-Control-Allow-Origin' '*' always;
add_header Cache-Control public;
}
location = / {
return 302 https://openfreemap.org;
}

View File

@@ -0,0 +1,45 @@
from pathlib import Path
import requests
from http_host_lib.config import config
def set_tileset_versions():
need_nginx_sync = False
for area in ['planet', 'monaco']:
r = requests.get(f'https://assets.openfreemap.com/versions/deployed_{area}.txt', timeout=30)
r.raise_for_status()
remote_version = r.text.strip()
print(f' remote version for {area}: {remote_version}')
local_version_file = config.ofm_config_dir / f'tileset_version_{area}.txt'
if not local_version_file.exists():
local_version_start = None
else:
with open(local_version_file) as fp:
local_version_start = fp.read()
if not remote_version:
print(' remote version not specified')
if local_version_start is not None:
local_version_file.unlink()
need_nginx_sync = True
continue
mnt_file = Path(f'/mnt/ofm/{area}-{remote_version}/metadata.json')
if not mnt_file.exists():
print(' local version does not exist')
if local_version_start is not None:
local_version_file.unlink()
need_nginx_sync = True
continue
if remote_version != local_version_start:
with open(local_version_file, 'w') as fp:
fp.write(remote_version)
need_nginx_sync = True
return need_nginx_sync

View File

@@ -0,0 +1,69 @@
import os
import subprocess
import sys
from pathlib import Path
import requests
def assert_sudo():
if os.geteuid() != 0:
sys.exit(' needs sudo')
def assert_linux():
if not Path('/etc/fstab').exists():
sys.exit(' needs to be run on Linux')
def assert_single_process():
p = subprocess.run(['pgrep', '-fl', sys.argv[0]], capture_output=True, text=True)
lines = [l for l in p.stdout.splitlines() if 'python' in l]
if len(lines) >= 2:
sys.exit(' detected multiple processes, terminating')
def download_if_size_differs(url: str, local_file: Path) -> bool:
if not local_file.exists() or local_file.stat().st_size != get_remote_file_size(url):
download_file_aria2(url, local_file)
return True
return False
def get_remote_file_size(url: str) -> int | None:
r = requests.head(url, timeout=30)
size = r.headers.get('Content-Length')
return int(size) if size else None
def download_file_aria2(url: str, local_file: Path):
print(f' downloading {url} into {local_file}')
local_file.unlink(missing_ok=True)
subprocess.run(
[
'aria2c',
'--split=8',
'--max-connection-per-server=8',
'--file-allocation=none',
'--min-split-size=1M',
'-d',
local_file.parent,
'-o',
local_file.name,
url,
],
check=True,
)
def python_venv_executable() -> Path:
venv_path = os.environ.get('VIRTUAL_ENV')
if venv_path:
return Path(venv_path) / 'bin' / 'python'
elif sys.prefix != sys.base_prefix:
return Path(sys.prefix) / 'bin' / 'python'
else:
return Path(sys.executable)