Files
owui-tools/vm/vmd/project.py
T
2026-09-15 15:24:03 -04:00

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