Initial Commit
This commit is contained in:
@@ -0,0 +1,167 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user