.
authorThomas Walker Lynch <eknp9n@reasoningtechnology.com>
Tue, 11 Nov 2025 05:04:16 +0000 (05:04 +0000)
committerThomas Walker Lynch <eknp9n@reasoningtechnology.com>
Tue, 11 Nov 2025 05:04:16 +0000 (05:04 +0000)
14 files changed:
developer/manager/CLI.py
developer/manager/dispatch.py
developer/manager/env.py
developer/manager/infrastructure/db.py
developer/manager/text.py
release/manager/CLI.py
release/manager/dispatch.py
release/manager/env.py
release/manager/infrastructure/db.py
release/manager/text.py
tester/document/notes.txt [new file with mode: 0644]
tester/document/spec.txt [new file with mode: 0644]
tester/document/workflow.org [new file with mode: 0644]
tester/manager/subu_manager [new symlink]

index bda340b..e3b3dea 100755 (executable)
@@ -1,10 +1,9 @@
 #!/usr/bin/env python3
 # -*- mode: python; coding: utf-8; python-indent-offset: 2; indent-tabs-mode: nil -*-
-"""
-1. CLI.py
- dispatch.
+"""CLI.py — subu manager front-end.
+
+Role: parse argv, choose command, call dispatch.
 
-Role: parse argv, choose command, call
 CLI should not do any work beyond:
 
   * figure out program_name (for example, manager/CLI.py or wrapper name)
@@ -18,65 +17,50 @@ from text import make_text
 import dispatch
 
 
-def build_arg_parser(program_name):
-  """
-  Build the top level argument parser for the subu manager.
+def register_db_commands(subparsers):
+  """Register DB-related commands under 'db'.
+
+  db load schema
   """
-  parser = argparse.ArgumentParser(prog=program_name, add_help=False)
-  parser.add_argument("-V","--Version", action="store_true", help="print version")
+  ap_db = subparsers.add_parser("db")
+  db_sub = ap_db.add_subparsers(dest="db_verb")
 
-  subparsers = parser.add_subparsers(dest="verb")
+  ap = db_sub.add_parser("load")
+  ap.add_argument("what", choices=["schema"])
 
-  register_subu_commands(subparsers)
-  register_wireguard_commands(subparsers)
-  register_attach_commands(subparsers)
-  register_network_commands(subparsers)
-  register_option_commands(subparsers)
-  register_exec_commands(subparsers)
-
-  return parser
 
 def register_subu_commands(subparsers):
-  """
-  Register subu related commands:
-    init, make, remove, list, info, information, lo
+  """Register subu related commands under 'subu':
 
-  The subu path is:
-
-    masu subu subu ...
-
-  For example:
-    subu make Thomas S0
-    subu make Thomas S0 S1
+    subu make <masu> <subu> [<subu>]*
+    subu remove <Subu_ID> | <masu> <subu> [<subu>]*
+    subu list
+    subu info <Subu_ID> | <masu> <subu> [<subu>]*
   """
-  # init
-  ap = subparsers.add_parser("init")
+  ap_subu = subparsers.add_parser("subu")
+  subu_sub = ap_subu.add_subparsers(dest="subu_verb")
 
   # make: path[0] is masu, remaining elements are the subu chain
-  ap = subparsers.add_parser("make")
-  ap.add_argument("path", nargs ="+")  # [masu, subu, subu, ...]
+  ap = subu_sub.add_parser("make")
+  ap.add_argument("path", nargs="+")
 
-  # remove: same path structure
-  ap = subparsers.add_parser("remove")
-  ap.add_argument("path", nargs ="+")  # [masu, subu, subu, ...]
+  # remove: either ID or path
+  ap = subu_sub.add_parser("remove")
+  ap.add_argument("target")
+  ap.add_argument("rest", nargs="*")
 
   # list
-  subparsers.add_parser("list")
+  subu_sub.add_parser("list")
 
-  # info / information
-  ap = subparsers.add_parser("info")
-  ap.add_argument("subu_id")
-  ap = subparsers.add_parser("information")
-  ap.add_argument("subu_id")
+  # info
+  ap = subu_sub.add_parser("info")
+  ap.add_argument("target")
+  ap.add_argument("rest", nargs="*")
 
-  # lo
-  ap = subparsers.add_parser("lo")
-<  ap.add_argument("state", choices =["up","down"])
-  ap.add_argument("subu_id")
 
 def register_wireguard_commands(subparsers):
-  """
-  Register WireGuard related commands, grouped under 'WG':
+  """Register WireGuard related commands, grouped under 'WG':
+
     WG global <BaseCIDR>
     WG make <host:port>
     WG server_provided_public_key <WG_ID> <Base64Key>
@@ -101,8 +85,8 @@ def register_wireguard_commands(subparsers):
 
 
 def register_attach_commands(subparsers):
-  """
-  Register attach and detach commands:
+  """Register attach and detach commands:
+
     attach WG <Subu_ID> <WG_ID>
     detach WG <Subu_ID>
   """
@@ -117,44 +101,90 @@ def register_attach_commands(subparsers):
 
 
 def register_network_commands(subparsers):
-  """
-  Register network aggregate commands:
+  """Register network aggregate commands:
+
     network up|down <Subu_ID>
   """
   ap = subparsers.add_parser("network")
-  ap.add_argument("state", choices=["up","down"])
+  ap.add_argument("state", choices=["up", "down"])
   ap.add_argument("subu_id")
 
 
 def register_option_commands(subparsers):
-  """
-  Register option commands:
-    option set|get|list ...
+  """Register option commands.
+
+  Current surface:
+    option Unix <mode>       # e.g. dry|run
   """
   ap = subparsers.add_parser("option")
-  ap.add_argument("action", choices=["set","get","list"])
-  ap.add_argument("subu_id")
-  ap.add_argument("name", nargs="?")
-  ap.add_argument("value", nargs="?")
+  ap.add_argument("area", choices=["Unix"])
+  ap.add_argument("mode")
 
 
 def register_exec_commands(subparsers):
-  """
-  Register exec command:
+  """Register exec command:
+
     exec <Subu_ID> -- <cmd> ...
   """
   ap = subparsers.add_parser("exec")
   ap.add_argument("subu_id")
   # Use a dedicated "--" argument so that:
-  #   subu exec subu_7 -- curl -4v https://ifconfig.me
+  #   CLI.py exec subu_7 -- curl -4v https://ifconfig.me
   # works as before.
   ap.add_argument("--", dest="cmd", nargs=argparse.REMAINDER, default=[])
 
 
-def CLI(argv=None) -> int:
-  """
-  Top level entry point for the subu manager CLI.
+def build_arg_parser(program_name: str) -> argparse.ArgumentParser:
+  """Build the top level argument parser for the subu manager."""
+  parser = argparse.ArgumentParser(prog=program_name, add_help=False)
+  parser.add_argument("-V", "--Version", action="store_true", help="print version")
+
+  subparsers = parser.add_subparsers(dest="verb")
+
+  register_db_commands(subparsers)
+  register_subu_commands(subparsers)
+  register_wireguard_commands(subparsers)
+  register_attach_commands(subparsers)
+  register_network_commands(subparsers)
+  register_option_commands(subparsers)
+  register_exec_commands(subparsers)
+
+  return parser
+
+
+def _collect_parse_errors(ns, program_name: str) -> list[str]:
+  """Check for semantic argument problems and collect error strings.
+
+  We keep this lightweight and focused on things we can know without
+  touching the filesystem or the database.
   """
+  errors: list[str] = []
+
+  if ns.verb == "subu":
+    sv = getattr(ns, "subu_verb", None)
+    if sv == "make":
+      if not ns.path or len(ns.path) < 2:
+        errors.append(
+          "subu make requires at least <masu> and one <subu> component"
+        )
+    elif sv in ("remove", "info"):
+      # Either ID or path. For path we need at least 2 tokens.
+      if ns.target.startswith("subu_"):
+        if ns.verb == "subu" and sv in ("remove", "info") and ns.rest:
+          errors.append(
+            f"{program_name} subu {sv} with an ID form must not have extra path tokens"
+          )
+      else:
+        if len([ns.target] + list(ns.rest)) < 2:
+          errors.append(
+            f"{program_name} subu {sv} <masu> <subu> [<subu> ...] requires at least two tokens"
+          )
+
+  return errors
+
+
+def CLI(argv=None) -> int:
+  """Top level entry point for the subu manager CLI."""
   if argv is None:
     argv = sys.argv[1:]
 
@@ -162,10 +192,6 @@ def CLI(argv=None) -> int:
   #
   # 1. If SUBU_PROGNAME is set in the environment, use that.
   # 2. Otherwise, derive it from sys.argv[0] (basename).
-  #
-  # This way:
-  #   - tester calling "CLI.py" sees "CLI.py" in help/usage.
-  #   - a future wrapper called "subu" will show "subu".
   prog_override = os.environ.get("SUBU_PROGNAME")
   if prog_override:
     program_name = prog_override
@@ -177,7 +203,7 @@ def CLI(argv=None) -> int:
 
   # No arguments is the same as "help".
   if not argv:
-    print(text.help(), end ="")
+    print(text.usage(), end="")
     return 0
 
   # Simple verbs that bypass argparse so they always work.
@@ -190,40 +216,43 @@ def CLI(argv=None) -> int:
     "version": text.version,
   }
   if argv[0] in simple:
-    print(simple[argv[0]](), end ="")
+    print(simple[argv[0]](), end="")
     return 0
 
   parser = build_arg_parser(program_name)
   ns = parser.parse_args(argv)
 
   if getattr(ns, "Version", False):
-    print(text.version(), end ="")
+    print(text.version(), end="")
     return 0
 
-  try:
-    if ns.verb == "init":
-      return dispatch.init()
-
-    if ns.verb == "make":
-      # ns.path is ['masu', 'subu', ...]
-      return dispatch.subu_make(ns.path)
-
-    if ns.verb == "remove":
-      return dispatch.subu_remove(ns.path)
-
-    if ns.verb == "list":
-      return dispatch.subu_list()
-
-    if ns.verb in ("info","information"):
-      return dispatch.subu_info(ns.subu_id)
+  # Collect semantic parse errors before we call dispatch.
+  errors = _collect_parse_errors(ns, program_name)
+  if errors:
+    for msg in errors:
+      print(f"error: {msg}", file=sys.stderr)
+    return 2
 
-    if ns.verb == "lo":
-      return dispatch.lo_toggle(ns.subu_id, ns.state)
+  try:
+    if ns.verb == "db":
+      if ns.db_verb == "load" and ns.what == "schema":
+        return dispatch.db_load_schema()
+
+    if ns.verb == "subu":
+      sv = ns.subu_verb
+      if sv == "make":
+        return dispatch.subu_make(ns.path)
+      if sv == "list":
+        return dispatch.subu_list()
+      if sv == "info":
+        return dispatch.subu_info(ns.target, ns.rest)
+      if sv == "remove":
+        return dispatch.subu_remove(ns.target, ns.rest)
 
     if ns.verb == "WG":
       v = ns.wg_verb
-      if v in ("info","information") and ns.arg1 is None:
-        print("WG info requires WG_ID", file =sys.stderr)
+      if v in ("info", "information") and ns.arg1 is None:
+        print("WG info requires WG_ID", file=sys.stderr)
         return 2
       if v == "global":
         return dispatch.wg_global(ns.arg1)
@@ -231,7 +260,7 @@ def CLI(argv=None) -> int:
         return dispatch.wg_make(ns.arg1)
       if v == "server_provided_public_key":
         return dispatch.wg_server_public_key(ns.arg1, ns.arg2)
-      if v in ("info","information"):
+      if v in ("info", "information"):
         return dispatch.wg_info(ns.arg1)
       if v == "up":
         return dispatch.wg_up(ns.arg1)
@@ -255,17 +284,18 @@ def CLI(argv=None) -> int:
 
     if ns.verb == "exec":
       if not ns.cmd:
-        print(f"{program_name} exec <Subu_ID> -- <cmd> ...", file =sys.stderr)
+        print(f"{program_name} exec <Subu_ID> -- <cmd> ...", file=sys.stderr)
         return 2
       return dispatch.exec(ns.subu_id, ns.cmd)
 
     # If we reach here, the verb was not recognised.
-    print(text.usage(), end ="")
+    print(text.usage(), end="")
     return 2
 
   except Exception as e:
-    print(f"error: {e}", file =sys.stderr)
+    print(f"error: {e}", file=sys.stderr)
     return 1
 
+
 if __name__ == "__main__":
   sys.exit(CLI())
index 707f85b..cb59d47 100644 (file)
 # dispatch.py
 # -*- mode: python; coding: utf-8; python-indent-offset: 2; indent-tabs-mode: nil -*-
 
-import os, sys
+import os, sys, sqlite3
 import env
 from domain import subu as subu_domain
 from infrastructure.db import open_db, ensure_schema
 from infrastructure.options_store import set_option
 
 
-def init():
+def _require_root(action: str) -> bool:
+  """Return True if running as root, else print error and return False."""
+  try:
+    euid = os.geteuid()
+  except AttributeError:
+    # Non-POSIX; be permissive.
+    return True
+  if euid != 0:
+    print(f"{action}: must be run as root", file=sys.stderr)
+    return False
+  return True
+
+
+def _db_path() -> str:
+  return env.db_path()
+
+
+def _open_existing_db() -> sqlite3.Connection | None:
+  """Open the existing manager DB or print an error and return None.
+
+  This does *not* create the DB; callers should ensure that
+  'db load schema' has been run first.
   """
-  Handle: subu init <TOKEN>
+  path = _db_path()
+  if not os.path.exists(path):
+    print(
+      f"subu: database does not exist at '{path}'.\n"
+      f"       Run 'db load schema' as root first.",
+      file=sys.stderr,
+    )
+    return None
+  try:
+    conn = open_db(path)
+  except Exception as e:
+    print(f"subu: unable to open database '{path}': {e}", file=sys.stderr)
+    return None
+
+  # Use row objects so we can access columns by name.
+  conn.row_factory = sqlite3.Row
+  return conn
 
-  For now, TOKEN is unused. The behavior is:
 
-    * if subu.db exists, refuse to overwrite it
-    * if it does not exist, make it and apply schema.sql
+def db_load_schema() -> int:
+  """Handle: CLI.py db load schema
+
+  Ensure the DB directory exists, open the DB, and apply schema.sql.
   """
-  db_path = env.db_path()
-  if os.path.exists(db_path):
-    print("subu.db already exists; refusing to overwrite", file =sys.stderr)
+  if not _require_root("db load schema"):
+    return 1
+
+  path = _db_path()
+  db_dir = os.path.dirname(path) or "."
+
+  try:
+    os.makedirs(db_dir, mode=0o750, exist_ok=True)
+  except PermissionError as e:
+    print(f"subu: cannot create db directory '{db_dir}': {e}", file=sys.stderr)
+    return 1
+
+  try:
+    conn = open_db(path)
+  except Exception as e:
+    print(f"subu: unable to open database '{path}': {e}", file=sys.stderr)
     return 1
 
-  conn = open_db(db_path)
   try:
     ensure_schema(conn)
   finally:
     conn.close()
+
+  print(f"subu: schema loaded into {path}")
   return 0
 
+
 def subu_make(path_tokens: list[str]) -> int:
-  """
-  Handle: subu make <masu> <subu> [<subu> ...]
+  """Handle: CLI.py subu make <masu> <subu> [<subu> ...]
 
   path_tokens is:
     [masu, subu, subu, ...]
 
   Example:
-    subu make Thomas S0
-    subu make Thomas S0 S1
+    CLI.py subu make Thomas developer
+    CLI.py subu make Thomas developer bolt
   """
   if not path_tokens or len(path_tokens) < 2:
     print(
       "subu: make requires at least <masu> and one <subu> component",
-      file =sys.stderr,
+      file=sys.stderr,
     )
     return 2
 
