From: Thomas Walker Lynch Date: Mon, 10 Nov 2025 12:00:16 +0000 (+0000) Subject: start of testing X-Git-Url: https://git.reasoningtechnology.com/style/static/gitweb.css?a=commitdiff_plain;h=1fdafdc37ea4ab579e28998e79ad48c10bb01487;p=subu start of testing --- diff --git a/developer/manager/CLI.py b/developer/manager/CLI.py index 0185450..bda340b 100755 --- a/developer/manager/CLI.py +++ b/developer/manager/CLI.py @@ -51,7 +51,6 @@ def register_subu_commands(subparsers): """ # init ap = subparsers.add_parser("init") - ap.add_argument("token", nargs ="?") # make: path[0] is masu, remaining elements are the subu chain ap = subparsers.add_parser("make") @@ -72,7 +71,7 @@ def register_subu_commands(subparsers): # lo ap = subparsers.add_parser("lo") - ap.add_argument("state", choices =["up","down"]) +< ap.add_argument("state", choices =["up","down"]) ap.add_argument("subu_id") def register_wireguard_commands(subparsers): @@ -203,7 +202,7 @@ def CLI(argv=None) -> int: try: if ns.verb == "init": - return dispatch.init(ns.token) + return dispatch.init() if ns.verb == "make": # ns.path is ['masu', 'subu', ...] diff --git a/developer/manager/dispatch.py b/developer/manager/dispatch.py index 48b7aeb..707f85b 100644 --- a/developer/manager/dispatch.py +++ b/developer/manager/dispatch.py @@ -8,7 +8,7 @@ from infrastructure.db import open_db, ensure_schema from infrastructure.options_store import set_option -def init(token =None): +def init(): """ Handle: subu init diff --git a/developer/manager/domain/subu.py b/developer/manager/domain/subu.py index d870231..1141ebe 100644 --- a/developer/manager/domain/subu.py +++ b/developer/manager/domain/subu.py @@ -3,6 +3,7 @@ from infrastructure.unix import ( ensure_unix_user, + ensure_user_in_group, remove_unix_user_and_group, user_exists, ) @@ -32,8 +33,8 @@ def subu_username(masu: str, path_components: list[str]) -> str: Build the Unix username for a subu. Examples: - masu = "Thomas", path = ["S0"] -> "Thomas_S0" - masu = "Thomas", path = ["S0","S1"] -> "Thomas_S0_S1" + masu = "Thomas", path = ["S0"] -> "Thomas_S0" + masu = "Thomas", path = ["S0","S1"] -> "Thomas_S0_S1" The path is: masu subu subu ... @@ -51,8 +52,8 @@ def _parent_username(masu: str, path_components: list[str]) -> str | None: Return the Unix username of the parent subu, or None if this is top-level. Examples: - masu="Thomas", path=["S0"] -> None (parent is just the masu) - masu="Thomas", path=["S0","S1"] -> "Thomas_S0" + masu="Thomas", path=["S0"] -> None (parent is just the masu) + masu="Thomas", path=["S0","S1"] -> "Thomas_S0" """ if len(path_components) <= 1: return None @@ -61,6 +62,35 @@ def _parent_username(masu: str, path_components: list[str]) -> str | None: return subu_username(masu, parent_path) +def _ancestor_group_names(masu: str, path_components: list[str]) -> list[str]: + """ + Compute ancestor groups that a subu must join for directory traversal. + + For path: + [masu, s1, s2, ..., sk] + + we return: + [masu, + masu_s1, + masu_s1_s2, + ..., + masu_s1_..._s{k-1}] + + The last element (full username) is NOT included, because that is + the subu's own primary group. + """ + groups: list[str] = [] + # masu group (allows traversal of /home/masu and /home/masu/subu_data) + groups.append(_validate_token("masu", masu)) + + # For deeper subu, add each ancestor subu's group + for depth in range(1, len(path_components)): + prefix = path_components[:depth] + groups.append(subu_username(masu, prefix)) + + return groups + + def make_subu(masu: str, path_components: list[str]) -> str: """ Make the Unix user and group for this subu. @@ -73,7 +103,7 @@ def make_subu(masu: str, path_components: list[str]) -> str: - tokens must not contain '_' - parent must exist: * for first-level subu: Unix user 'masu' must exist - * for deeper subu: parent subu Unix user must exist + * for deeper subu: parent subu unix user must exist Returns: Unix username, for example 'Thomas_S0' or 'Thomas_S0_S1'. @@ -82,6 +112,7 @@ def make_subu(masu: str, path_components: list[str]) -> str: raise SystemExit("subu: make requires at least one subu component") # Normalize and validate tokens (this will raise SystemExit on error). + # subu_username will call _validate_token internally. username = subu_username(masu, path_components) # Enforce parent existence @@ -104,6 +135,15 @@ def make_subu(masu: str, path_components: list[str]) -> str: # For now, group and user share the same name. ensure_unix_user(username, username) + + # Add this subu to the ancestor groups so that directory traversal works: + # /home/masu + # /home/masu/subu_data + # /home/masu/subu_data//subu_data/... + ancestor_groups = _ancestor_group_names(masu, path_components) + for gname in ancestor_groups: + ensure_user_in_group(username, gname) + return username diff --git a/developer/manager/infrastructure/unix.py b/developer/manager/infrastructure/unix.py index 6aa86cf..395d88b 100644 --- a/developer/manager/infrastructure/unix.py +++ b/developer/manager/infrastructure/unix.py @@ -56,6 +56,26 @@ def ensure_unix_user(name: str, primary_group: str): run(["useradd", "-m", "-g", primary_group, "-s", "/bin/bash", name]) +def ensure_user_in_group(user: str, group: str): + """ + Ensure 'user' is a member of supplementary group 'group'. + + - Raises if either user or group does not exist. + - No-op if the membership is already present. + """ + if not user_exists(user): + raise RuntimeError(f"ensure_user_in_group: user '{user}' does not exist") + if not group_exists(group): + raise RuntimeError(f"ensure_user_in_group: group '{group}' does not exist") + + g = grp.getgrnam(group) + if user in g.gr_mem: + return + + # usermod -a -G adds the group, preserving existing ones. + run(["usermod", "-a", "-G", group, user]) + + def remove_unix_user_and_group(name: str): """ Remove a Unix user and group that match this name, if they exist. diff --git a/developer/manager/text.py b/developer/manager/text.py index 012f087..127b724 100644 --- a/developer/manager/text.py +++ b/developer/manager/text.py @@ -28,7 +28,7 @@ Usage: {program_name} example # example workflow {program_name} version # print version - {program_name} init + {program_name} init {program_name} make [_]* {program_name} list {program_name} info | {program_name} information @@ -59,8 +59,9 @@ Usage: return f"""Subu manager (v{current_version()}) 1) Init - {program_name} init - Makes ./subu.db. Refuses to run if db exists. + {program_name} init + Gives an error if the db file already exits, otherwise creates it. The db file + path is set in env.py. 2) Subu {program_name} make [_]* @@ -99,7 +100,7 @@ Usage: def example(self): program_name = self.program_name return f"""# 0) Initialise the subu database (once per directory) -{program_name} init dzkq7b +{program_name} init # 1) Make Subu {program_name} make Thomas US diff --git a/developer/tool/release b/developer/tool/release index c41207b..7a9f557 100755 --- a/developer/tool/release +++ b/developer/tool/release @@ -1,7 +1,7 @@ #!/usr/bin/env -S python3 -B # -*- mode: python; coding: utf-8; python-indent-offset: 2; indent-tabs-mode: nil -*- -import os, sys, shutil, stat, pwd, grp, glob, tempfile +import os, sys, shutil, stat, pwd, grp, glob, tempfile, datetime HELP = """usage: release {write|clean|ls|help|dry write} [DIR] write [DIR] Write released files. @@ -10,7 +10,7 @@ HELP = """usage: release {write|clean|ls|help|dry write} [DIR] clean [DIR] Remove the contents of the release directories. - For DIR=manager: clean $REPO_HOME/release/manager. - For other DIR values: clean only that subdirectory under the release root. - ls List $REPO_HOME/release as an indented tree: PERMS OWNER NAME. + ls List $REPO_HOME/release as an indented tree: PERMS OWNER DATE NAME. help Show this message. dry write [DIR] Preview what write would do without modifying the filesystem. @@ -19,11 +19,13 @@ HELP = """usage: release {write|clean|ls|help|dry write} [DIR] ENV_MUST_BE = "developer/tool/env" DEFAULT_DIR_MODE = 0o700 # 077-congruent dirs - def exit_with_status(msg, code=1): print(f"release: {msg}", file=sys.stderr) sys.exit(code) +def format_mtime_utc(ts: float) -> str: + dt = datetime.datetime.fromtimestamp(ts, datetime.timezone.utc) + return dt.strftime("%Y-%m-%d_%H:%M:%S_Z") def assert_env(): env = os.environ.get("ENV", "") @@ -101,21 +103,23 @@ def ensure_dir(path, mode=DEFAULT_DIR_MODE, dry=False): os.makedirs(path, exist_ok=True) ensure_mode(path, mode) - def filemode(m): try: return stat.filemode(m) except Exception: return oct(m & 0o777) - def owner_group(st): try: - return f"{pwd.getpwuid(st.st_uid).pw_name}:{grp.getgrgid(st.st_gid).gr_name}" + user = pwd.getpwuid(st.st_uid).pw_name + group = grp.getgrgid(st.st_gid).gr_name except Exception: - return f"{st.st_uid}:{st.st_gid}" + user = str(st.st_uid) + group = str(st.st_gid) + return user if user == group else f"{user}:{group}" +# ---------- LS (two-pass owner:group width) ---------- # ---------- LS (two-pass owner:group width) ---------- def list_tree(root): if not os.path.isdir(root): @@ -133,39 +137,57 @@ def list_tree(root): files.sort(key=lambda e: e.name) if is_root: + # dotfiles at root first for f in (e for e in files if e.name.startswith(".")): st = os.lstat(f.path) - entries.append((False, depth, filemode(st.st_mode), owner_group(st), f.name)) + perms = filemode(st.st_mode) + og = owner_group(st) + mtime = format_mtime_utc(st.st_mtime) + entries.append((False, depth, perms, og, mtime, f.name)) + # dirs for d in dirs: st = os.lstat(d.path) - entries.append((True, depth, filemode(st.st_mode), owner_group(st), d.name + "/")) + perms = filemode(st.st_mode) + og = owner_group(st) + mtime = format_mtime_utc(st.st_mtime) + entries.append((True, depth, perms, og, mtime, d.name + "/")) gather(d.path, depth + 1, False) + # other files for f in (e for e in files if not e.name.startswith(".")): st = os.lstat(f.path) - entries.append((False, depth, filemode(st.st_mode), owner_group(st), f.name)) + perms = filemode(st.st_mode) + og = owner_group(st) + mtime = format_mtime_utc(st.st_mtime) + entries.append((False, depth, perms, og, mtime, f.name)) else: + # normal subdirs: dirs then files for d in dirs: st = os.lstat(d.path) - entries.append((True, depth, filemode(st.st_mode), owner_group(st), d.name + "/")) + perms = filemode(st.st_mode) + og = owner_group(st) + mtime = format_mtime_utc(st.st_mtime) + entries.append((True, depth, perms, og, mtime, d.name + "/")) gather(d.path, depth + 1, False) for f in files: st = os.lstat(f.path) - entries.append((False, depth, filemode(st.st_mode), owner_group(st), f.name)) + perms = filemode(st.st_mode) + og = owner_group(st) + mtime = format_mtime_utc(st.st_mtime) + entries.append((False, depth, perms, og, mtime, f.name)) gather(root, depth=1, is_root=True) ogw = 0 - for (_isdir, _depth, _perms, ownergrp, _name) in entries: + for (_isdir, _depth, _perms, ownergrp, _mtime, _name) in entries: if len(ownergrp) > ogw: ogw = len(ownergrp) print("release/") - for (_isdir, depth, perms, ownergrp, name) in entries: + for (_isdir, depth, perms, ownergrp, mtime, name) in entries: indent = " " * depth - print(f"{perms} {ownergrp:<{ogw}} {indent}{name}") + print(f"{perms} {ownergrp:<{ogw}} {mtime} {indent}{name}") # ---------- end LS ---------- - def iter_src_files(topdir, src_root): """ Yield (src_abs, rel) pairs. diff --git a/release/manager/domain/subu.py b/release/manager/domain/subu.py index 53f674d..1141ebe 100644 --- a/release/manager/domain/subu.py +++ b/release/manager/domain/subu.py @@ -3,12 +3,13 @@ from infrastructure.unix import ( ensure_unix_user, + ensure_user_in_group, remove_unix_user_and_group, user_exists, ) -def _validate_token(label: str, token: str): +def _validate_token(label: str, token: str) -> str: """ Validate a single path token (masu or subu). @@ -32,14 +33,14 @@ def subu_username(masu: str, path_components: list[str]) -> str: Build the Unix username for a subu. Examples: - masu = "Thomas", path = ["S0"] -> "Thomas_S0" - masu = "Thomas", path = ["S0","S1"] -> "Thomas_S0_S1" + masu = "Thomas", path = ["S0"] -> "Thomas_S0" + masu = "Thomas", path = ["S0","S1"] -> "Thomas_S0_S1" The path is: masu subu subu ... """ masu_s = _validate_token("masu", masu).replace(" ", "_") - subu_parts = [] + subu_parts: list[str] = [] for s in path_components: subu_parts.append(_validate_token("subu", s).replace(" ", "_")) parts = [masu_s] + subu_parts @@ -51,8 +52,8 @@ def _parent_username(masu: str, path_components: list[str]) -> str | None: Return the Unix username of the parent subu, or None if this is top-level. Examples: - masu="Thomas", path=["S0"] -> None (parent is just the masu) - masu="Thomas", path=["S0","S1"] -> "Thomas_S0" + masu="Thomas", path=["S0"] -> None (parent is just the masu) + masu="Thomas", path=["S0","S1"] -> "Thomas_S0" """ if len(path_components) <= 1: return None @@ -61,6 +62,35 @@ def _parent_username(masu: str, path_components: list[str]) -> str | None: return subu_username(masu, parent_path) +def _ancestor_group_names(masu: str, path_components: list[str]) -> list[str]: + """ + Compute ancestor groups that a subu must join for directory traversal. + + For path: + [masu, s1, s2, ..., sk] + + we return: + [masu, + masu_s1, + masu_s1_s2, + ..., + masu_s1_..._s{k-1}] + + The last element (full username) is NOT included, because that is + the subu's own primary group. + """ + groups: list[str] = [] + # masu group (allows traversal of /home/masu and /home/masu/subu_data) + groups.append(_validate_token("masu", masu)) + + # For deeper subu, add each ancestor subu's group + for depth in range(1, len(path_components)): + prefix = path_components[:depth] + groups.append(subu_username(masu, prefix)) + + return groups + + def make_subu(masu: str, path_components: list[str]) -> str: """ Make the Unix user and group for this subu. @@ -92,17 +122,28 @@ def make_subu(masu: str, path_components: list[str]) -> str: masu_name = _validate_token("masu", masu) if not user_exists(masu_name): raise SystemExit( - f"subu: cannot make '{username}': masu Unix user '{masu_name}' does not exist" + f"subu: cannot make '{username}': " + f"masu Unix user '{masu_name}' does not exist" ) else: # Deeper subu: require parent subu Unix user to exist if not user_exists(parent_uname): raise SystemExit( - f"subu: cannot make '{username}': parent subu unix user '{parent_uname}' does not exist" + f"subu: cannot make '{username}': " + f"parent subu unix user '{parent_uname}' does not exist" ) # For now, group and user share the same name. ensure_unix_user(username, username) + + # Add this subu to the ancestor groups so that directory traversal works: + # /home/masu + # /home/masu/subu_data + # /home/masu/subu_data//subu_data/... + ancestor_groups = _ancestor_group_names(masu, path_components) + for gname in ancestor_groups: + ensure_user_in_group(username, gname) + return username diff --git a/release/manager/infrastructure/unix.py b/release/manager/infrastructure/unix.py index 6aa86cf..395d88b 100644 --- a/release/manager/infrastructure/unix.py +++ b/release/manager/infrastructure/unix.py @@ -56,6 +56,26 @@ def ensure_unix_user(name: str, primary_group: str): run(["useradd", "-m", "-g", primary_group, "-s", "/bin/bash", name]) +def ensure_user_in_group(user: str, group: str): + """ + Ensure 'user' is a member of supplementary group 'group'. + + - Raises if either user or group does not exist. + - No-op if the membership is already present. + """ + if not user_exists(user): + raise RuntimeError(f"ensure_user_in_group: user '{user}' does not exist") + if not group_exists(group): + raise RuntimeError(f"ensure_user_in_group: group '{group}' does not exist") + + g = grp.getgrnam(group) + if user in g.gr_mem: + return + + # usermod -a -G adds the group, preserving existing ones. + run(["usermod", "-a", "-G", group, user]) + + def remove_unix_user_and_group(name: str): """ Remove a Unix user and group that match this name, if they exist. diff --git a/release/manager/text.py b/release/manager/text.py index 012f087..b6eef09 100644 --- a/release/manager/text.py +++ b/release/manager/text.py @@ -60,7 +60,8 @@ Usage: 1) Init {program_name} init - Makes ./subu.db. Refuses to run if db exists. + Gives an error if the db file already exits, otherwise creates it. The db file + path is set in env.py. 2) Subu {program_name} make [_]*