168 lines
6.9 KiB
Python
168 lines
6.9 KiB
Python
"""Project names, datasets and quotas.
|
|
|
|
WHERE A NAME BECOMES A PATH
|
|
|
|
A project name arrives from the model, which got it from a user, and it
|
|
goes on to become four different things: a ZFS dataset, a host directory,
|
|
a Gitea repository and a URL. Each of those has its own way of being
|
|
wrong - a leading dash becomes an argument, a `..` becomes a different
|
|
directory, a slash becomes a different repo owner - so the name is
|
|
checked once, here, against a pattern narrow enough that none of those
|
|
readings is possible, and checked again by owuivm-launch before it is
|
|
handed to qemu.
|
|
|
|
That double check is deliberate. `security.py` in the api container says
|
|
the same thing about URLs: the in-app validation is a usability layer,
|
|
and the boundary is somewhere else. Here the boundary is this module plus
|
|
the launcher, and neither trusts the other. It is the same discipline as
|
|
`matlab.py`, which re-validates a lifted function name against the helper
|
|
filename pattern at the moment it opens the file - "one check at the
|
|
boundary is worth more than any number upstream".
|
|
|
|
WHY ZFS AND NOT A DIRECTORY
|
|
|
|
The 10 GB cap has to hold against a guest that is actively trying to fill
|
|
the array, and `du` in a loop does not. A dataset quota is refused by the
|
|
filesystem at write time, inside the guest, as ENOSPC. The parent carries
|
|
exec=off, setuid=off and devices=off, which are inherited: a setuid
|
|
binary or a device node a guest plants in its share is inert on the host
|
|
whatever the guest's own mount options said.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import re
|
|
import subprocess
|
|
|
|
# Lowercase, 2-31 characters, alphanumeric at BOTH ends. No dots (which
|
|
# would let `..` through and read oddly as a repo name), no underscores
|
|
# (Gitea allows them but the extra character buys nothing), no uppercase
|
|
# (ZFS permits it, but two datasets differing only in case on a
|
|
# case-insensitive path is a bug waiting to be filed).
|
|
#
|
|
# The trailing-alphanumeric requirement is not cosmetic: `name-` survives
|
|
# every path check and then reads as an incomplete word everywhere it is
|
|
# displayed, and a name ending in a hyphen is the sort of thing that gets
|
|
# concatenated with a suffix somewhere downstream and stops being unique.
|
|
PROJECT_RE = re.compile(r"^[a-z0-9][a-z0-9-]{0,29}[a-z0-9]$")
|
|
|
|
# The ZFS pool these live on. /Pool is this project's default and nothing
|
|
# more - a pool is named when it is created, and `zfs list` will tell you
|
|
# what yours is called. install.sh writes the chosen value into
|
|
# /etc/owuivm/vmd.env, which owuivmd.service loads, so the daemon and the
|
|
# privileged helper always agree about it.
|
|
#
|
|
# The dataset name is the mountpoint without the leading slash, which is
|
|
# the normal ZFS arrangement: pool `tank` mounted at /tank.
|
|
POOL_ROOT = (os.environ.get("OWUIVM_POOL_ROOT") or "/Pool").rstrip("/") or "/Pool"
|
|
PROJECTS_DATASET = f"{POOL_ROOT.lstrip('/')}/vmprojects"
|
|
PROJECTS_ROOT = f"{POOL_ROOT}/vmprojects"
|
|
QUOTA = os.environ.get("OWUIVM_PROJECT_QUOTA", "10G")
|
|
|
|
# Names that are valid by the pattern but would be confusing or would
|
|
# collide with something. Kept short on purpose: a long deny list is a
|
|
# sign the pattern is wrong.
|
|
RESERVED = frozenset({"base", "iso", "state", "run", "git", "gitea", "owuivm"})
|
|
|
|
|
|
class ProjectError(ValueError):
|
|
"""A project name or dataset problem, phrased for the model to read."""
|
|
|
|
|
|
def validate(name: str) -> str:
|
|
"""Return `name` if it is usable as a dataset, path, repo and URL."""
|
|
if not name:
|
|
raise ProjectError("no project name was given.")
|
|
if not PROJECT_RE.fullmatch(name):
|
|
raise ProjectError(
|
|
f"'{name}' is not a usable project name. Use 2-31 characters, "
|
|
"lowercase letters, digits and hyphens, starting with a letter "
|
|
"or digit. The name becomes a folder on the array and a "
|
|
"repository in Gitea, so it cannot contain '/', '..' or spaces."
|
|
)
|
|
if name in RESERVED:
|
|
raise ProjectError(f"'{name}' is reserved. Pick another project name.")
|
|
return name
|
|
|
|
|
|
def path_of(name: str) -> str:
|
|
"""The mounted directory for a validated project.
|
|
|
|
The realpath check is not redundant with `validate`. It is what
|
|
catches the case the pattern cannot see: a symlink planted at
|
|
/Pool/vmprojects/<name> by something with write access there,
|
|
pointing anywhere it likes.
|
|
"""
|
|
validate(name)
|
|
path = os.path.join(PROJECTS_ROOT, name)
|
|
if os.path.realpath(path) != path:
|
|
raise ProjectError(
|
|
f"the path for project '{name}' is not where it should be; refusing to use it."
|
|
)
|
|
return path
|
|
|
|
|
|
def _zfs(*args: str) -> subprocess.CompletedProcess:
|
|
# No shell anywhere near this. Every argument is either a constant or
|
|
# a name that went through validate().
|
|
return subprocess.run(
|
|
["/usr/sbin/zfs", *args],
|
|
capture_output=True, text=True, timeout=60, check=False,
|
|
)
|
|
|
|
|
|
def exists(name: str) -> bool:
|
|
validate(name)
|
|
return _zfs("list", "-H", "-o", "name", f"{PROJECTS_DATASET}/{name}").returncode == 0
|
|
|
|
|
|
def ensure(name: str, owner_uid: int, owner_gid: int) -> str:
|
|
"""Create the project's dataset if it is not there, and return its path."""
|
|
validate(name)
|
|
if not exists(name):
|
|
# exec/setuid/devices are inherited from the parent; the quota is
|
|
# per-project and is the only thing standing between one caller
|
|
# and everybody else's space on the array.
|
|
result = _zfs("create", "-o", f"quota={QUOTA}", f"{PROJECTS_DATASET}/{name}")
|
|
if result.returncode != 0:
|
|
raise ProjectError(
|
|
f"could not create storage for project '{name}': {result.stderr.strip()}"
|
|
)
|
|
path = path_of(name)
|
|
os.chown(path, owner_uid, owner_gid)
|
|
# setgid, so everything the guest writes lands in the group that vmd
|
|
# and virtiofsd share and both can still read it afterwards.
|
|
os.chmod(path, 0o2770)
|
|
return path
|
|
|
|
|
|
def usage(name: str) -> tuple[int, int]:
|
|
"""(used bytes, quota bytes) for a project, for the model to be told."""
|
|
validate(name)
|
|
result = _zfs("get", "-Hp", "-o", "value", "used,quota", f"{PROJECTS_DATASET}/{name}")
|
|
if result.returncode != 0:
|
|
return (0, 0)
|
|
values = result.stdout.split()
|
|
if len(values) < 2:
|
|
return (0, 0)
|
|
return (int(values[0]), int(values[1]))
|
|
|
|
|
|
def describe_usage(name: str) -> str:
|
|
used, quota = usage(name)
|
|
if not quota:
|
|
return ""
|
|
pct = 100.0 * used / quota
|
|
line = f"Project storage: {used / 1e9:.2f} GB of {quota / 1e9:.0f} GB used ({pct:.0f}%)."
|
|
if pct >= 90:
|
|
# Said plainly rather than as a warning the model can round off:
|
|
# at the quota, writes fail inside the guest as ENOSPC, and a
|
|
# build failing for that reason looks exactly like a build
|
|
# failing for a real one.
|
|
line += (
|
|
" At 100% the guest cannot write at all and every command that"
|
|
" creates a file will fail with 'No space left on device'."
|
|
)
|
|
return line
|