+  if not _require_root("subu make"):
+    return 1
+
   masu = path_tokens[0]
   subu_path = path_tokens[1:]
 
+  # 1) Create Unix user + groups.
   try:
     username = subu_domain.make_subu(masu, subu_path)
-    print(f"made subu unix user '{username}'")
-    return 0
   except SystemExit as e:
-    # domain layer uses SystemExit for user-facing validation errors
-    print(str(e), file =sys.stderr)
+    # Domain layer uses SystemExit for validation errors.
+    print(f"subu: {e}", file=sys.stderr)
     return 2
   except Exception as e:
-    print(f"error making subu: {e}", file =sys.stderr)
+    print(f"subu: error creating Unix user for {path_tokens}: {e}", file=sys.stderr)
     return 1
 
-def subu_remove(path_tokens: list[str]) -> int:
-  """
-  Handle: subu remove <masu> <subu> [<subu> ...]
+  # 2) Record in SQLite.
+  conn = _open_existing_db()
+  if conn is None:
+    # Unix side succeeded but DB is missing; report and stop.
+    return 1
 
-  path_tokens is:
-    [masu, subu, subu, ...]
-  """
-  if not path_tokens or len(path_tokens) < 2:
-    print(
-      "subu: remove requires at least <masu> and one <subu> component",
-      file =sys.stderr,
-    )
-    return 2
+  owner = masu
+  leaf_name = subu_path[-1]
+  full_unix_name = username
+  path_str = " ".join([masu] + subu_path)
+  netns_name = full_unix_name  # simple deterministic choice for now
 
-  masu = path_tokens[0]
-  subu_path = path_tokens[1:]
+  from datetime import datetime, timezone
+
+  now = datetime.now(timezone.utc).isoformat()
 
   try:
-    username = subu_domain.remove_subu(masu, subu_path)
-    print(f"removed subu unix user '{username}'")
-    return 0
-  except SystemExit as e:
-    print(str(e), file =sys.stderr)
-    return 2
+    cur = conn.execute(
+      """INSERT INTO subu
+            (owner, name, full_unix_name, path, netns_name, wg_id, created_at, updated_at)
+            VALUES (?, ?, ?, ?, ?, NULL, ?, ?)""",
+      (owner, leaf_name, full_unix_name, path_str, netns_name, now, now),
+    )
+    conn.commit()
+    subu_id = cur.lastrowid
+  except sqlite3.IntegrityError as e:
+    print(f"subu: database already has an entry for '{full_unix_name}': {e}", file=sys.stderr)
+    conn.close()
+    return 1
   except Exception as e:
-    print(f"error removing subu: {e}", file =sys.stderr)
+    print(f"subu: error recording subu in database: {e}", file=sys.stderr)
+    conn.close()
     return 1
 
-def option_unix(mode: str) -> int:
+  conn.close()
+
+  print(f"subu_{subu_id}")
+  return 0
+
+
+def _resolve_subu(conn: sqlite3.Connection, target: str, rest: list[str]) -> sqlite3.Row | None:
+  """Resolve a subu either by ID (subu_7) or by path.
+
+  ID form:
+    target = 'subu_7', rest = []
+
+  Path form:
+    target = masu, rest = [subu, subu, ...]
   """
-  Handle: subu option Unix dry|run
+  # ID form: subu_7
+  if target.startswith("subu_") and not rest:
+    try:
+      subu_numeric_id = int(target.split("_", 1)[1])
+    except ValueError:
+      print(f"subu: invalid Subu_ID '{target}'", file=sys.stderr)
+      return None
+
+    row = conn.execute("SELECT * FROM subu WHERE id = ?", (subu_numeric_id,)).fetchone()
+    if row is None:
+      print(f"subu: no such subu with id {subu_numeric_id}", file=sys.stderr)
+    return row
+
+  # Path form
+  path_tokens = [target] + list(rest)
+  if len(path_tokens) < 2:
+    print(
+      "subu: path form requires at least <masu> and one <subu> component",
+      file=sys.stderr,
+    )
+    return None
 
-  Example:
-    subu option Unix dry
-    subu option Unix run
+  owner = path_tokens[0]
+  path_str = " ".join(path_tokens)
+
+  row = conn.execute(
+    "SELECT * FROM subu WHERE owner = ? AND path = ?",
+    (owner, path_str),
+  ).fetchone()
+
+  if row is None:
+    print(f"subu: no such subu with owner='{owner}' and path='{path_str}'", file=sys.stderr)
+  return row
+
+
+def subu_list() -> int:
+  """Handle: CLI.py subu list"""
+  conn = _open_existing_db()
+  if conn is None:
+    return 1
+
+  cur = conn.execute(
+    "SELECT id, owner, path, full_unix_name, netns_name, wg_id FROM subu ORDER BY id"
+  )
+
+  rows = cur.fetchall()
+  conn.close()
+
+  if not rows:
+    print("(no subu in database)")
+    return 0
+
+  for row in rows:
+    subu_id = row[0]
+    owner = row[1]
+    path = row[2]
+    full_unix_name = row[3]
+    netns_name = row[4]
+    wg_id = row[5]
+    wg_display = "-" if wg_id is None else f"WG_{wg_id}"
+    print(f"subu_{subu_id}\t{owner}\t{path}\t{full_unix_name}\t{netns_name}\t{wg_display}")
+
+  return 0
+
+
+def subu_info(target: str, rest: list[str]) -> int:
+  """Handle: CLI.py subu info <Subu_ID>|<masu> <subu> [<subu> ...]
+
+  Examples:
+    CLI.py subu info subu_3
+    CLI.py subu info Thomas developer bolt
   """
-  if mode not in ("dry","run"):
-    print(f"unknown Unix mode '{mode}', expected 'dry' or 'run'", file =sys.stderr)
-    return 2
-  set_option("Unix.mode", mode)
-  print(f"Unix mode set to {mode}")
+  conn = _open_existing_db()
+  if conn is None:
+    return 1
+
+  row = _resolve_subu(conn, target, rest)
+  if row is None:
+    conn.close()
+    return 1
+
+  subu_id = row["id"]
+  owner = row["owner"]
+  name = row["name"]
+  full_unix_name = row["full_unix_name"]
+  path = row["path"]
+  netns_name = row["netns_name"]
+  wg_id = row["wg_id"]
+  created_at = row["created_at"]
+  updated_at = row["updated_at"]
+
+  conn.close()
+
+  print(f"Subu_ID:    subu_{subu_id}")
+  print(f"Owner:      {owner}")
+  print(f"Name:       {name}")
+  print(f"Path:       {path}")
+  print(f"Unix user:  {full_unix_name}")
+  print(f"Netns:      {netns_name}")
+  print(f"WG_ID:      {wg_id if wg_id is not None else '-'}")
+  print(f"Created:    {created_at}")
+  print(f"Updated:    {updated_at}")
   return 0
 
 
-# The remaining commands can stay as stubs for now.
-# They are left so the CLI imports succeed, but will raise if used.
+def subu_remove(target: str, rest: list[str]) -> int:
+  """Handle: CLI.py subu remove <Subu_ID>|<masu> <subu> [<subu> ...]
 
-def subu_list():
-  raise NotImplementedError("subu_list is not yet made")
+  This removes both:
+    - the Unix user/group associated with the subu, and
+    - the corresponding row from the database.
+  """
+  if not _require_root("subu remove"):
+    return 1
 
+  conn = _open_existing_db()
+  if conn is None:
+    return 1
 
-def subu_info(subu_id):
-  raise NotImplementedError("subu_info is not yet made")
+  row = _resolve_subu(conn, target, rest)
+  if row is None:
+    conn.close()
+    return 1
 
+  subu_id = row["id"]
+  owner = row["owner"]
+  path_str = row["path"]
+  path_tokens = path_str.split(" ")
+  if not path_tokens or len(path_tokens) < 2:
+    print(f"subu: stored path is invalid for id {subu_id}: '{path_str}'", file=sys.stderr)
+    conn.close()
+    return 1
 
-def lo_toggle(subu_id, state):
-  raise NotImplementedError("lo_toggle is not yet made")
+  masu = path_tokens[0]
+  subu_path = path_tokens[1:]
+
+  # 1) Remove Unix user + group.
+  try:
+    username = subu_domain.remove_subu(masu, subu_path)
+  except SystemExit as e:
+    print(f"subu: {e}", file=sys.stderr)
+    conn.close()
+    return 2
+  except Exception as e:
+    print(f"subu: error removing Unix user for id subu_{subu_id}: {e}", file=sys.stderr)
+    conn.close()
+    return 1
 
+  # 2) Remove from DB.
+  try:
+    conn.execute("DELETE FROM subu WHERE id = ?", (subu_id,))
+    conn.commit()
+  except Exception as e:
+    print(f"subu: error removing database row for id subu_{subu_id}: {e}", file=sys.stderr)
+    conn.close()
+    return 1
 
-def wg_global(base_cidr):
-  raise NotImplementedError("wg_global is not yet made")
+  conn.close()
 
+  print(f"removed subu_{subu_id} {username}")
+  return 0
 
-def wg_make(endpoint):
-  raise NotImplementedError("wg_make is not yet made")
 
+# Placeholder stubs for existing option / WG / network / exec wiring.
+# These keep the module importable while we focus on subu + db.
 
-def wg_server_public_key(wg_id, key):
-  raise NotImplementedError("wg_server_public_key is not yet made")
+def wg_global(arg1: str | None) -> int:
+  print("WG global: not yet implemented", file=sys.stderr)
+  return 1
 
 
-def wg_info(wg_id):
-  raise NotImplementedError("wg_info is not yet made")
+def wg_make(arg1: str | None) -> int:
+  print("WG make: not yet implemented", file=sys.stderr)
+  return 1
 
 
-def wg_up(wg_id):
-  raise NotImplementedError("wg_up is not yet made")
+def wg_server_public_key(arg1: str | None, arg2: str | None) -> int:
+  print("WG server_provided_public_key: not yet implemented", file=sys.stderr)
+  return 1
 
 
-def wg_down(wg_id):
-  raise NotImplementedError("wg_down is not yet made")
+def wg_info(arg1: str | None) -> int:
+  print("WG info: not yet implemented", file=sys.stderr)
+  return 1
 
 
-def attach_wg(subu_id, wg_id):
-  raise NotImplementedError("attach_wg is not yet made")
+def wg_up(arg1: str | None) -> int:
+  print("WG up: not yet implemented", file=sys.stderr)
+  return 1
 
 
-def detach_wg(subu_id):
-  raise NotImplementedError("detach_wg is not yet made")
+def wg_down(arg1: str | None) -> int:
+  print("WG down: not yet implemented", file=sys.stderr)
+  return 1
 
 
-def network_toggle(subu_id, state):
-  raise NotImplementedError("network_toggle is not yet made")
+def attach_wg(subu_id: str, wg_id: str) -> int:
+  print("attach WG: not yet implemented", file=sys.stderr)
+  return 1
 
 
-def option_set(subu_id, name, value):
-  raise NotImplementedError("option_set is not yet made")
+def detach_wg(subu_id: str) -> int:
+  print("detach WG: not yet implemented", file=sys.stderr)
+  return 1
 
 
-def option_get(subu_id, name):
-  raise NotImplementedError("option_get is not yet made")
+def network_toggle(subu_id: str, state: str) -> int:
+  print("network up/down: not yet implemented", file=sys.stderr)
+  return 1
 
 
-def option_list(subu_id):
-  raise NotImplementedError("option_list is not yet made")
+def option_unix(mode: str) -> int:
+  # example: store a Unix handling mode into options_store
+  set_option("Unix.mode", mode)
+  print(f"Unix mode set to {mode}")
+  return 0
 
 
-def exec(subu_id, cmd_argv):
-  raise NotImplementedError("exec is not yet made")
+def exec(subu_id: str, cmd_argv: list[str]) -> int:
+  print("exec: not yet implemented", file=sys.stderr)
+  return 1
index 55b28a4..37eb66e 100644 (file)
@@ -8,14 +8,14 @@ def version() -> str:
   """
   Software / CLI version.
   """
-  return "0.3.3"
+  return "0.3.4"
 
 
 def db_schema_version() -> str:
   """
   Database schema version (used in the DB filename).
 
-  This only changes when the DB layout/semantics change,
+  Bump this only when the DB layout/semantics change,
   not for every CLI code change.
   """
   return "0.1"
@@ -23,20 +23,14 @@ def db_schema_version() -> str:
 
 def db_root_dir() -> Path:
   """
-  Default directory for the system-wide subu database.
-
-  This is intentionally independent of the project/repo location.
+  Root directory for the manager database.
   """
   return Path("/opt/subu")
 
 
 def db_filename() -> str:
   """
-  Default SQLite database filename, including schema version.
-
-    subu_<schema>.sqlite3
-
-  Example: subu_0.1.sqlite3
+  Filename of the SQLite database, relative to db_root_dir.
   """
   return f"subu_{db_schema_version()}.sqlite3"
 
index 9800c45..e4ef48c 100644 (file)
@@ -13,14 +13,26 @@ def schema_path_default():
   return Path(__file__).with_name("schema.sql")
 
 
-def open_db(path =None):
+def open_db(path=None):
   """
   Return a sqlite3.Connection with sensible pragmas.
   Caller is responsible for closing.
+
+  If path is None, the canonical manager DB path from env.db_path()
+  is used. The parent directory is created if it does not exist.
   """
   if path is None:
     path = env.db_path()
-  conn = sqlite3.connect(path)
+
+  path_obj = Path(path)
+  parent = path_obj.parent
+
+  try:
+    parent.mkdir(parents=True, exist_ok=True)
+  except PermissionError as e:
+    raise RuntimeError(f"cannot create DB directory '{parent}': {e}") from e
+
+  conn = sqlite3.connect(str(path_obj))
   conn.row_factory = sqlite3.Row
   conn.execute("PRAGMA foreign_keys = ON")
   conn.execute("PRAGMA journal_mode = WAL")
@@ -33,6 +45,6 @@ def ensure_schema(conn):
   Ensure the schema in schema.sql is applied.
   This is idempotent: executing the DDL again is acceptable.
   """
-  sql = schema_path_default().read_text(encoding ="utf-8")
+  sql = schema_path_default().read_text(encoding="utf-8")
   conn.executescript(sql)
   conn.commit()
index 127b724..f39abc9 100644 (file)
 # text.py
+# -*- mode: python; coding: utf-8; python-indent-offset: 2; indent-tabs-mode: nil -*-
 
-from env import version as current_version
-
-
-class Text:
-  """
-  Program text bound to a specific command name.
-
-  Usage:
-    text_1 = Text("subu")
-    text_2 = Text("manager")
-
-    print(text_1.usage())
-    print(text_2.help())
-  """
-
-  def __init__(self, program_name ="subu"):
-    self.program_name = program_name
-
-  def usage(self):
-    program_name = self.program_name
-    return f"""{program_name} — Subu manager (v{current_version()})
-
-Usage:
-  {program_name}                   # usage
-  {program_name} help              # detailed help
-  {program_name} example           # example workflow
-  {program_name} version           # print version
-
-  {program_name} init
-  {program_name} make <masu> <subu> [_<subu>]*
-  {program_name} list
-  {program_name} info <Subu_ID> | {program_name} information <Subu_ID>
-
-  {program_name} lo up|down <Subu_ID>
-
-  {program_name} WG global <BaseCIDR>
-  {program_name} WG make <host:port>
-  {program_name} WG server_provided_public_key <WG_ID> <Base64Key>
-  {program_name} WG info|information <WG_ID>
-  {program_name} WG up <WG_ID>
-  {program_name} WG down <WG_ID>
-
-  {program_name} attach WG <Subu_ID> <WG_ID>
-  {program_name} detach WG <Subu_ID>
-
-  {program_name} network up|down <Subu_ID>
-
-  {program_name} option set <Subu_ID> <subu> <value>
-  {program_name} option get <Subu_ID> <subu>
-  {program_name} option list <Subu_ID>
-
-  {program_name} exec <Subu_ID> -- <cmd> ...
 """
-
-  def help(self, verbose =False):
-    program_name = self.program_name
-    return f"""Subu manager (v{current_version()})
-
-1) Init
-  {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 <masu> <subu> [_<subu>]*
-  {program_name} list
-  {program_name} info <Subu_ID>
-
-3) Loopback
-  {program_name} lo up|down <Subu_ID>
-
-4) WireGuard objects (independent of subu)
-  {program_name} WG global <BaseCIDR>                 # for example, 192.168.112.0/24
-  {program_name} WG make <host:port>                # allocates next /32
-  {program_name} WG server_provided_public_key <WG_ID> <Base64Key>
-  {program_name} WG info <WG_ID>
-  {program_name} WG up <WG_ID> / {program_name} WG down <WG_ID> # administrative toggle after attached
-
-5) Attach or detach and eBPF steering
-  {program_name} attach WG <Subu_ID> <WG_ID>
-    - Makes WireGuard device as subu_<M> inside ns-subu_<N>, assigns /32, MTU 1420
-    - Installs per-subu cgroup and loads eBPF scaffold (user identifier check, metadata map)
-    - Keeps device administrative-down until `{program_name} network up`
-  {program_name} detach WG <Subu_ID>
-    - Deletes device, removes cgroup and eBPF program
-
-6) Network aggregate
-  {program_name} network up|down <Subu_ID>
-    - Ensures loopback is up on 'up', toggles attached WireGuard interfaces
-
-7) Options
-  {program_name} option set|get|list ...
-
-8) Exec
-  {program_name} exec <Subu_ID> -- <cmd> ...
+text.py — user-facing text for the subu manager CLI.
 """
 
-  def example(self):
-    program_name = self.program_name
-    return f"""# 0) Initialise the subu database (once per directory)
-{program_name} init
-
-# 1) Make Subu
-{program_name} make Thomas US
-# -> subu_1
-
-# 2) WireGuard pool once
-{program_name} WG global 192.168.112.0/24
-
-# 3) Make WireGuard object with endpoint
-{program_name} WG make ReasoningTechnology.com:51820
-# -> WG_1
-
-# 4) Server public key (placeholder)
-{program_name} WG server_provided_public_key WG_1 ABCDEFG...xyz=
 
-# 5) Attach device and install cgroup and eBPF steering
-{program_name} attach WG subu_1 WG_1
-
-# 6) Bring network up (loopback and WireGuard)
-{program_name} network up subu_1
-
-# 7) Test inside namespace
-{program_name} exec subu_1 -- curl -4v https://ifconfig.me
-"""
-
-  def version(self):
-    return current_version()
-
-
-def make_text(program_name ="subu"):
-  return Text(program_name)
+class _Text:
+  def __init__(self, program_name: str):
+    self.program_name = program_name
+    # Keep version string in one place here for now.
+    self._version = "0.3.4"
+
+  # ---- Public API expected by CLI.py ---------------------------------------
+
+  def version(self) -> str:
+    """
+    Return a short version string suitable for 'PROG version'.
+    """
+    return f"{self._version}\n"
+
+  def usage(self) -> str:
+    """
+    Return a short usage summary including the command surface.
+    """
+    p = self.program_name
+    v = self._version
+    return (
+      f"{p} — Subu manager (v{v})\n"
+      "\n"
+      "Usage:\n"
+      f"  {p}                   # usage\n"
+      f"  {p} help              # detailed help\n"
+      f"  {p} example           # example workflow\n"
+      f"  {p} version           # print version\n"
+      "\n"
+      f"  {p} db load schema\n"
+      "\n"
+      f"  {p} subu make <masu> <subu> [<subu> ...]\n"
+      f"  {p} subu list\n"
+      f"  {p} subu info subu_<id>\n"
+      f"  {p} subu info <masu> <subu> [<subu> ...]\n"
+      f"  {p} subu remove subu_<id>\n"
+      f"  {p} subu remove <masu> <subu> [<subu> ...]\n"
+      "\n"
+      f"  {p} lo up|down <Subu_ID>\n"
+      "\n"
+      f"  {p} WG global <BaseCIDR>\n"
+      f"  {p} WG make <host:port>\n"
+      f"  {p} WG server_provided_public_key <WG_ID> <Base64Key>\n"
+      f"  {p} WG info|information <WG_ID>\n"
+      f"  {p} WG up <WG_ID>\n"
+      f"  {p} WG down <WG_ID>\n"
+      "\n"
+      f"  {p} attach WG <Subu_ID> <WG_ID>\n"
+      f"  {p} detach WG <Subu_ID>\n"
+      "\n"
+      f"  {p} network up|down <Subu_ID>\n"
+      "\n"
+      f"  {p} option set <Subu_ID> <name> <value>\n"
+      f"  {p} option get <Subu_ID> <name>\n"
+      f"  {p} option list <Subu_ID>\n"
+      "\n"
+      f"  {p} exec <Subu_ID> -- <cmd> ...\n"
+    )
+
+  def help(self) -> str:
+    """
+    Return a more detailed help text.
+
+    For now this is usage plus a short explanatory block.
+    """
+    p = self.program_name
+    return (
+      self.usage()
+      + "\n"
+      "Notes:\n"
+      f"  * '{p} db load schema' must be run as root and will create/update the\n"
+      "    manager's SQLite database (schema only).\n"
+      "  * 'subu' commands manage subu records and their corresponding Unix users.\n"
+      "    They accept either a numeric Subu_ID (e.g. 'subu_3') or a path\n"
+      "    (<masu> <subu> [<subu> ...]) where noted.\n"
+      "  * WireGuard, attach/detach, network, option, and exec commands are\n"
+      "    reserved for managing networking and runtime behavior of existing subu.\n"
+      "\n"
+    )
+
+  def example(self) -> str:
+    """
+    Return an example workflow.
+    """
+    p = self.program_name
+    return (
+      f"Example workflow:\n"
+      "\n"
+      f"  # 1. As root, create or update the manager database schema\n"
+      f"  sudo {p} db load schema\n"
+      "\n"
+      f"  # 2. As root, create a developer subu for Thomas\n"
+      f"  sudo {p} subu make Thomas developer\n"
+      "\n"
+      f"  # 3. As root, create a nested subu 'bolt' under Thomas/developer\n"
+      f"  sudo {p} subu make Thomas developer bolt\n"
+      "\n"
+      f"  # 4. As any user, list all known subu\n"
+      f"  {p} subu list\n"
+      "\n"
+      f"  # 5. Show detailed info by path\n"
+      f"  {p} subu info Thomas developer bolt\n"
+      "\n"
+      f"  # 6. Later, remove the nested subu by ID\n"
+      f"  sudo {p} subu remove subu_3\n"
+      "\n"
+    )
+
+
+def make_text(program_name: str) -> _Text:
+  """
+  Factory used by CLI.py to get a text provider for the given program name.
+  """
+  return _Text(program_name)
index 0185450..e3b3dea 100755 (executable)
@@ -1,10 +1,9 @@
 #!/usr/bin/env python3
 # -*- mode: python; coding: utf-8; python-indent-offset: 2; indent-tabs-mode: nil -*-
-"""
-1. CLI.py
- dispatch.
+"""CLI.py — subu manager front-end.
+
+Role: parse argv, choose command, call dispatch.
 
-Role: parse argv, choose command, call
 CLI should not do any work beyond:
 
   * figure out program_name (for example, manager/CLI.py or wrapper name)
@@ -18,66 +17,50 @@ from text import make_text
 import dispatch
 
 
-def build_arg_parser(program_name):
-  """
-  Build the top level argument parser for the subu manager.
+def register_db_commands(subparsers):
+  """Register DB-related commands under 'db'.
+
+  db load schema
   """
-  parser = argparse.ArgumentParser(prog=program_name, add_help=False)
-  parser.add_argument("-V","--Version", action="store_true", help="print version")
+  ap_db = subparsers.add_parser("db")
+  db_sub = ap_db.add_subparsers(dest="db_verb")
 
-  subparsers = parser.add_subparsers(dest="verb")
+  ap = db_sub.add_parser("load")
+  ap.add_argument("what", choices=["schema"])
 
-  register_subu_commands(subparsers)
-  register_wireguard_commands(subparsers)
-  register_attach_commands(subparsers)
-  register_network_commands(subparsers)
-  register_option_commands(subparsers)
-  register_exec_commands(subparsers)
-
-  return parser
 
 def register_subu_commands(subparsers):
-  """
-  Register subu related commands:
-    init, make, remove, list, info, information, lo
+  """Register subu related commands under 'subu':
 
-  The subu path is:
-
-    masu subu subu ...
-
-  For example:
-    subu make Thomas S0
-    subu make Thomas S0 S1
+    subu make <masu> <subu> [<subu>]*
+    subu remove <Subu_ID> | <masu> <subu> [<subu>]*
+    subu list
+    subu info <Subu_ID> | <masu> <subu> [<subu>]*
   """
-  # init
-  ap = subparsers.add_parser("init")
-  ap.add_argument("token", nargs ="?")
+  ap_subu = subparsers.add_parser("subu")
+  subu_sub = ap_subu.add_subparsers(dest="subu_verb")
 
   # make: path[0] is masu, remaining elements are the subu chain
-  ap = subparsers.add_parser("make")
-  ap.add_argument("path", nargs ="+")  # [masu, subu, subu, ...]
+  ap = subu_sub.add_parser("make")
+  ap.add_argument("path", nargs="+")
 
-  # remove: same path structure
-  ap = subparsers.add_parser("remove")
-  ap.add_argument("path", nargs ="+")  # [masu, subu, subu, ...]
+  # remove: either ID or path
+  ap = subu_sub.add_parser("remove")
+  ap.add_argument("target")
+  ap.add_argument("rest", nargs="*")
 
   # list
-  subparsers.add_parser("list")
+  subu_sub.add_parser("list")
 
-  # info / information
-  ap = subparsers.add_parser("info")
-  ap.add_argument("subu_id")
-  ap = subparsers.add_parser("information")
-  ap.add_argument("subu_id")
+  # info
+  ap = subu_sub.add_parser("info")
+  ap.add_argument("target")
+  ap.add_argument("rest", nargs="*")
 
-  # lo
-  ap = subparsers.add_parser("lo")
-  ap.add_argument("state", choices =["up","down"])
-  ap.add_argument("subu_id")
 
 def register_wireguard_commands(subparsers):
-  """
-  Register WireGuard related commands, grouped under 'WG':
+  """Register WireGuard related commands, grouped under 'WG':
+
     WG global <BaseCIDR>
     WG make <host:port>
     WG server_provided_public_key <WG_ID> <Base64Key>
@@ -102,8 +85,8 @@ def register_wireguard_commands(subparsers):
 
 
 def register_attach_commands(subparsers):
-  """
-  Register attach and detach commands:
+  """Register attach and detach commands:
+
     attach WG <Subu_ID> <WG_ID>
     detach WG <Subu_ID>
   """
@@ -118,44 +101,90 @@ def register_attach_commands(subparsers):
 
 
 def register_network_commands(subparsers):
-  """
-  Register network aggregate commands:
+  """Register network aggregate commands:
+
     network up|down <Subu_ID>
   """
   ap = subparsers.add_parser("network")
-  ap.add_argument("state", choices=["up","down"])
+  ap.add_argument("state", choices=["up", "down"])
   ap.add_argument("subu_id")
 
 
 def register_option_commands(subparsers):
-  """
-  Register option commands:
-    option set|get|list ...
+  """Register option commands.
+
+  Current surface:
+    option Unix <mode>       # e.g. dry|run
   """
   ap = subparsers.add_parser("option")
-  ap.add_argument("action", choices=["set","get","list"])
-  ap.add_argument("subu_id")
-  ap.add_argument("name", nargs="?")
-  ap.add_argument("value", nargs="?")
+  ap.add_argument("area", choices=["Unix"])
+  ap.add_argument("mode")
 
 
 def register_exec_commands(subparsers):
-  """
-  Register exec command:
+  """Register exec command:
+
     exec <Subu_ID> -- <cmd> ...
   """
   ap = subparsers.add_parser("exec")
   ap.add_argument("subu_id")
   # Use a dedicated "--" argument so that:
-  #   subu exec subu_7 -- curl -4v https://ifconfig.me
+  #   CLI.py exec subu_7 -- curl -4v https://ifconfig.me
   # works as before.
   ap.add_argument("--", dest="cmd", nargs=argparse.REMAINDER, default=[])
 
 
-def CLI(argv=None) -> int:
-  """
-  Top level entry point for the subu manager CLI.
+def build_arg_parser(program_name: str) -> argparse.ArgumentParser:
+  """Build the top level argument parser for the subu manager."""
+  parser = argparse.ArgumentParser(prog=program_name, add_help=False)
+  parser.add_argument("-V", "--Version", action="store_true", help="print version")
+
+  subparsers = parser.add_subparsers(dest="verb")
+
+  register_db_commands(subparsers)
+  register_subu_commands(subparsers)
+  register_wireguard_commands(subparsers)
+  register_attach_commands(subparsers)
+  register_network_commands(subparsers)
+  register_option_commands(subparsers)
+  register_exec_commands(subparsers)
+
+  return parser
+
+
+def _collect_parse_errors(ns, program_name: str) -> list[str]:
+  """Check for semantic argument problems and collect error strings.
+
+  We keep this lightweight and focused on things we can know without
+  touching the filesystem or the database.
   """
+  errors: list[str] = []
+
+  if ns.verb == "subu":
+    sv = getattr(ns, "subu_verb", None)
+    if sv == "make":
+      if not ns.path or len(ns.path) < 2:
+        errors.append(
+          "subu make requires at least <masu> and one <subu> component"
+        )
+    elif sv in ("remove", "info"):
+      # Either ID or path. For path we need at least 2 tokens.
+      if ns.target.startswith("subu_"):
+        if ns.verb == "subu" and sv in ("remove", "info") and ns.rest:
+          errors.append(
+            f"{program_name} subu {sv} with an ID form must not have extra path tokens"
+          )
+      else:
+        if len([ns.target] + list(ns.rest)) < 2:
+          errors.append(
+            f"{program_name} subu {sv} <masu> <subu> [<subu> ...] requires at least two tokens"
+          )
+
+  return errors
+
+
+def CLI(argv=None) -> int:
+  """Top level entry point for the subu manager CLI."""
   if argv is None:
     argv = sys.argv[1:]
 
@@ -163,10 +192,6 @@ def CLI(argv=None) -> int:
   #
   # 1. If SUBU_PROGNAME is set in the environment, use that.
   # 2. Otherwise, derive it from sys.argv[0] (basename).
-  #
-  # This way:
-  #   - tester calling "CLI.py" sees "CLI.py" in help/usage.
-  #   - a future wrapper called "subu" will show "subu".
   prog_override = os.environ.get("SUBU_PROGNAME")
   if prog_override:
     program_name = prog_override
@@ -178,7 +203,7 @@ def CLI(argv=None) -> int:
 
   # No arguments is the same as "help".
   if not argv:
-    print(text.help(), end ="")
+    print(text.usage(), end="")
     return 0
 
   # Simple verbs that bypass argparse so they always work.
@@ -191,40 +216,43 @@ def CLI(argv=None) -> int:
     "version": text.version,
   }
   if argv[0] in simple:
-    print(simple[argv[0]](), end ="")
+    print(simple[argv[0]](), end="")
     return 0
 
   parser = build_arg_parser(program_name)
   ns = parser.parse_args(argv)
 
   if getattr(ns, "Version", False):
-    print(text.version(), end ="")
+    print(text.version(), end="")
     return 0
 
-  try:
-    if ns.verb == "init":
-      return dispatch.init(ns.token)
-
-    if ns.verb == "make":
-      # ns.path is ['masu', 'subu', ...]
-      return dispatch.subu_make(ns.path)
-
-    if ns.verb == "remove":
-      return dispatch.subu_remove(ns.path)
-
-    if ns.verb == "list":
-      return dispatch.subu_list()
-
-    if ns.verb in ("info","information"):
-      return dispatch.subu_info(ns.subu_id)
+  # Collect semantic parse errors before we call dispatch.
+  errors = _collect_parse_errors(ns, program_name)
+  if errors:
+    for msg in errors:
+      print(f"error: {msg}", file=sys.stderr)
+    return 2
 
-    if ns.verb == "lo":
-      return dispatch.lo_toggle(ns.subu_id, ns.state)
+  try:
+    if ns.verb == "db":
+      if ns.db_verb == "load" and ns.what == "schema":
+        return dispatch.db_load_schema()
+
+    if ns.verb == "subu":
+      sv = ns.subu_verb
+      if sv == "make":
+        return dispatch.subu_make(ns.path)
+      if sv == "list":
+        return dispatch.subu_list()
+      if sv == "info":
+        return dispatch.subu_info(ns.target, ns.rest)
+      if sv == "remove":
+        return dispatch.subu_remove(ns.target, ns.rest)
 
     if ns.verb == "WG":
       v = ns.wg_verb
-      if v in ("info","information") and ns.arg1 is None:
-        print("WG info requires WG_ID", file =sys.stderr)
+      if v in ("info", "information") and ns.arg1 is None:
+        print("WG info requires WG_ID", file=sys.stderr)
         return 2
       if v == "global":
         return dispatch.wg_global(ns.arg1)
@@ -232,7 +260,7 @@ def CLI(argv=None) -> int:
         return dispatch.wg_make(ns.arg1)
       if v == "server_provided_public_key":
         return dispatch.wg_server_public_key(ns.arg1, ns.arg2)
-      if v in ("info","information"):
+      if v in ("info", "information"):
         return dispatch.wg_info(ns.arg1)
       if v == "up":
         return dispatch.wg_up(ns.arg1)
@@ -256,17 +284,18 @@ def CLI(argv=None) -> int:
 
     if ns.verb == "exec":
       if not ns.cmd:
-        print(f"{program_name} exec <Subu_ID> -- <cmd> ...", file =sys.stderr)
+        print(f"{program_name} exec <Subu_ID> -- <cmd> ...", file=sys.stderr)
         return 2
       return dispatch.exec(ns.subu_id, ns.cmd)
 
     # If we reach here, the verb was not recognised.
-    print(text.usage(), end ="")
+    print(text.usage(), end="")
     return 2
 
   except Exception as e:
-    print(f"error: {e}", file =sys.stderr)
+    print(f"error: {e}", file=sys.stderr)
     return 1
 
+
 if __name__ == "__main__":
   sys.exit(CLI())
index 48b7aeb..cb59d47 100644 (file)
 # dispatch.py
 # -*- mode: python; coding: utf-8; python-indent-offset: 2; indent-tabs-mode: nil -*-
 
-import os, sys
+import os, sys, sqlite3
 import env
 from domain import subu as subu_domain
 from infrastructure.db import open_db, ensure_schema
 from infrastructure.options_store import set_option
 
 
-def init(token =None):
+def _require_root(action: str) -> bool:
+  """Return True if running as root, else print error and return False."""
+  try:
+    euid = os.geteuid()
+  except AttributeError:
+    # Non-POSIX; be permissive.
+    return True
+  if euid != 0:
+    print(f"{action}: must be run as root", file=sys.stderr)
+    return False
+  return True
+
+
+def _db_path() -> str:
+  return env.db_path()
+
+
+def _open_existing_db() -> sqlite3.Connection | None:
+  """Open the existing manager DB or print an error and return None.
+
+  This does *not* create the DB; callers should ensure that
+  'db load schema' has been run first.
   """
-  Handle: subu init <TOKEN>
+  path = _db_path()
+  if not os.path.exists(path):
+    print(
+      f"subu: database does not exist at '{path}'.\n"
+      f"       Run 'db load schema' as root first.",
+      file=sys.stderr,
+    )
+    return None
+  try:
+    conn = open_db(path)
+  except Exception as e:
+    print(f"subu: unable to open database '{path}': {e}", file=sys.stderr)
+    return None
+
+  # Use row objects so we can access columns by name.
+  conn.row_factory = sqlite3.Row
+  return conn
 
-  For now, TOKEN is unused. The behavior is:
 
-    * if subu.db exists, refuse to overwrite it
-    * if it does not exist, make it and apply schema.sql
+def db_load_schema() -> int:
+  """Handle: CLI.py db load schema
+
+  Ensure the DB directory exists, open the DB, and apply schema.sql.
   """
-  db_path = env.db_path()
-  if os.path.exists(db_path):
-    print("subu.db already exists; refusing to overwrite", file =sys.stderr)
+  if not _require_root("db load schema"):
+    return 1
+
+  path = _db_path()
+  db_dir = os.path.dirname(path) or "."
+
+  try:
+    os.makedirs(db_dir, mode=0o750, exist_ok=True)
+  except PermissionError as e:
+    print(f"subu: cannot create db directory '{db_dir}': {e}", file=sys.stderr)
+    return 1
+
+  try:
+    conn = open_db(path)
+  except Exception as e:
+    print(f"subu: unable to open database '{path}': {e}", file=sys.stderr)
     return 1
 
-  conn = open_db(db_path)
   try:
     ensure_schema(conn)
   finally:
     conn.close()
+
+  print(f"subu: schema loaded into {path}")
   return 0
 
+
 def subu_make(path_tokens: list[str]) -> int:
-  """
-  Handle: subu make <masu> <subu> [<subu> ...]
+  """Handle: CLI.py subu make <masu> <subu> [<subu> ...]
 
   path_tokens is:
     [masu, subu, subu, ...]
 
   Example:
-    subu make Thomas S0
-    subu make Thomas S0 S1
+    CLI.py subu make Thomas developer
+    CLI.py subu make Thomas developer bolt
   """
   if not path_tokens or len(path_tokens) < 2:
     print(
       "subu: make requires at least <masu> and one <subu> component",
-      file =sys.stderr,
+      file=sys.stderr,
     )
     return 2
 
+  if not _require_root("subu make"):
+    return 1
+
   masu = path_tokens[0]
   subu_path = path_tokens[1:]
 
+  # 1) Create Unix user + groups.
   try:
     username = subu_domain.make_subu(masu, subu_path)
-    print(f"made subu unix user '{username}'")
-    return 0
   except SystemExit as e:
-    # domain layer uses SystemExit for user-facing validation errors
-    print(str(e), file =sys.stderr)
+    # Domain layer uses SystemExit for validation errors.
+    print(f"subu: {e}", file=sys.stderr)
     return 2
   except Exception as e:
-    print(f"error making subu: {e}", file =sys.stderr)
+    print(f"subu: error creating Unix user for {path_tokens}: {e}", file=sys.stderr)
     return 1
 
-def subu_remove(path_tokens: list[str]) -> int:
-  """
-  Handle: subu remove <masu> <subu> [<subu> ...]
+  # 2) Record in SQLite.
+  conn = _open_existing_db()
+  if conn is None:
+    # Unix side succeeded but DB is missing; report and stop.
+    return 1
 
-  path_tokens is:
-    [masu, subu, subu, ...]
-  """
-  if not path_tokens or len(path_tokens) < 2:
-    print(
-      "subu: remove requires at least <masu> and one <subu> component",
-      file =sys.stderr,
-    )
-    return 2
+  owner = masu
+  leaf_name = subu_path[-1]
+  full_unix_name = username
+  path_str = " ".join([masu] + subu_path)
+  netns_name = full_unix_name  # simple deterministic choice for now
 
-  masu = path_tokens[0]
-  subu_path = path_tokens[1:]
+  from datetime import datetime, timezone
+
+  now = datetime.now(timezone.utc).isoformat()
 
   try:
-    username = subu_domain.remove_subu(masu, subu_path)
-    print(f"removed subu unix user '{username}'")
-    return 0
-  except SystemExit as e:
-    print(str(e), file =sys.stderr)
-    return 2
+    cur = conn.execute(
+      """INSERT INTO subu
+            (owner, name, full_unix_name, path, netns_name, wg_id, created_at, updated_at)
+            VALUES (?, ?, ?, ?, ?, NULL, ?, ?)""",
+      (owner, leaf_name, full_unix_name, path_str, netns_name, now, now),
+    )
+    conn.commit()
+    subu_id = cur.lastrowid
+  except sqlite3.IntegrityError as e:
+    print(f"subu: database already has an entry for '{full_unix_name}': {e}", file=sys.stderr)
+    conn.close()
+    return 1
   except Exception as e:
-    print(f"error removing subu: {e}", file =sys.stderr)
+    print(f"subu: error recording subu in database: {e}", file=sys.stderr)
+    conn.close()
     return 1
 
-def option_unix(mode: str) -> int:
+  conn.close()
+
+  print(f"subu_{subu_id}")
+  return 0
+
+
+def _resolve_subu(conn: sqlite3.Connection, target: str, rest: list[str]) -> sqlite3.Row | None:
+  """Resolve a subu either by ID (subu_7) or by path.
+
+  ID form:
+    target = 'subu_7', rest = []
+
+  Path form:
+    target = masu, rest = [subu, subu, ...]
   """
-  Handle: subu option Unix dry|run
+  # ID form: subu_7
+  if target.startswith("subu_") and not rest:
+    try:
+      subu_numeric_id = int(target.split("_", 1)[1])
+    except ValueError:
+      print(f"subu: invalid Subu_ID '{target}'", file=sys.stderr)
+      return None
+
+    row = conn.execute("SELECT * FROM subu WHERE id = ?", (subu_numeric_id,)).fetchone()
+    if row is None:
+      print(f"subu: no such subu with id {subu_numeric_id}", file=sys.stderr)
+    return row
+
+  # Path form
+  path_tokens = [target] + list(rest)
+  if len(path_tokens) < 2:
+    print(
+      "subu: path form requires at least <masu> and one <subu> component",
+      file=sys.stderr,
+    )
+    return None
 
-  Example:
-    subu option Unix dry
-    subu option Unix run
+  owner = path_tokens[0]
+  path_str = " ".join(path_tokens)
+
+  row = conn.execute(
+    "SELECT * FROM subu WHERE owner = ? AND path = ?",
+    (owner, path_str),
+  ).fetchone()
+
+  if row is None:
+    print(f"subu: no such subu with owner='{owner}' and path='{path_str}'", file=sys.stderr)
+  return row
+
+
+def subu_list() -> int:
+  """Handle: CLI.py subu list"""
+  conn = _open_existing_db()
+  if conn is None:
+    return 1
+
+  cur = conn.execute(
+    "SELECT id, owner, path, full_unix_name, netns_name, wg_id FROM subu ORDER BY id"
+  )
+
+  rows = cur.fetchall()
+  conn.close()
+
+  if not rows:
+    print("(no subu in database)")
+    return 0
+
+  for row in rows:
+    subu_id = row[0]
+    owner = row[1]
+    path = row[2]
+    full_unix_name = row[3]
+    netns_name = row[4]
+    wg_id = row[5]
+    wg_display = "-" if wg_id is None else f"WG_{wg_id}"
+    print(f"subu_{subu_id}\t{owner}\t{path}\t{full_unix_name}\t{netns_name}\t{wg_display}")
+
+  return 0
+
+
+def subu_info(target: str, rest: list[str]) -> int:
+  """Handle: CLI.py subu info <Subu_ID>|<masu> <subu> [<subu> ...]
+
+  Examples:
+    CLI.py subu info subu_3
+    CLI.py subu info Thomas developer bolt
   """
-  if mode not in ("dry","run"):
-    print(f"unknown Unix mode '{mode}', expected 'dry' or 'run'", file =sys.stderr)
-    return 2
-  set_option("Unix.mode", mode)
-  print(f"Unix mode set to {mode}")
+  conn = _open_existing_db()
+  if conn is None:
+    return 1
+
+  row = _resolve_subu(conn, target, rest)
+  if row is None:
+    conn.close()
+    return 1
+
+  subu_id = row["id"]
+  owner = row["owner"]
+  name = row["name"]
+  full_unix_name = row["full_unix_name"]
+  path = row["path"]
+  netns_name = row["netns_name"]
+  wg_id = row["wg_id"]
+  created_at = row["created_at"]
+  updated_at = row["updated_at"]
+
+  conn.close()
+
+  print(f"Subu_ID:    subu_{subu_id}")
+  print(f"Owner:      {owner}")
+  print(f"Name:       {name}")
+  print(f"Path:       {path}")
+  print(f"Unix user:  {full_unix_name}")
+  print(f"Netns:      {netns_name}")
+  print(f"WG_ID:      {wg_id if wg_id is not None else '-'}")
+  print(f"Created:    {created_at}")
+  print(f"Updated:    {updated_at}")
   return 0
 
 
-# The remaining commands can stay as stubs for now.
-# They are left so the CLI imports succeed, but will raise if used.
+def subu_remove(target: str, rest: list[str]) -> int:
+  """Handle: CLI.py subu remove <Subu_ID>|<masu> <subu> [<subu> ...]
 
-def subu_list():
-  raise NotImplementedError("subu_list is not yet made")
+  This removes both:
+    - the Unix user/group associated with the subu, and
+    - the corresponding row from the database.
+  """
+  if not _require_root("subu remove"):
+    return 1
 
+  conn = _open_existing_db()
+  if conn is None:
+    return 1
 
-def subu_info(subu_id):
-  raise NotImplementedError("subu_info is not yet made")
+  row = _resolve_subu(conn, target, rest)
+  if row is None:
+    conn.close()
+    return 1
 
+  subu_id = row["id"]
+  owner = row["owner"]
+  path_str = row["path"]
+  path_tokens = path_str.split(" ")
+  if not path_tokens or len(path_tokens) < 2:
+    print(f"subu: stored path is invalid for id {subu_id}: '{path_str}'", file=sys.stderr)
+    conn.close()
+    return 1
 
-def lo_toggle(subu_id, state):
-  raise NotImplementedError("lo_toggle is not yet made")
+  masu = path_tokens[0]
+  subu_path = path_tokens[1:]
+
+  # 1) Remove Unix user + group.
+  try:
+    username = subu_domain.remove_subu(masu, subu_path)
+  except SystemExit as e:
+    print(f"subu: {e}", file=sys.stderr)
+    conn.close()
+    return 2
+  except Exception as e:
+    print(f"subu: error removing Unix user for id subu_{subu_id}: {e}", file=sys.stderr)
+    conn.close()
+    return 1
 
+  # 2) Remove from DB.
+  try:
+    conn.execute("DELETE FROM subu WHERE id = ?", (subu_id,))
+    conn.commit()
+  except Exception as e:
+    print(f"subu: error removing database row for id subu_{subu_id}: {e}", file=sys.stderr)
+    conn.close()
+    return 1
 
-def wg_global(base_cidr):
-  raise NotImplementedError("wg_global is not yet made")
+  conn.close()
 
+  print(f"removed subu_{subu_id} {username}")
+  return 0
 
-def wg_make(endpoint):
-  raise NotImplementedError("wg_make is not yet made")
 
+# Placeholder stubs for existing option / WG / network / exec wiring.
+# These keep the module importable while we focus on subu + db.
 
-def wg_server_public_key(wg_id, key):
-  raise NotImplementedError("wg_server_public_key is not yet made")
+def wg_global(arg1: str | None) -> int:
+  print("WG global: not yet implemented", file=sys.stderr)
+  return 1
 
 
-def wg_info(wg_id):
-  raise NotImplementedError("wg_info is not yet made")
+def wg_make(arg1: str | None) -> int:
+  print("WG make: not yet implemented", file=sys.stderr)
+  return 1
 
 
-def wg_up(wg_id):
-  raise NotImplementedError("wg_up is not yet made")
+def wg_server_public_key(arg1: str | None, arg2: str | None) -> int:
+  print("WG server_provided_public_key: not yet implemented", file=sys.stderr)
+  return 1
 
 
-def wg_down(wg_id):
-  raise NotImplementedError("wg_down is not yet made")
+def wg_info(arg1: str | None) -> int:
+  print("WG info: not yet implemented", file=sys.stderr)
+  return 1
 
 
-def attach_wg(subu_id, wg_id):
-  raise NotImplementedError("attach_wg is not yet made")
+def wg_up(arg1: str | None) -> int:
+  print("WG up: not yet implemented", file=sys.stderr)
+  return 1
 
 
-def detach_wg(subu_id):
-  raise NotImplementedError("detach_wg is not yet made")
+def wg_down(arg1: str | None) -> int:
+  print("WG down: not yet implemented", file=sys.stderr)
+  return 1
 
 
-def network_toggle(subu_id, state):
-  raise NotImplementedError("network_toggle is not yet made")
+def attach_wg(subu_id: str, wg_id: str) -> int:
+  print("attach WG: not yet implemented", file=sys.stderr)
+  return 1
 
 
-def option_set(subu_id, name, value):
-  raise NotImplementedError("option_set is not yet made")
+def detach_wg(subu_id: str) -> int:
+  print("detach WG: not yet implemented", file=sys.stderr)
+  return 1
 
 
-def option_get(subu_id, name):
-  raise NotImplementedError("option_get is not yet made")
+def network_toggle(subu_id: str, state: str) -> int:
+  print("network up/down: not yet implemented", file=sys.stderr)
+  return 1
 
 
-def option_list(subu_id):
-  raise NotImplementedError("option_list is not yet made")
+def option_unix(mode: str) -> int:
+  # example: store a Unix handling mode into options_store
+  set_option("Unix.mode", mode)
+  print(f"Unix mode set to {mode}")
+  return 0
 
 
-def exec(subu_id, cmd_argv):
-  raise NotImplementedError("exec is not yet made")
+def exec(subu_id: str, cmd_argv: list[str]) -> int:
+  print("exec: not yet implemented", file=sys.stderr)
+  return 1
index 55b28a4..37eb66e 100644 (file)
@@ -8,14 +8,14 @@ def version() -> str:
   """
   Software / CLI version.
   """
-  return "0.3.3"
+  return "0.3.4"
 
 
 def db_schema_version() -> str:
   """
   Database schema version (used in the DB filename).
 
-  This only changes when the DB layout/semantics change,
+  Bump this only when the DB layout/semantics change,
   not for every CLI code change.
   """
   return "0.1"
@@ -23,20 +23,14 @@ def db_schema_version() -> str:
 
 def db_root_dir() -> Path:
   """
-  Default directory for the system-wide subu database.
-
-  This is intentionally independent of the project/repo location.
+  Root directory for the manager database.
   """
   return Path("/opt/subu")
 
 
 def db_filename() -> str:
   """
-  Default SQLite database filename, including schema version.
-
-    subu_<schema>.sqlite3
-
-  Example: subu_0.1.sqlite3
+  Filename of the SQLite database, relative to db_root_dir.
   """
   return f"subu_{db_schema_version()}.sqlite3"
 
index 9800c45..e4ef48c 100644 (file)
@@ -13,14 +13,26 @@ def schema_path_default():
   return Path(__file__).with_name("schema.sql")
 
 
-def open_db(path =None):
+def open_db(path=None):
   """
   Return a sqlite3.Connection with sensible pragmas.
   Caller is responsible for closing.
+
+  If path is None, the canonical manager DB path from env.db_path()
+  is used. The parent directory is created if it does not exist.
   """
   if path is None:
     path = env.db_path()
-  conn = sqlite3.connect(path)
+
+  path_obj = Path(path)
+  parent = path_obj.parent
+
+  try:
+    parent.mkdir(parents=True, exist_ok=True)
+  except PermissionError as e:
+    raise RuntimeError(f"cannot create DB directory '{parent}': {e}") from e
+
+  conn = sqlite3.connect(str(path_obj))
   conn.row_factory = sqlite3.Row
   conn.execute("PRAGMA foreign_keys = ON")
   conn.execute("PRAGMA journal_mode = WAL")
@@ -33,6 +45,6 @@ def ensure_schema(conn):
   Ensure the schema in schema.sql is applied.
   This is idempotent: executing the DDL again is acceptable.
   """
-  sql = schema_path_default().read_text(encoding ="utf-8")
+  sql = schema_path_default().read_text(encoding="utf-8")
   conn.executescript(sql)
   conn.commit()
index b6eef09..f39abc9 100644 (file)
 # text.py
+# -*- mode: python; coding: utf-8; python-indent-offset: 2; indent-tabs-mode: nil -*-
 
-from env import version as current_version
-
-
-class Text:
-  """
-  Program text bound to a specific command name.
-
-  Usage:
-    text_1 = Text("subu")
-    text_2 = Text("manager")
-
-    print(text_1.usage())
-    print(text_2.help())
-  """
-
-  def __init__(self, program_name ="subu"):
-    self.program_name = program_name
-
-  def usage(self):
-    program_name = self.program_name
-    return f"""{program_name} — Subu manager (v{current_version()})
-
-Usage:
-  {program_name}                   # usage
-  {program_name} help              # detailed help
-  {program_name} example           # example workflow
-  {program_name} version           # print version
-
-  {program_name} init <TOKEN>
-  {program_name} make <masu> <subu> [_<subu>]*
-  {program_name} list
-  {program_name} info <Subu_ID> | {program_name} information <Subu_ID>
-
-  {program_name} lo up|down <Subu_ID>
-
-  {program_name} WG global <BaseCIDR>
-  {program_name} WG make <host:port>
-  {program_name} WG server_provided_public_key <WG_ID> <Base64Key>
-  {program_name} WG info|information <WG_ID>
-  {program_name} WG up <WG_ID>
-  {program_name} WG down <WG_ID>
-
-  {program_name} attach WG <Subu_ID> <WG_ID>
-  {program_name} detach WG <Subu_ID>
-
-  {program_name} network up|down <Subu_ID>
-
-  {program_name} option set <Subu_ID> <subu> <value>
-  {program_name} option get <Subu_ID> <subu>
-  {program_name} option list <Subu_ID>
-
-  {program_name} exec <Subu_ID> -- <cmd> ...
 """
-
-  def help(self, verbose =False):
-    program_name = self.program_name
-    return f"""Subu manager (v{current_version()})
-
-1) Init
-  {program_name} init <TOKEN>
-    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 <masu> <subu> [_<subu>]*
-  {program_name} list
-  {program_name} info <Subu_ID>
-
-3) Loopback
-  {program_name} lo up|down <Subu_ID>
-
-4) WireGuard objects (independent of subu)
-  {program_name} WG global <BaseCIDR>                 # for example, 192.168.112.0/24
-  {program_name} WG make <host:port>                # allocates next /32
-  {program_name} WG server_provided_public_key <WG_ID> <Base64Key>
-  {program_name} WG info <WG_ID>
-  {program_name} WG up <WG_ID> / {program_name} WG down <WG_ID> # administrative toggle after attached
-
-5) Attach or detach and eBPF steering
-  {program_name} attach WG <Subu_ID> <WG_ID>
-    - Makes WireGuard device as subu_<M> inside ns-subu_<N>, assigns /32, MTU 1420
-    - Installs per-subu cgroup and loads eBPF scaffold (user identifier check, metadata map)
-    - Keeps device administrative-down until `{program_name} network up`
-  {program_name} detach WG <Subu_ID>
-    - Deletes device, removes cgroup and eBPF program
-
-6) Network aggregate
-  {program_name} network up|down <Subu_ID>
-    - Ensures loopback is up on 'up', toggles attached WireGuard interfaces
-
-7) Options
-  {program_name} option set|get|list ...
-
-8) Exec
-  {program_name} exec <Subu_ID> -- <cmd> ...
+text.py — user-facing text for the subu manager CLI.
 """
 
-  def example(self):
-    program_name = self.program_name
-    return f"""# 0) Initialise the subu database (once per directory)
-{program_name} init dzkq7b
-
-# 1) Make Subu
-{program_name} make Thomas US
-# -> subu_1
-
-# 2) WireGuard pool once
-{program_name} WG global 192.168.112.0/24
-
-# 3) Make WireGuard object with endpoint
-{program_name} WG make ReasoningTechnology.com:51820
-# -> WG_1
-
-# 4) Server public key (placeholder)
-{program_name} WG server_provided_public_key WG_1 ABCDEFG...xyz=
 
-# 5) Attach device and install cgroup and eBPF steering
-{program_name} attach WG subu_1 WG_1
-
-# 6) Bring network up (loopback and WireGuard)
-{program_name} network up subu_1
-
-# 7) Test inside namespace
-{program_name} exec subu_1 -- curl -4v https://ifconfig.me
-"""
-
-  def version(self):
-    return current_version()
-
-
-def make_text(program_name ="subu"):
-  return Text(program_name)
+class _Text:
+  def __init__(self, program_name: str):
+    self.program_name = program_name
+    # Keep version string in one place here for now.
+    self._version = "0.3.4"
+
+  # ---- Public API expected by CLI.py ---------------------------------------
+
+  def version(self) -> str:
+    """
+    Return a short version string suitable for 'PROG version'.
+    """
+    return f"{self._version}\n"
+
+  def usage(self) -> str:
+    """
+    Return a short usage summary including the command surface.
+    """
+    p = self.program_name
+    v = self._version
+    return (
+      f"{p} — Subu manager (v{v})\n"
+      "\n"
+      "Usage:\n"
+      f"  {p}                   # usage\n"
+      f"  {p} help              # detailed help\n"
+      f"  {p} example           # example workflow\n"
+      f"  {p} version           # print version\n"
+      "\n"
+      f"  {p} db load schema\n"
+      "\n"
+      f"  {p} subu make <masu> <subu> [<subu> ...]\n"
+      f"  {p} subu list\n"
+      f"  {p} subu info subu_<id>\n"
+      f"  {p} subu info <masu> <subu> [<subu> ...]\n"
+      f"  {p} subu remove subu_<id>\n"
+      f"  {p} subu remove <masu> <subu> [<subu> ...]\n"
+      "\n"
+      f"  {p} lo up|down <Subu_ID>\n"
+      "\n"
+      f"  {p} WG global <BaseCIDR>\n"
+      f"  {p} WG make <host:port>\n"
+      f"  {p} WG server_provided_public_key <WG_ID> <Base64Key>\n"
+      f"  {p} WG info|information <WG_ID>\n"
+      f"  {p} WG up <WG_ID>\n"
+      f"  {p} WG down <WG_ID>\n"
+      "\n"
+      f"  {p} attach WG <Subu_ID> <WG_ID>\n"
+      f"  {p} detach WG <Subu_ID>\n"
+      "\n"
+      f"  {p} network up|down <Subu_ID>\n"
+      "\n"
+      f"  {p} option set <Subu_ID> <name> <value>\n"
+      f"  {p} option get <Subu_ID> <name>\n"
+      f"  {p} option list <Subu_ID>\n"
+      "\n"
+      f"  {p} exec <Subu_ID> -- <cmd> ...\n"
+    )
+
+  def help(self) -> str:
+    """
+    Return a more detailed help text.
+
+    For now this is usage plus a short explanatory block.
+    """
+    p = self.program_name
+    return (
+      self.usage()
+      + "\n"
+      "Notes:\n"
+      f"  * '{p} db load schema' must be run as root and will create/update the\n"
+      "    manager's SQLite database (schema only).\n"
+      "  * 'subu' commands manage subu records and their corresponding Unix users.\n"
+      "    They accept either a numeric Subu_ID (e.g. 'subu_3') or a path\n"
+      "    (<masu> <subu> [<subu> ...]) where noted.\n"
+      "  * WireGuard, attach/detach, network, option, and exec commands are\n"
+      "    reserved for managing networking and runtime behavior of existing subu.\n"
+      "\n"
+    )
+
+  def example(self) -> str:
+    """
+    Return an example workflow.
+    """
+    p = self.program_name
+    return (
+      f"Example workflow:\n"
+      "\n"
+      f"  # 1. As root, create or update the manager database schema\n"
+      f"  sudo {p} db load schema\n"
+      "\n"
+      f"  # 2. As root, create a developer subu for Thomas\n"
+      f"  sudo {p} subu make Thomas developer\n"
+      "\n"
+      f"  # 3. As root, create a nested subu 'bolt' under Thomas/developer\n"
+      f"  sudo {p} subu make Thomas developer bolt\n"
+      "\n"
+      f"  # 4. As any user, list all known subu\n"
+      f"  {p} subu list\n"
+      "\n"
+      f"  # 5. Show detailed info by path\n"
+      f"  {p} subu info Thomas developer bolt\n"
+      "\n"
+      f"  # 6. Later, remove the nested subu by ID\n"
+      f"  sudo {p} subu remove subu_3\n"
+      "\n"
+    )
+
+
+def make_text(program_name: str) -> _Text:
+  """
+  Factory used by CLI.py to get a text provider for the given program name.
+  """
+  return _Text(program_name)
diff --git a/tester/document/notes.txt b/tester/document/notes.txt
new file mode 100644 (file)
index 0000000..1044621
--- /dev/null
@@ -0,0 +1,33 @@
+I propose these commands for the tester:
+
+```
+> checkout core  # 'normal mode of work'
+
+> checkout release [majgor] [.minor]  If versions are not listed, the largest number versions are choosen
+
+> where # prints the current branch name
+
+> list other release updates # lists names of files that are on the developer or release branch release directory that have been updated since the version on the tester branch release directory were made.  Also issues errors for those on the tester branch release directory that are newer than those on the developer/release branch release directory.
+
+> other release merge   # for core_tester_branch, merges from core_developer_branch. Before the merge, publishes message if any file under the `$REPO_HOME/tester` would be changed, and asks the tester really wants to do the merge.
+
+> push  # pushes changes to upstream repo, gives errors if files were changed anywhere except under $REPO_HOME/tester, and refuses to push.  This can be gotten around by using manual git commands, but in the future, there might be permission problems.
+
+> pull # pulls from upstream repo
+
+> publish minor  # creates a new release_branch_<version major>.<version minor> If there was no minor version before, the minor version becomes `1`.  Otherwise, the minor version is incremented. A release_branch_<version major>  branch must exist.  Version numbers are consequitive numbers.
+
+> publish  major [<major version>] [minor version]  # if no `release_branch_<version major>[.<version minor>` exists, then it creates a branch with major number 0, and minor number 1. If the version is specified, and it does not exist, it is made.
+
+```
+
+How they are used:
+
+1. The tester normally works on the core_tester_branch. He starts there after entering the project with `. env_tester`.  He merges changes from the core_developer_branch when ready to accept the next batch of changes from the developer.
+
+2. When the tester is feels a release is "done" he publishes it using `publish`. One would imagine the tester is in communication with the team before doing this. It is also interesting in that it gives the tester the last word on whether something gets published.
+
+Then later, perhaps due to bug fixes, the tester might have to do work testing already published work again.  We hope not, but it could happen.  To do this:
+
+3.the tester runs a `checkout` command. He might then run `checkout core` to get back to testing the core product.
+
diff --git a/tester/document/spec.txt b/tester/document/spec.txt
new file mode 100644 (file)
index 0000000..1443c13
--- /dev/null
@@ -0,0 +1,36 @@
+I propose these commands for the tester:
+
+```
+> checkout core  # 'normal mode of work'
+
+> checkout release [majgor] [.minor]  If versions are not listed, the largest number versions are choosen
+
+> where # prints the current branch name
+
+> ensure test branch # if the current branch is not one of the test branches, runs `checkout core`.
+
+> list other release updates # lists names of files that are on the developer or release branch release directory that have been updated since the version on the tester branch release directory were made.  Also issues errors for those on the tester branch release directory that are newer than those on the developer/release branch release directory.
+
+> other release merge   # for core_tester_branch, merges from core_developer_branch. Before the merge, publishes message if any file under the `$REPO_HOME/tester` would be changed, and asks the tester really wants to do the merge.
+
+> push  # pushes changes to upstream repo, gives errors if files were changed anywhere except under $REPO_HOME/tester, and refuses to push.  This can be gotten around by using manual git commands, but in the future, there might be permission problems.
+
+> pull # pulls from upstream repo
+
+> publish minor  # creates a new release_branch_<version major>.<version minor> If there was no minor version before, the next minor version becomes `1`.  Otherwise, the minor version is incremented. A release_branch_<version major>  branch must exist.  Version numbers are consequitive numbers.
+
+> publish  major [<major version>] [minor version]  # if no `release_branch_<version major>[.<version minor>]` exists, then it creates a branch with major number 0, and minor number 1. If the version is specified, and it does not exist, it is made.
+
+```
+
+How they are used:
+
+1. The tester normally works on the core_tester_branch. He starts there after entering the project with `. env_tester`.  He merges changes from the core_developer_branch when ready to accept the next batch of changes from the developer.
+
+2. When the tester is feels a release is "done" he publishes it using `publish`. One would imagine the tester is in communication with the team before doing this. It is also interesting in that it gives the tester the last word on whether something gets published.
+
+Then later, perhaps due to bug fixes, the tester might have to do work testing already published work again.  We hope not, but it could happen.  To do this:
+
+3.the tester runs a `checkout` command. He might then run `checkout core` to get back to testing the core product.
+
+Remember to always create a separate CLI for parsing commands, and then to call a function that does the work.  That way the various commands can include each other as modules, and they call each other's functions.  No loops please ;-)
diff --git a/tester/document/workflow.org b/tester/document/workflow.org
new file mode 100644 (file)
index 0000000..c0082fe
--- /dev/null
@@ -0,0 +1,479 @@
+#+TITLE: Core Branches, Tester Policies, and Workflows
+#+AUTHOR: Reasoning Technology
+#+OPTIONS: num:t
+
+Branches and naming
+
+1.1. Developer branch
+
+=core_developer_branch=
+
+Single canonical development branch.
+
+Developer commits source changes and updated release artifacts here.
+
+1.2. Tester branches
+
+=core_tester_branch=
+
+Main testing branch.
+
+Must correspond to =core_developer_branch=.
+
+Tester does normal day-to-day work here.
+
+=release_tester_<major>[.<minor>]=
+
+Testing branches tied to specific releases.
+
+Each must correspond to an existing =release_<major>[.<minor>]= branch.
+
+1.3. Release branches
+
+=release_<major>[.<minor>]=
+
+Branches representing published releases.
+
+There is never a =release_<major>.0=; that case is named =release_<major>=.
+
+=release_<3>= is treated as version =(3,0)= for ordering.
+
+1.4. Version ordering
+
+Versions are ordered as integer pairs =(major, minor)=.
+
+Minor is treated as =0= when absent.
+
+For two versions:
+
+=(M1, m1) > (M2, m2)= if:
+
+=M1 > M2=, or
+
+=M1 == M2= and =m1 > m2=.
+
+Examples:
+
+=3.10 > 3.2= (minor 10 vs minor 2).
+
+=4.0 > 3.999= (any major 4 > any major 3).
+
+=release_3.2 > release_3= because =(3,2) > (3,0)=.
+
+Directory layout and roles
+
+2.1. Fixed directories
+
+=$REPO_HOME= :: Absolute project root.
+
+Always exists (Harmony skeleton):
+
+=$REPO_HOME/developer=
+
+=$REPO_HOME/tester=
+
+=$REPO_HOME/release=
+
+2.2. Role responsibilities
+
+Developer
+
+Works under =$REPO_HOME/developer= (e.g. after ~. ./env_developer~).
+
+Updates code and runs =release= to populate =$REPO_HOME/release=.
+
+Commits and pushes changes on =core_developer_branch=.
+
+Tester
+
+Works under =$REPO_HOME/tester= (after ~. ./env_tester~).
+
+Writes tests and runs them against artifacts in =$REPO_HOME/release=.
+
+Commits tests on =core_tester_branch= or =release_tester_*=.
+
+Toolsmith
+
+Works on shared code under =tool_shared/= (not the git-ignored =tool_shared/third_party/=).
+
+Commits shared tool changes on =core_developer_branch=.
+
+Developer gets these via ~git pull~ on =core_developer_branch=.
+
+Tester gets them when merging from =core_developer_branch= into tester branches.
+
+2.3. third_party tools
+
+=tool_shared/third_party/= is git-ignored.
+
+Each user is responsible for their own copies there.
+
+Shared, version-controlled tools live elsewhere in the tree (e.g. under =tool_shared/= but not in =third_party/=).
+
+Policies
+
+3.1. Tester write policy
+
+Tester may only write under:
+
+=$REPO_HOME/tester/**=
+
+It is against policy for tester-side pushes to include changes outside =$REPO_HOME/tester/**=.
+
+Current tools enforce this softly:
+
+They list such paths and ask “these are against policy, are you sure?”.
+
+The tester may still proceed, but is expected to do so consciously.
+
+3.2. Developer vs tester domains
+
+Developer owns:
+
+=developer/
+
+=release/
+
+Shared code (e.g. under =tool_shared/=, except git-ignored areas).
+
+Tester owns:
+
+=tester/= and its subtrees.
+
+Toolsmith changes to shared code are made on =core_developer_branch= and flow to testers via merges.
+
+3.3. Executable files
+
+For any merge/pull helper that changes files:
+
+Executable files (based on mode bits) are always listed explicitly before confirmation.
+
+This applies to:
+
+Merges from developer branches.
+
+Pulls/merges from tester’s own remote branches.
+
+3.4. Soft enforcement
+
+For now, tools do not hard-fail on policy violations.
+
+Instead:
+
+They list affected files (especially outside =$REPO_HOME/tester/**=).
+
+They prompt for confirmation:
+
+“These paths are against policy, are you sure you want to proceed?”
+
+Future enforcement via permissions is possible, but not assumed.
+
+Workflows
+
+4.1. Developer workflow (core_developer_branch)
+
+Normal cycle:
+
+Work on code under =developer/=
+
+Build and run tests locally.
+
+Run =release= to update =$REPO_HOME/release= artifacts.
+
+Commit changes (code + release artifacts + any shared tools).
+
+Push to =origin/core_developer_branch=.
+
+The state of =release/= on =core_developer_branch= defines “what the tester will see next time they merge”.
+
+4.2. Tester workflow on core_tester_branch
+
+Enter environment:
+
+~. ./env_tester~
+
+Ensure branch is =core_tester_branch= (via =checkout core=).
+
+Normal cycle:
+
+Run tests against current =$REPO_HOME/release=.
+
+Edit tests under =tester/=
+
+Commit tests as needed.
+
+Optionally push to =origin/core_tester_branch= using the policy-aware =push= command.
+
+Accepting a new core release from developer:
+
+Run a “list updates” / “merge core” helper:
+
+It compares =core_tester_branch= with =core_developer_branch=, restricted to =release/= (and possibly shared tools).
+
+Lists changed files and executable files.
+
+If acceptable:
+
+Merge =origin/core_developer_branch= into =core_tester_branch=.
+
+Commit/resolve as needed.
+
+Push updated =core_tester_branch=.
+
+4.3. Tester workflow on release_tester_<major>[.<minor>]
+
+When a published release needs regression or bug-fix testing:
+
+Identify target release branch =release_<major>[.<minor>]=.
+
+Checkout corresponding tester branch:
+
+=release_tester_<major>[.<minor>]=
+
+Testing cycle:
+
+Run tests against =$REPO_HOME/release= as it exists on this branch.
+
+Update/re-run tests under =tester/= if necessary.
+
+Commit to the =release_tester_* branch.
+
+Optionally push that branch for collaboration.
+
+When hotfixes or updated releases appear:
+
+Corresponding =release_<major>[.<minor>]= is updated on =core_developer_branch= side.
+
+The tester’s “release merge” helper can bring those changes into =release_tester_<major>[.<minor>]= in a controlled way (listing affected paths first).
+
+4.4. Toolsmith workflow
+
+Toolsmith edits shared code (not =third_party/=) on =core_developer_branch=.
+
+Normal steps:
+
+~. ./env_developer~ (or a dedicated toolsmith env that still uses =core_developer_branch=).
+
+Modify shared code (e.g. under =tool_shared/=).
+
+Commit changes on =core_developer_branch=.
+
+Push to =origin/core_developer_branch=.
+
+Developer picks up these changes with their usual pulls.
+
+Tester gets these changes when merging from =core_developer_branch= into tester branches.
+
+Tools can highlight such changes (and executables) before merges so the tester knows when shared infrastructure has shifted.
+
+Tester commands (conceptual)
+
+5.1. General
+
+Commands live under =$REPO_HOME/tester/tool/= and are on =PATH= after ~. ./env_tester~.
+
+Each command:
+
+Has its own CLI parser.
+
+Calls worker functions (in shared modules) to do the real work.
+
+Does not call other commands via their CLIs (no loops).
+
+5.2. Branch introspection
+
+=where=
+
+Prints current branch name.
+
+Returns an error if current branch is not a testing branch:
+
+=core_tester_branch=, or
+
+Any =release_tester_<major>[.<minor>]=.
+
+=checkout core=
+
+Switches to =core_tester_branch=.
+
+This is the “normal mode of work” for new testing.
+
+=checkout release [<major>] [.<minor>]=
+
+Without arguments:
+
+Finds all =release_tester_<major>[.<minor>]= branches.
+
+Parses their versions as integer pairs.
+
+Selects the highest =(major, minor)= (with minor =0 when absent).
+
+With arguments:
+
+Attempts to checkout the specific =release_tester_<major>[.<minor>]= branch.
+
+Errors if that branch does not exist.
+
+5.3. Release updates and merges
+
+=list other release updates=
+
+Compares the current tester branch with the corresponding developer branch:
+
+=core_tester_branch= ↔ =core_developer_branch=
+
+=release_tester_<N[.M]> ↔ release_<N[.M]>=
+
+Restricts comparison to =$REPO_HOME/release/**=.
+
+Lists:
+
+Files that are newer on developer (tester is behind).
+
+Files that are newer on tester (unexpected; highlighted as against policy).
+
+Prints a summary and may ask for confirmation before any merge operation.
+
+=other release merge= (name subject to refinement)
+
+For =core_tester_branch=:
+
+Merges from =core_developer_branch=.
+
+For =release_tester_<N[.M]>=:
+
+Merges from =release_<N[.M]>=.
+
+Before merging, it:
+
+Lists any files under =$REPO_HOME/tester/**= that would change.
+
+Lists all executable files that would change anywhere.
+
+Prompts:
+
+“These paths are against policy (or shared infrastructure changes), are you sure?”
+
+5.4. Push and pull
+
+=push=
+
+Pre-flight:
+
+Diff current branch vs its upstream.
+
+Identify files outside =$REPO_HOME/tester/**=.
+
+If such files exist:
+
+List them, with executables highlighted.
+
+Prompt: “These are against policy, are you sure you want to push?”
+
+If confirmed:
+
+Runs =git push= to the branch’s upstream.
+
+=pull=
+
+Used to pull updates from the same tester branch on the remote (e.g. shared testing).
+
+Behavior:
+
+Lists files that will be changed, especially executables and non-tester paths.
+
+Prompts for confirmation before performing any merge or fast-forward.
+
+Developer changes are not brought in via =pull=; they come through the dedicated “merge from developer” commands.
+
+Publishing and versioning
+
+6.1. Publishing a new release (tester-driven)
+
+When tester deems core testing “done” and the team agrees:
+
+From =core_tester_branch=:
+
+Use =publish major [<major>] [<minor>]= or =publish minor= to create / bump the appropriate =release_<major>[.<minor>]= branch.
+
+Each publish:
+
+Creates a new =release_<major>[.<minor>]= branch if needed.
+
+Ensures that for a given major:
+
+First minor is =1= (no =.0= names).
+
+The corresponding =release_tester_<major>[.<minor>]= branch is used for any additional testing of that specific release.
+
+6.2. Minor and major increments
+
+=publish minor=
+
+For an existing major version:
+
+Examines all existing =release_<major>[.<minor>]= branches.
+
+Treats absent minor as =0= for ordering.
+
+Creates the next minor as =minor_last + 1=.
+
+Names it =release_<major>.<next_minor>=.
+
+=publish major [<major>] [<minor>]=
+
+Creates a new major version branch when a significant release is ready.
+
+If no major specified:
+
+Uses the next integer after the highest existing major.
+
+Starts with either:
+
+=release_<new_major>= (implicit .0), or
+
+=release_<new_major>.1= depending on the chosen policy for that project.
+
+If version specified and does not yet exist:
+
+Creates =release_<major>[.<minor>]= and corresponding tester branch.
+
+Summary
+
+7.1. Core ideas
+
+The project has a clear branch topology:
+
+=core_developer_branch= for development + releases.
+
+=core_tester_branch= for ongoing testing.
+
+=release_<major>[.<minor>]= and =release_tester_<major>[.<minor>]= for published releases and their tests.
+
+The filesystem is partitioned by role:
+
+Developer owns =developer/= and =release/= and shared code.
+
+Tester owns =tester/=.
+
+Shared code is edited on =core_developer_branch= and flows to testers via merges.
+
+Versioning is numeric and explicit:
+
+Major/minor pairs with implicit minor 0 when absent.
+
+No =.0= suffix in branch names; =release_3= is the =3.0= level.
+
+Tools (per-command CLIs) enforce policies softly:
+
+They detect and list out-of-policy changes.
+
+They always highlight executable changes.
+
+They ask for confirmation instead of silently permitting or hard-failing.
+
+Publishing is tester-driven:
+
+Tester (in coordination with the team) decides when a release is ready.
+
+Publishing creates or advances =release_* and =release_tester_* branches so that future testing and regression work can target exact versions.
diff --git a/tester/manager/subu_manager b/tester/manager/subu_manager
new file mode 120000 (symlink)
index 0000000..c6f63f7
--- /dev/null
@@ -0,0 +1 @@
+/home/Thomas/subu_data/developer/subu_data/subu/release/manager/CLI.py
\ No newline at end of file