From: Thomas Walker Lynch Date: Fri, 7 Nov 2025 10:50:36 +0000 (+0000) Subject: development work X-Git-Url: https://git.reasoningtechnology.com/style/static/gitweb.js?a=commitdiff_plain;h=360fe009f01fb21e4ec5745845ef0e7e140e5731;p=subu development work --- diff --git a/.gitignore b/.gitignore index 6885132..801694e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ __pycache__ *~ +*.sqlite3 \ No newline at end of file diff --git a/developer/manager/CLI.py b/developer/manager/CLI.py index 23278d7..0185450 100755 --- a/developer/manager/CLI.py +++ b/developer/manager/CLI.py @@ -13,7 +13,7 @@ CLI should not do any work beyond: * exit with the returned status code """ -import sys, argparse +import os, sys, argparse from text import make_text import dispatch @@ -36,20 +36,30 @@ def build_arg_parser(program_name): return parser - def register_subu_commands(subparsers): """ Register subu related commands: - init, make, list, info, information, lo + init, make, remove, list, info, information, lo + + The subu path is: + + masu subu subu ... + + For example: + subu make Thomas S0 + subu make Thomas S0 S1 """ # init ap = subparsers.add_parser("init") - ap.add_argument("token", nargs="?") + ap.add_argument("token", nargs ="?") - # make + # make: path[0] is masu, remaining elements are the subu chain ap = subparsers.add_parser("make") - ap.add_argument("owner") - ap.add_argument("name") + ap.add_argument("path", nargs ="+") # [masu, subu, subu, ...] + + # remove: same path structure + ap = subparsers.add_parser("remove") + ap.add_argument("path", nargs ="+") # [masu, subu, subu, ...] # list subparsers.add_parser("list") @@ -62,10 +72,9 @@ 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): """ Register WireGuard related commands, grouped under 'WG': @@ -150,14 +159,26 @@ def CLI(argv=None) -> int: if argv is None: argv = sys.argv[1:] - # For now we fix the program name to "subu". - # A release wrapper can later pass a different program name. - program_name = "subu" + # Determine the program name for text/help: + # + # 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 + else: + raw0 = sys.argv[0] or "subu" + program_name = os.path.basename(raw0) or "subu" + text = make_text(program_name) # No arguments is the same as "help". if not argv: - print(text.help(), end="") + print(text.help(), end ="") return 0 # Simple verbs that bypass argparse so they always work. @@ -170,14 +191,14 @@ 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: @@ -185,7 +206,11 @@ def CLI(argv=None) -> int: return dispatch.init(ns.token) if ns.verb == "make": - return dispatch.subu_make(ns.owner, ns.name) + # 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() @@ -199,7 +224,7 @@ def CLI(argv=None) -> int: 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) + print("WG info requires WG_ID", file =sys.stderr) return 2 if v == "global": return dispatch.wg_global(ns.arg1) @@ -226,27 +251,22 @@ def CLI(argv=None) -> int: return dispatch.network_toggle(ns.subu_id, ns.state) if ns.verb == "option": - if ns.action == "set": - return dispatch.option_set(ns.subu_id, ns.name, ns.value) - if ns.action == "get": - return dispatch.option_get(ns.subu_id, ns.name) - if ns.action == "list": - return dispatch.option_list(ns.subu_id) + if ns.area == "Unix": + return dispatch.option_unix(ns.mode) if ns.verb == "exec": if not ns.cmd: - print(f"{program_name} exec -- ...", file=sys.stderr) + print(f"{program_name} exec -- ...", 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()) diff --git a/developer/manager/dispatch.py b/developer/manager/dispatch.py index d0cce3c..48b7aeb 100644 --- a/developer/manager/dispatch.py +++ b/developer/manager/dispatch.py @@ -1,167 +1,173 @@ -#!/usr/bin/env python3 +# dispatch.py # -*- mode: python; coding: utf-8; python-indent-offset: 2; indent-tabs-mode: nil -*- -""" -dispatch.py - -Role: provide one function for each CLI verb so that: - * CLI.py can call these functions - * Other Python code can also import and call them directly +import os, sys +import env +from domain import subu as subu_domain +from infrastructure.db import open_db, ensure_schema +from infrastructure.options_store import set_option -Each function should return an integer status code where practical. -Implementation note: +def init(token =None): + """ + Handle: subu init - At this stage of the refactor, the functions are stubs. They define the - public interface and may raise NotImplementedError. As the domain modules - under domain/ are completed (subu.py, wg.py, network.py, options.py, - exec.py), these functions should be updated to call into those modules. -""" -# dispatch.py -from domain import subu as subu_domain + For now, TOKEN is unused. The behavior is: -def init(token=None): + * if subu.db exists, refuse to overwrite it + * if it does not exist, make it and apply schema.sql """ - Initialize ./subu.db using schema.sql - token is currently unused but kept for CLI compatibility. - """ - # open_db + ensure_schema via domain layer convenience - from infrastructure.db import open_db, ensure_schema - conn = open_db("subu.db") + db_path = env.db_path() + if os.path.exists(db_path): + print("subu.db already exists; refusing to overwrite", file =sys.stderr) + return 1 + + conn = open_db(db_path) try: ensure_schema(conn) - return 0 finally: conn.close() + return 0 + +def subu_make(path_tokens: list[str]) -> int: + """ + Handle: subu make [ ...] + + path_tokens is: + [masu, subu, subu, ...] + + Example: + subu make Thomas S0 + subu make Thomas S0 S1 + """ + if not path_tokens or len(path_tokens) < 2: + print( + "subu: make requires at least and one component", + file =sys.stderr, + ) + return 2 + masu = path_tokens[0] + subu_path = path_tokens[1:] -def subu_make(owner, name): try: - s = subu_domain.make_subu(owner, name) - # print the made ID or username like your older CLI did - print(f"made subu: id={s.id} username={s.username}") + 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) + return 2 except Exception as e: - print(f"error creating subu: {e}", file=sys.stderr) + print(f"error making subu: {e}", file =sys.stderr) return 1 +def subu_remove(path_tokens: list[str]) -> int: + """ + Handle: subu remove [ ...] + + path_tokens is: + [masu, subu, subu, ...] + """ + if not path_tokens or len(path_tokens) < 2: + print( + "subu: remove requires at least and one component", + file =sys.stderr, + ) + return 2 + + masu = path_tokens[0] + subu_path = path_tokens[1:] -def subu_list(): try: - subs = subu_domain.list_subu() - if not subs: - print("no subu found") - return 0 - # simple table - print("ID OWNER NAME USERNAME CREATED_AT") - for s in subs: - print(f"{s.id} {s.owner} {s.name} {s.username} {s.made_at}") + 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 except Exception as e: - print(f"error listing subu: {e}", file=sys.stderr) + print(f"error removing subu: {e}", file =sys.stderr) return 1 - -def subu_info(subu_id): +def option_unix(mode: str) -> int: """ - Handle: subu info|information + Handle: subu option Unix dry|run + + Example: + subu option Unix dry + subu option Unix run """ - raise NotImplementedError("subu_info is not yet implemented") + 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}") + 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_list(): + raise NotImplementedError("subu_list is not yet made") + + +def subu_info(subu_id): + raise NotImplementedError("subu_info is not yet made") def lo_toggle(subu_id, state): - """ - Handle: subu lo up|down - """ - raise NotImplementedError("lo_toggle is not yet implemented") + raise NotImplementedError("lo_toggle is not yet made") def wg_global(base_cidr): - """ - Handle: subu WG global - """ - raise NotImplementedError("wg_global is not yet implemented") + raise NotImplementedError("wg_global is not yet made") def wg_make(endpoint): - """ - Handle: subu WG make - """ - raise NotImplementedError("wg_make is not yet implemented") + raise NotImplementedError("wg_make is not yet made") def wg_server_public_key(wg_id, key): - """ - Handle: subu WG server_provided_public_key - """ - raise NotImplementedError("wg_server_public_key is not yet implemented") + raise NotImplementedError("wg_server_public_key is not yet made") def wg_info(wg_id): - """ - Handle: subu WG info|information - """ - raise NotImplementedError("wg_info is not yet implemented") + raise NotImplementedError("wg_info is not yet made") def wg_up(wg_id): - """ - Handle: subu WG up - """ - raise NotImplementedError("wg_up is not yet implemented") + raise NotImplementedError("wg_up is not yet made") def wg_down(wg_id): - """ - Handle: subu WG down - """ - raise NotImplementedError("wg_down is not yet implemented") + raise NotImplementedError("wg_down is not yet made") def attach_wg(subu_id, wg_id): - """ - Handle: subu attach WG - """ - raise NotImplementedError("attach_wg is not yet implemented") + raise NotImplementedError("attach_wg is not yet made") def detach_wg(subu_id): - """ - Handle: subu detach WG - """ - raise NotImplementedError("detach_wg is not yet implemented") + raise NotImplementedError("detach_wg is not yet made") def network_toggle(subu_id, state): - """ - Handle: subu network up|down - """ - raise NotImplementedError("network_toggle is not yet implemented") + raise NotImplementedError("network_toggle is not yet made") def option_set(subu_id, name, value): - """ - Handle: subu option set - """ - raise NotImplementedError("option_set is not yet implemented") + raise NotImplementedError("option_set is not yet made") def option_get(subu_id, name): - """ - Handle: subu option get - """ - raise NotImplementedError("option_get is not yet implemented") + raise NotImplementedError("option_get is not yet made") def option_list(subu_id): - """ - Handle: subu option list - """ - raise NotImplementedError("option_list is not yet implemented") + raise NotImplementedError("option_list is not yet made") def exec(subu_id, cmd_argv): - """ - Handle: subu exec -- ... - """ - raise NotImplementedError("exec is not yet implemented") + raise NotImplementedError("exec is not yet made") diff --git a/developer/manager/domain/subu.py b/developer/manager/domain/subu.py index 7f77f86..53f674d 100644 --- a/developer/manager/domain/subu.py +++ b/developer/manager/domain/subu.py @@ -1,200 +1,124 @@ -""" -4.1 domain/subu.py - -Subu objects: creation, lookup, hierarchy, netns identity. +# domain/subu.py +# -*- mode: python; coding: utf-8; python-indent-offset: 2; indent-tabs-mode: nil -*- -4.1.1 make_subu(owner: str, name: str) -> Subu -4.1.2 list_subu() -> list[Subu] -4.1.3 get_subu(subu_id: str) -> Subu -4.1.4 ensure_unix_identity(subu: Subu) -> None -4.1.5 ensure_netns(subu: Subu) -> None +from infrastructure.unix import ( + ensure_unix_user, + remove_unix_user_and_group, + user_exists, +) -(A Subu can be a dataclass or NamedTuple.) -""" -# domain/subu.py -from dataclasses import dataclass -from infrastructure.db import open_db, ensure_schema -import sqlite3 -import time +def _validate_token(label: str, token: str): + """ + Validate a single path token (masu or subu). -DB_PATH = "subu.db" + Rules: + - must be non-empty after stripping whitespace + - must not contain underscore '_' + """ + token_stripped = token.strip() + if not token_stripped: + raise SystemExit(f"subu: {label} name must be non-empty") + if "_" in token_stripped: + raise SystemExit( + f"subu: {label} name '{token_stripped}' must not contain underscore '_'" + ) + # dashes are fine; acronyms and proper nouns are fine. + return token_stripped -@dataclass -class Subu: - id: int - owner: str - name: str - username: str - made_at: str +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" -def _make_username(owner, name): - # simple deterministic username: owner_name -> owner_name (no spaces) - owner_s = owner.replace(" ", "_") - name_s = name.replace(" ", "_") - return f"{owner_s}_{name_s}" + The path is: + masu subu subu ... + """ + masu_s = _validate_token("masu", masu).replace(" ", "_") + subu_parts = [] + for s in path_components: + subu_parts.append(_validate_token("subu", s).replace(" ", "_")) + parts = [masu_s] + subu_parts + return "_".join(parts) -def make_subu(owner: str, name: str) -> Subu: +def _parent_username(masu: str, path_components: list[str]) -> str | None: """ - Create a subu row in subu.db and return the Subu dataclass. + 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" """ - conn = open_db(DB_PATH) - try: - ensure_schema(conn) - cur = conn.cursor() - username = _make_username(owner, name) - made_at = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()) - cur.execute( - "INSERT INTO subu (owner, name, username, made_at) VALUES (?, ?, ?, ?)", - (owner, name, username, made_at), - ) - conn.commit() - rowid = cur.lastrowid - row = conn.execute("SELECT id, owner, name, username, made_at FROM subu WHERE id = ?", (rowid,)).fetchone() - return Subu(row["id"], row["owner"], row["name"], row["username"], row["made_at"]) - finally: - conn.close() + if len(path_components) <= 1: + return None + # parent path is everything except last token + parent_path = path_components[:-1] + return subu_username(masu, parent_path) -def list_subu(): - """ - Return a list of Subu objects currently in the DB. +def make_subu(masu: str, path_components: list[str]) -> str: """ - conn = open_db(DB_PATH) - try: - ensure_schema(conn) - rows = conn.execute("SELECT id, owner, name, username, made_at FROM subu ORDER BY id").fetchall() - return [Subu(r["id"], r["owner"], r["name"], r["username"], r["made_at"]) for r in rows] - finally: - conn.close() - -def info_subu(subu_id: str): - sid = int(subu_id.split("_")[1]) - with closing(_db()) as db: - row = db.execute("SELECT * FROM subu WHERE id=?", (sid,)).fetchone() - if not row: - print("not found"); return - print(row) - wg = db.execute("SELECT wg_id FROM subu WHERE id=?", (sid,)).fetchone()[0] - if wg is not None: - wrow = db.execute("SELECT * FROM wg WHERE id=?", (wg,)).fetchone() - print("WG:", wrow) - opts = db.execute("SELECT name,value FROM options WHERE subu_id=?", (sid,)).fetchall() - print("Options:", opts) - -def lo_toggle(subu_id: str, state: str): - sid = int(subu_id.split("_")[1]) - with closing(_db()) as db: - ns = db.execute("SELECT netns FROM subu WHERE id=?", (sid,)).fetchone() - if not ns: raise ValueError("subu not found") - ns = ns[0] - run(["ip", "netns", "exec", ns, "ip", "link", "set", "lo", state]) - db.execute("UPDATE subu SET lo_state=? WHERE id=?", (state, sid)) - db.commit() - print(f"{subu_id}: lo {state}") - -# ---------------- High-level Subu factory ---------------- - -def make_subu(path_tokens: list[str]) -> str: - """ - Create a new Subu with hierarchical name and full wiring: + Make the Unix user and group for this subu. - path_tokens: ['Thomas', 'US'] or ['Thomas', 'new-subu', 'Rabbit'] + The subu path is: + masu subu subu ... Rules: - - len(path_tokens) >= 2 - - parent path (everything except last token) must already exist - as: - * a Unix user (for len==2: just the top-level user, e.g. 'Thomas') - * and as a Subu in our DB if len > 2 (e.g. 'Thomas_new-subu') - - new Unix user name is path joined by '_', e.g. 'Thomas_new-subu_Rabbit' - - mas u(root) is path_tokens[0] - - groups: - - -incommon - - Side effects: - - DB row in 'subu' (id, owner, name, full_unix_name, path, netns_name, ...) - - netns ns-subu_ made with lo down - - Unix user made/ensured - - Unix groups ensured and membership updated - - Returns: textual Subu_ID, e.g. 'subu_7'. + - len(path_components) >= 1 + - 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 + + Returns: + Unix username, for example 'Thomas_S0' or 'Thomas_S0_S1'. """ - if not path_tokens or len(path_tokens) < 2: - raise SystemExit("subu: make requires at least two path elements, e.g. 'Thomas US'") - - # Normalised pieces - path_tokens = [p.strip() for p in path_tokens if p.strip()] - if len(path_tokens) < 2: - raise SystemExit("subu: make requires at least two non-empty path elements") - - masu = path_tokens[0] # root user / owner - leaf = path_tokens[-1] # new subu leaf - parent_tokens = path_tokens[:-1] # parent path - full_unix_name = "_".join(path_tokens) # e.g. 'Thomas_new-subu_Rabbit' - parent_unix_name = "_".join(parent_tokens) - path_str = " ".join(path_tokens) # e.g. 'Thomas new-subu Rabbit' - - # 1) Enforce parent existing - - # Case A: top-level subu (e.g. ['Thomas', 'US']) - if len(path_tokens) == 2: - # Require the root user to exist as a Unix user - if not _user_exists(masu): + if not path_components: + 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 + parent_uname = _parent_username(masu, path_components) + if parent_uname is None: + # Top-level subu: require the masu Unix user to exist + masu_name = _validate_token("masu", masu) + if not user_exists(masu_name): raise SystemExit( - f"subu: cannot make '{path_str}': root user '{masu}' does not exist" + f"subu: cannot make '{username}': masu Unix user '{masu_name}' does not exist" ) else: - # Case B: deeper subu: require parent subu exists in our DB - parent_row = get_subu_by_full_unix_name(parent_unix_name) - if not parent_row: + # Deeper subu: require parent subu Unix user to exist + if not user_exists(parent_uname): raise SystemExit( - f"subu: cannot make '{path_str}': parent subu '{parent_unix_name}' does not exist" + f"subu: cannot make '{username}': parent subu unix user '{parent_uname}' does not exist" ) - # Also forbid duplicate full_unix_name - existing = get_subu_by_full_unix_name(full_unix_name) - if existing: - raise SystemExit( - f"subu: subu with name '{full_unix_name}' already exists (id=subu_{existing[0]})" - ) - - # 2) Insert DB row and allocate ID + netns_name - - with closing(open_db()) as db: - subu_id_num = _first_free_id(db, "subu") - netns_name = f"ns-subu_{subu_id_num}" - - db.execute( - "INSERT INTO subu(id, owner, name, full_unix_name, path, netns_name, wg_id, made_at, updated_at) " - "VALUES (?, ?, ?, ?, ?, ?, NULL, datetime('now'), datetime('now'))", - (subu_id_num, masu, leaf, full_unix_name, path_str, netns_name) - ) - db.commit() - - subu_id = f"subu_{subu_id_num}" - - # 3) Create netns + lo down - _make_netns_for_subu(subu_id_num, netns_name) + # For now, group and user share the same name. + ensure_unix_user(username, username) + return username - # 4) Ensure Unix user + groups - unix_user = full_unix_name - group_masu = masu - group_incommon = f"{masu}-incommon" - - _ensure_group(group_masu) - _ensure_group(group_incommon) +def remove_subu(masu: str, path_components: list[str]) -> str: + """ + Remove the Unix user and group for this subu, if they exist. - _ensure_user(unix_user, group_masu) - _add_user_to_group(unix_user, group_masu) # mostly redundant but explicit - _add_user_to_group(unix_user, group_incommon) + The subu path is: + masu subu subu ... - print(f"Created Subu {subu_id} for path '{path_str}' with Unix user '{unix_user}' " - f"and netns '{netns_name}'") + Returns: + Unix username that was targeted. + """ + if not path_components: + raise SystemExit("subu: remove requires at least one subu component") - return subu_id + username = subu_username(masu, path_components) + remove_unix_user_and_group(username) + return username diff --git a/developer/manager/env.py b/developer/manager/env.py new file mode 100644 index 0000000..55b28a4 --- /dev/null +++ b/developer/manager/env.py @@ -0,0 +1,55 @@ +# env.py +# -*- mode: python; coding: utf-8; python-indent-offset: 2; indent-tabs-mode: nil -*- + +from pathlib import Path + + +def version() -> str: + """ + Software / CLI version. + """ + return "0.3.3" + + +def db_schema_version() -> str: + """ + Database schema version (used in the DB filename). + + This only changes when the DB layout/semantics change, + not for every CLI code change. + """ + return "0.1" + + +def db_root_dir() -> Path: + """ + Default directory for the system-wide subu database. + + This is intentionally independent of the project/repo location. + """ + return Path("/opt/subu") + + +def db_filename() -> str: + """ + Default SQLite database filename, including schema version. + + subu_.sqlite3 + + Example: subu_0.1.sqlite3 + """ + return f"subu_{db_schema_version()}.sqlite3" + + +def db_path() -> str: + """ + Full path to the SQLite database file. + + Currently this is: + + /opt/subu/subu_.sqlite3 + + There is deliberately no environment override here; this path + defines the canonical system-wide DB used by all manager invocations. + """ + return str(db_root_dir() / db_filename()) diff --git a/developer/manager/infrastructure/db.py b/developer/manager/infrastructure/db.py index 83fb5c5..9800c45 100644 --- a/developer/manager/infrastructure/db.py +++ b/developer/manager/infrastructure/db.py @@ -1,43 +1,25 @@ # infrastructure/db.py -import os -import pwd -import grp -import subprocess -from contextlib import closing +# -*- mode: python; coding: utf-8; python-indent-offset: 2; indent-tabs-mode: nil -*- + import sqlite3 from pathlib import Path +import env -""" -5.1 infrastructure/db.py - -All SQLite access. -5.1.1 open_db(path: str = "subu.db") -> sqlite3.Connection -5.1.2 ensure_schema(conn) -> None -5.1.3 insert_subu(conn, subu: Subu) -> None -5.1.4 fetch_subu(conn, subu_id: str) -> Subu -5.1.5 list_subu(conn) -> list[Subu] -5.1.6 insert_wg(conn, wg: WG) -> None -5.1.7 fetch_wg(conn, wg_id: str) -> WG -5.1.8 update_wg(conn, wg: WG) -> None -5.1.9 set_option_row(conn, subu_id: str, name: str, value: str) -> None -5.1.10 get_option_row(conn, subu_id: str, name: str) -> str | None -5.1.11 list_option_rows(conn, subu_id: str) -> dict[str, str] - -(Exact breakdown can be tuned when we see schema.sql.) -""" - -# infrastructure/db.py -# -*- mode: python; coding: utf-8; python-indent-offset: 2; indent-tabs-mode: nil -*- +def schema_path_default(): + """ + Path to schema.sql, assumed to live next to this file. + """ + return Path(__file__).with_name("schema.sql") -def schema_path_default(): return Path(__file__).with_name("schema.sql") -def db_path_default(): return "." -def open_db(path ="subu.db"): +def open_db(path =None): """ Return a sqlite3.Connection with sensible pragmas. Caller is responsible for closing. """ + if path is None: + path = env.db_path() conn = sqlite3.connect(path) conn.row_factory = sqlite3.Row conn.execute("PRAGMA foreign_keys = ON") @@ -45,92 +27,12 @@ def open_db(path ="subu.db"): conn.execute("PRAGMA synchronous = NORMAL") return conn + 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() - -def _db(): - if not DB_FILE.exists(): - raise FileNotFoundError("subu.db not found; run `subu init ` first") - return sqlite3.connect(DB_FILE) - -def init_db(path: str = db_path_default()): - """ - Initialise subu.db if missing; refuse to overwrite existing file. - """ - if os.path.exists(path): - print(f"subu: db already exists at {path}") - return - - with closing(sqlite3.connect(path)) as db: - db.executescript(SCHEMA_SQL) - db.execute( - "INSERT INTO meta(key,value) VALUES ('made_at', datetime('now'))" - ) - db.commit() - print(f"subu: made new db at {path}") - - -def cmd_init(token: str|None): - if DB_FILE.exists(): - raise FileExistsError("db already exists") - if not token or len(token) < 6: - raise ValueError("init requires a 6+ char token") - with closing(sqlite3.connect(DB_FILE)) as db: - c = db.cursor() - c.executescript(""" - CREATE TABLE subu ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - owner TEXT, - name TEXT, - netns TEXT, - lo_state TEXT DEFAULT 'down', - wg_id INTEGER, - network_state TEXT DEFAULT 'down' - ); - CREATE TABLE wg ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - endpoint TEXT, - local_ip TEXT, - allowed_ips TEXT, - pubkey TEXT, - state TEXT DEFAULT 'down' - ); - CREATE TABLE options ( - subu_id INTEGER, - name TEXT, - value TEXT, - PRIMARY KEY (subu_id, name) - ); - """) - db.commit() - print(f"made subu.db (v{VERSION})") - -def _first_free_id(db, table: str) -> int: - """ - Return the smallest non-negative integer not in table.id. - Assumes 'id' INTEGER PRIMARY KEY in that table. - """ - rows = db.execute(f"SELECT id FROM {table} ORDER BY id ASC").fetchall() - used = {r[0] for r in rows} - i = 0 - while i in used: - i += 1 - return i - -def get_subu_by_full_unix_name(full_unix_name: str): - """ - Return the DB row for a subu with this full_unix_name, or None. - """ - with closing(open_db()) as db: - row = db.execute( - "SELECT id, owner, name, full_unix_name, path, netns_name " - "FROM subu WHERE full_unix_name = ?", - (full_unix_name,) - ).fetchone() - return row diff --git a/developer/manager/infrastructure/options_store.py b/developer/manager/infrastructure/options_store.py new file mode 100644 index 0000000..913a383 --- /dev/null +++ b/developer/manager/infrastructure/options_store.py @@ -0,0 +1,58 @@ +# infrastructure/options_store.py +# -*- mode: python; coding: utf-8; python-indent-offset: 2; indent-tabs-mode: nil -*- + +from pathlib import Path + +# Options file lives next to CLI in the manager release tree. +# In dev it will be the same relative layout. +OPTIONS_FILE = Path("subu.options") + + +def load_options(): + """ + Load options from subu.options into a dictionary. + + Lines are of the form: key=value + Lines starting with '#' or blank lines are ignored. + """ + opts = {} + if not OPTIONS_FILE.exists(): + return opts + text = OPTIONS_FILE.read_text(encoding="utf-8") + for line in text.splitlines(): + line = line.strip() + if not line or line.startswith("#"): + continue + if "=" not in line: + continue + k, v = line.split("=", 1) + opts[k.strip()] = v.strip() + return opts + + +def save_options(opts: dict): + """ + Save a dictionary of options back to subu.options. + """ + lines = [] + for k in sorted(opts.keys()): + v = opts[k] + lines.append(f"{k}={v}\n") + OPTIONS_FILE.write_text("".join(lines), encoding="utf-8") + + +def set_option(name: str, value: str): + """ + Set a single option key to a value. + """ + opts = load_options() + opts[name] = value + save_options(opts) + + +def get_option(name: str, default=None): + """ + Get an option value by name, or default if missing. + """ + opts = load_options() + return opts.get(name, default) diff --git a/developer/manager/infrastructure/unix.py b/developer/manager/infrastructure/unix.py index fec6d9a..6aa86cf 100644 --- a/developer/manager/infrastructure/unix.py +++ b/developer/manager/infrastructure/unix.py @@ -1,41 +1,69 @@ -""" -unix.py +# infrastructure/unix.py +# -*- mode: python; coding: utf-8; python-indent-offset: 2; indent-tabs-mode: nil -*- -Thin wrappers for OS commands. +import subprocess, pwd, grp -5.2.1 run(cmd: list[str], check: bool = True) -> subprocess.CompletedProcess -5.2.2 ip(*args: str, check: bool = True) -5.2.3 ip_netns(*args: str, check: bool = True) -5.2.4 wg(*args: str, check: bool = True) -Optional later: logging, dry-run, etc. -""" +def run(cmd, check =True): + """ + Run a Unix command, capturing output. -# ---------------- Unix users & groups ---------------- + Raises RuntimeError if check is True and the command fails. + """ + r = subprocess.run( + cmd, + stdout =subprocess.PIPE, + stderr =subprocess.PIPE, + text =True, + ) + if check and r.returncode != 0: + raise RuntimeError(f"cmd failed: {' '.join(cmd)}\n{r.stderr}") + return r -def _group_exists(name: str) -> bool: + +def group_exists(name: str) -> bool: try: grp.getgrnam(name) return True except KeyError: return False -def _user_exists(name: str) -> bool: + +def user_exists(name: str) -> bool: try: pwd.getpwnam(name) return True except KeyError: return False -def _ensure_group(name: str): - if not _group_exists(name): - # groupadd + +def ensure_unix_group(name: str): + """ + Ensure a Unix group with this name exists. + """ + if not group_exists(name): run(["groupadd", name]) -def _ensure_user(name: str, primary_group: str): - if not _user_exists(name): - # useradd -m -g -s /bin/bash + +def ensure_unix_user(name: str, primary_group: str): + """ + Ensure a Unix user with this name exists and has the given primary group. + + The primary group is made if needed. + """ + ensure_unix_group(primary_group) + if not user_exists(name): run(["useradd", "-m", "-g", primary_group, "-s", "/bin/bash", name]) -def _add_user_to_group(user: str, group: str): - run(["usermod", "-aG", group, user]) + +def remove_unix_user_and_group(name: str): + """ + Remove a Unix user and group that match this name, if they exist. + + The user is removed first, then the group. + """ + if user_exists(name): + # userdel returns non-zero if, for example, the user is logged in. + run(["userdel", name]) + if group_exists(name): + run(["groupdel", name]) diff --git a/developer/manager/subu.db b/developer/manager/subu.db deleted file mode 100644 index aff27ef..0000000 Binary files a/developer/manager/subu.db and /dev/null differ diff --git a/developer/manager/text.py b/developer/manager/text.py index 0b1b364..012f087 100644 --- a/developer/manager/text.py +++ b/developer/manager/text.py @@ -1,6 +1,6 @@ # text.py -from version import version as current_version +from env import version as current_version class Text: @@ -29,7 +29,7 @@ Usage: {program_name} version # print version {program_name} init - {program_name} make + {program_name} make [_]* {program_name} list {program_name} info | {program_name} information @@ -47,8 +47,8 @@ Usage: {program_name} network up|down - {program_name} option set - {program_name} option get + {program_name} option set + {program_name} option get {program_name} option list {program_name} exec -- ... @@ -63,7 +63,7 @@ Usage: Makes ./subu.db. Refuses to run if db exists. 2) Subu - {program_name} make + {program_name} make [_]* {program_name} list {program_name} info diff --git a/developer/manager/version.py b/developer/manager/version.py deleted file mode 100644 index ed3750c..0000000 --- a/developer/manager/version.py +++ /dev/null @@ -1,2 +0,0 @@ -def version(): - return "0.3.2" diff --git a/developer/tool/env b/developer/tool/env index 7f09d92..144a073 100644 --- a/developer/tool/env +++ b/developer/tool/env @@ -1,43 +1,2 @@ #!/usr/bin/env bash script_afp=$(realpath "${BASH_SOURCE[0]}") - -# input guards - - env_must_be="tool_shared/bespoke/env" - error=false - if [ "$ENV" != "$env_must_be" ]; then - echo "$(script_fp):: error: must be run in the $env_must_be environment" - error=true - fi - if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then - echo "$script_afp:: This script must be sourced, not executed." - error=true - fi - if $error; then exit 1; fi - -# so we can do the build - -export PATH=\ -"$REPO_HOME"/developer/tool/\ -:"$REPO_HOME"/tool_shared/bespoke/\ -:"$PATH" - -# so we can run the stuff we built locally. -export PATH=\ -"$REPO_HOME"/developer/shell\ -:"$PATH" - -# misc - - # make .githolder and .gitignore visible - alias ls="ls -a" - -# some feedback to show all went well - - export PROMPT_DECOR="$PROJECT"_developer - export ENV=$(script_fp) - echo ENV "$ENV" - cd "$REPO_HOME"/developer/ - - - diff --git a/developer/tool/release b/developer/tool/release index 2846eac..c41207b 100755 --- a/developer/tool/release +++ b/developer/tool/release @@ -1,56 +1,389 @@ -#!/usr/bin/env bash -script_afp=$(realpath "${BASH_SOURCE[0]}") - -# input guards - - if [ -z "$REPO_HOME" ]; then - echo "$(script_fp):: REPO_HOME is not set." - exit 1 - fi - - env_must_be="developer/tool/env" - if [ "$ENV" != "$env_must_be" ]; then - echo "$(script_fp):: error: must be run in the $env_must_be environment" - exit 1 - fi - -# script local environment - - release_dir="$REPO_HOME/release" - shell_dir="$REPO_HOME/developer/shell" - - if [ ! -d "$release_dir" ]; then - mkdir -p "$release_dir" - fi - - # Function to copy and set permissions - install_file() { - source_fp="$1" - target_dp="$2" - perms="$3" - - target_file="$target_dp/$(basename "$source_fp")" - - if [ ! -f "$source_fp" ]; then - echo "install_file:: Source file '$source_fp' does not exist." - return 1 - fi - - if ! install -m "$perms" "$source_fp" "$target_file"; then - echo "Error: Failed to install $(basename "$source_fp") to $target_dp" - exit 1 - else - echo "Installed $(basename "$source_fp") to $target_dp with permissions $perms" - fi - } - -# do the release - - echo "Starting release process..." - - # Install shell scripts - for script in $shell_dir/*; do - install_file "$script" "$release_dir" "ug+r+x" - done - -echo "$(script_fp) done." +#!/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 + +HELP = """usage: release {write|clean|ls|help|dry write} [DIR] + write [DIR] Write released files. + - For DIR=manager: copy $REPO_HOME/developer/manager into $REPO_HOME/release/manager. + - For other DIR values: write from developer/scratchpad/DIR into $REPO_HOME/release/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. + help Show this message. + dry write [DIR] + Preview what write would do without modifying the filesystem. +""" + +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 assert_env(): + env = os.environ.get("ENV", "") + if env != ENV_MUST_BE: + hint = ( + "ENV is not 'developer/tool/env'.\n" + "Enter the project with: source ./env_developer\n" + "That script exports: ROLE=developer; ENV=$ROLE/tool/env" + ) + exit_with_status(f"bad environment: ENV='{env}'. {hint}") + + +def repo_home(): + rh = os.environ.get("REPO_HOME") + if not rh: + exit_with_status("REPO_HOME not set (did you 'source ./env_developer'?)") + return rh + + +def dpath(*parts): + # Developer tree root: $REPO_HOME/developer/... + return os.path.join(repo_home(), "developer", *parts) + + +def rpath(*parts): + """ + Release root: $REPO_HOME/release/... + + Manager releases go to $REPO_HOME/release/manager. + """ + return os.path.join(repo_home(), "release", *parts) + + +def dev_root(): + return dpath() + + +def rel_root(): + return rpath() + + +def _display_src(p_abs: str) -> str: + try: + if os.path.commonpath([dev_root()]) == os.path.commonpath([dev_root(), p_abs]): + return os.path.relpath(p_abs, dev_root()) + except Exception: + pass + return p_abs + + +def _display_dst(p_abs: str) -> str: + try: + rel = os.path.relpath(p_abs, rel_root()) + rel = "" if rel == "." else rel + return "$REPO_HOME/release" + ("/" + rel if rel else "") + except Exception: + return p_abs + + +def ensure_mode(path, mode): + try: + os.chmod(path, mode) + except Exception: + pass + + +def ensure_dir(path, mode=DEFAULT_DIR_MODE, dry=False): + if dry: + if not os.path.isdir(path): + shown = _display_dst(path) if path.startswith(rel_root()) else ( + os.path.relpath(path, dev_root()) if path.startswith(dev_root()) else path + ) + print(f"(dry) mkdir -m {oct(mode)[2:]} '{shown}'") + return + 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}" + except Exception: + return f"{st.st_uid}:{st.st_gid}" + + +# ---------- LS (two-pass owner:group width) ---------- +def list_tree(root): + if not os.path.isdir(root): + return + entries = [] + + def gather(path: str, depth: int, is_root: bool): + try: + it = list(os.scandir(path)) + except FileNotFoundError: + return + dirs = [e for e in it if e.is_dir(follow_symlinks=False)] + files = [e for e in it if not e.is_dir(follow_symlinks=False)] + dirs.sort(key=lambda e: e.name) + files.sort(key=lambda e: e.name) + + if is_root: + 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)) + for d in dirs: + st = os.lstat(d.path) + entries.append((True, depth, filemode(st.st_mode), owner_group(st), d.name + "/")) + gather(d.path, depth + 1, False) + 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)) + else: + for d in dirs: + st = os.lstat(d.path) + entries.append((True, depth, filemode(st.st_mode), owner_group(st), 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)) + + gather(root, depth=1, is_root=True) + + ogw = 0 + for (_isdir, _depth, _perms, ownergrp, _name) in entries: + if len(ownergrp) > ogw: + ogw = len(ownergrp) + + print("release/") + for (_isdir, depth, perms, ownergrp, name) in entries: + indent = " " * depth + print(f"{perms} {ownergrp:<{ogw}} {indent}{name}") +# ---------- end LS ---------- + + +def iter_src_files(topdir, src_root): + """ + Yield (src_abs, rel) pairs. + + For manager: + - base is $REPO_HOME/developer/manager + + For other topdirs (future reuse), preserves original Harmony behavior: + - base is src_root/topdir (usually developer/scratchpad/topdir) + """ + if topdir == "manager": + base = dpath("manager") + else: + base = os.path.join(src_root, topdir) + + if not os.path.isdir(base): + return + yield + + if topdir == "kmod": + for p in sorted(glob.glob(os.path.join(base, "*.ko"))): + yield (p, os.path.basename(p)) + else: + for root, dirs, files in os.walk(base): + dirs.sort() + files.sort() + for fn in files: + src = os.path.join(root, fn) + rel = os.path.relpath(src, base) + yield (src, rel) + + +def _target_mode_from_source(src_abs: str) -> int: + """077 policy: files 0600; if source has owner-exec, make 0700.""" + try: + sm = stat.S_IMODE(os.stat(src_abs).st_mode) + except FileNotFoundError: + return 0o600 + return 0o700 if (sm & stat.S_IXUSR) else 0o600 + + +def copy_one(src_abs, dst_abs, dry=False): + src_show = _display_src(src_abs) + dst_show = _display_dst(dst_abs) + parent = os.path.dirname(dst_abs) + os.makedirs(parent, exist_ok=True) + target_mode = _target_mode_from_source(src_abs) + + def _is_writable_dir(p): + return os.access(p, os.W_OK) + + flip_needed = not _is_writable_dir(parent) + restore_mode = None + parent_show = _display_dst(parent) + + if dry: + if flip_needed: + print(f"(dry) chmod u+w '{parent_show}'") + if os.path.exists(dst_abs): + print(f"(dry) unlink '{dst_show}'") + print(f"(dry) install -m {oct(target_mode)[2:]} -D '{src_show}' '{dst_show}'") + if flip_needed: + print(f"(dry) chmod u-w '{parent_show}'") + return + + try: + if flip_needed: + try: + st_parent = os.stat(parent) + restore_mode = stat.S_IMODE(st_parent.st_mode) + os.chmod(parent, restore_mode | stat.S_IWUSR) + except PermissionError: + exit_with_status( + f"cannot write: parent dir not writable and chmod failed on {parent_show}" + ) + + fd, tmp_path = tempfile.mkstemp(prefix=".tmp.", dir=parent) + try: + with os.fdopen(fd, "wb") as tmpf, open(src_abs, "rb") as sf: + shutil.copyfileobj(sf, tmpf) + tmpf.flush() + os.chmod(tmp_path, target_mode) + os.replace(tmp_path, dst_abs) + finally: + try: + if os.path.exists(tmp_path): + os.unlink(tmp_path) + except Exception: + pass + finally: + if restore_mode is not None: + try: + os.chmod(parent, restore_mode) + except Exception: + pass + + print(f"+ install -m {oct(target_mode)[2:]} '{src_show}' '{dst_show}'") + + +def write_one_dir(topdir, dry): + """ + For manager: + + - Source: $REPO_HOME/developer/manager + - Dest: $REPO_HOME/release/manager + + For other topdirs: + + - Source: $REPO_HOME/developer/scratchpad/ + - Dest: $REPO_HOME/release/ + """ + rel_root_dir = rpath() + src_root = dpath("scratchpad") + + if topdir == "manager": + src_dir = dpath("manager") + dst_dir = rpath("manager") + else: + src_dir = os.path.join(src_root, topdir) + dst_dir = os.path.join(rel_root_dir, topdir) + + if not os.path.isdir(src_dir): + shown = _display_src(src_dir) + if topdir == "manager": + exit_with_status( + f"cannot write: expected developer manager tree at '{shown}'. " + f"Expected directory: $REPO_HOME/developer/manager" + ) + else: + exit_with_status( + f"cannot write: expected '{shown}' to exist. " + f"Create developer/scratchpad/{topdir} (Makefiles may need to populate it)." + ) + + ensure_dir(dst_dir, DEFAULT_DIR_MODE, dry=dry) + + wrote = False + for src_abs, rel in iter_src_files(topdir, src_root): + dst_abs = os.path.join(dst_dir, rel) + copy_one(src_abs, dst_abs, dry=dry) + wrote = True + if not wrote: + msg = "no matching artifacts found" + if topdir == "kmod": + msg += " (looking for *.ko)" + print(f"(info) {msg} in {_display_src(src_dir)}") + + +def cmd_write(dir_arg, dry=False): + assert_env() + ensure_dir(rpath(), DEFAULT_DIR_MODE, dry=dry) + + if dir_arg: + write_one_dir(dir_arg, dry=dry) + else: + # Legacy behavior: auto-discover from scratchpad/ + src_root = dpath("scratchpad") + if not os.path.isdir(src_root): + print(f"(info) nothing to release; no scratchpad/ directory at {_display_src(src_root)}") + return + subs = sorted( + [e.name for e in os.scandir(src_root) if e.is_dir(follow_symlinks=False)] + ) + if not subs: + print( + f"(info) nothing to release; no subdirectories found under {_display_src(src_root)}" + ) + return + for td in subs: + write_one_dir(td, dry=dry) + + +def _clean_contents(dir_path): + if not os.path.isdir(dir_path): + return + for name in os.listdir(dir_path): + p = os.path.join(dir_path, name) + if os.path.isdir(p) and not os.path.islink(p): + shutil.rmtree(p, ignore_errors=True) + else: + try: + os.unlink(p) + except FileNotFoundError: + pass + + +def cmd_clean(dir_arg): + assert_env() + rel_root_dir = rpath() + if not os.path.isdir(rel_root_dir): + return + if dir_arg: + _clean_contents(os.path.join(rel_root_dir, dir_arg)) + else: + _clean_contents(rel_root_dir) + + +def CLI(): + if len(sys.argv) < 2: + print(HELP) + return + cmd, *args = sys.argv[1:] + if cmd == "write": + cmd_write(args[0] if args else None, dry=False) + elif cmd == "clean": + cmd_clean(args[0] if args else None) + elif cmd == "ls": + list_tree(rpath()) + elif cmd == "help": + print(HELP) + elif cmd == "dry": + if args and args[0] == "write": + cmd_write(args[1] if len(args) >= 2 else None, dry=True) + else: + print(HELP) + else: + print(HELP) + + +if __name__ == "__main__": + CLI() diff --git a/env_administrator b/env_administrator deleted file mode 100644 index bb09700..0000000 --- a/env_administrator +++ /dev/null @@ -1,10 +0,0 @@ -#!/usr/bin/env bash -script_afp=$(realpath "${BASH_SOURCE[0]}") -if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then - echo "$script_afp:: This script must be sourced, not executed." - exit 1 -fi - -source tool_shared/bespoke/env -source tool/env - diff --git a/env_developer b/env_developer index c080d44..7067d75 100644 --- a/env_developer +++ b/env_developer @@ -1,6 +1,6 @@ #!/usr/bin/env bash -# toolsmith owned developer environment file -# see developer/tool/env for the developer's customizations +# env_developer — enter the project developer environment +# (must be sourced) script_afp=$(realpath "${BASH_SOURCE[0]}") if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then @@ -8,25 +8,37 @@ if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then exit 1 fi -# environment shared by all roles -source tool_shared/bespoke/env - -export ROLE=developer -export ENV=$ROLE -if [[ -n "${ROLE:-}" ]] && [[ -d "$REPO_HOME/$ROLE/tool" ]]; then - PATH="$REPO_HOME/$ROLE/tool:$PATH" -fi - -# shared tools -# if [[ ":$PATH:" != *":$PYTHON_HOME/bin:"* ]]; then -# export PATH="$PYTHON_HOME/bin:$PATH" -# fi - -export PATH="$REPO_HOME/tool_shared/third_party/Man_In_Grey/release/shell":"$PATH" - -cd $ROLE - -# pull in developer's customization -# source tool/env $@ - -echo "in environment: $ENV" +# enter project environment +# + source tool_shared/bespoke/env + +# setup tools +# + export PYTHON_HOME="$REPO_HOME/tool_shared/third_party/Python" + if [[ ":$PATH:" != *":$PYTHON_HOME/bin:"* ]]; then + export PATH="$PYTHON_HOME/bin:$PATH" + fi + + RT_gcc="$REPO_HOME/tool_shared/third_party/RT_gcc/release" + if [[ ":$PATH:" != *":$RT_gcc:"* ]]; then + export PATH="$RT_gcc:$PATH" + fi + +# enter the role environment +# + export ROLE=developer + + tool="$REPO_HOME/$ROLE/tool" + if [[ ":$PATH:" != *":$tool:"* ]]; then + export PATH="$tool:$PATH" + fi + + export ENV=$ROLE/tool/env + + cd "$ROLE" + if [[ -f "tool/env" ]]; then + source "tool/env" + echo "in environment: $ENV" + else + echo "not found: $ENV" + fi diff --git a/env_tester b/env_tester index af74b18..5580c87 100644 --- a/env_tester +++ b/env_tester @@ -1,5 +1,6 @@ #!/usr/bin/env bash -# toolsmith-owned tester environment file +# env_tester — enter the project tester environment +# (must be sourced) script_afp=$(realpath "${BASH_SOURCE[0]}") if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then @@ -7,28 +8,37 @@ if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then exit 1 fi -# shared, project-wide -source tool_shared/bespoke/env - -export ROLE=tester -export ENV=$ROLE - -# tester-local tool dir first (if any) -if [[ -d "$REPO_HOME/$ROLE/tool" ]]; then - PATH="$REPO_HOME/$ROLE/tool:$PATH" -fi - -# shared Python (from toolsmith-provided venv) -if [[ ":$PATH:" != *":$PYTHON_HOME/bin:"* ]]; then - PATH="$PYTHON_HOME/bin:$PATH" -fi -export PATH - -export RELEASE="$REPO_HOME/release" - -cd "$ROLE" - -# pull in tester customizations (optional file) -[[ -f tool/env ]] && source tool/env "$@" - -echo "in environment: $ENV" +# enter project environment +# + source tool_shared/bespoke/env + +# setup tools +# + export PYTHON_HOME="$REPO_HOME/tool_shared/third_party/Python" + if [[ ":$PATH:" != *":$PYTHON_HOME/bin:"* ]]; then + export PATH="$PYTHON_HOME/bin:$PATH" + fi + + RT_gcc="$REPO_HOME/tool_shared/third_party/RT_gcc/release" + if [[ ":$PATH:" != *":$RT_gcc:"* ]]; then + export PATH="$RT_gcc:$PATH" + fi + +# enter the role environment +# + export ROLE=tester + + tool="$REPO_HOME/$ROLE/tool" + if [[ ":$PATH:" != *":$tool:"* ]]; then + export PATH="$tool:$PATH" + fi + + export ENV=$ROLE/tool/env + + cd "$ROLE" + if [[ -f "tool/env" ]]; then + source "tool/env" + echo "in environment: $ENV" + else + echo "not found: $ENV" + fi diff --git a/env_toolsmith b/env_toolsmith new file mode 100644 index 0000000..ee09200 --- /dev/null +++ b/env_toolsmith @@ -0,0 +1,45 @@ +#!/usr/bin/env bash +# env_toolsmith — enter the project toolsmith environment +# (must be sourced) + +script_afp=$(realpath "${BASH_SOURCE[0]}") +if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then + echo "$script_afp:: This script must be sourced, not executed." + exit 1 +fi + +# enter project environment +# + source tool_shared/bespoke/env + +# setup tools +# initially these will not exist, as the toolsmith installs them +# + export PYTHON_HOME="$REPO_HOME/tool_shared/third_party/Python" + if [[ ":$PATH:" != *":$PYTHON_HOME/bin:"* ]]; then + export PATH="$PYTHON_HOME/bin:$PATH" + fi + + RT_gcc="$REPO_HOME/tool_shared/third_party/RT_gcc/release" + if [[ ":$PATH:" != *":$RT_gcc:"* ]]; then + export PATH="$RT_gcc:$PATH" + fi + +# enter the role environment +# + export ROLE=toolsmith + + TOOL_DIR="$REPO_HOME/tool" + if [[ ":$PATH:" != *":$TOOL_DIR:"* ]]; then + export PATH="$TOOL_DIR:$PATH" + fi + + export ENV="tool/env" + + cd "$REPO_HOME" + if [[ -f "tool/env" ]]; then + source "tool/env" + echo "in environment: $ENV" + else + echo "not found: $ENV" + fi diff --git a/release/Man_In_Grey/.gitignore b/release/Man_In_Grey/.gitignore deleted file mode 100644 index aa0e8eb..0000000 --- a/release/Man_In_Grey/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -* -!/.gitignore \ No newline at end of file diff --git a/release/Python/.githolder b/release/Python/.githolder deleted file mode 100644 index e69de29..0000000 diff --git a/release/amd54/.githolder b/release/amd54/.githolder deleted file mode 100644 index e69de29..0000000 diff --git a/release/manager/CLI.py b/release/manager/CLI.py new file mode 100755 index 0000000..0185450 --- /dev/null +++ b/release/manager/CLI.py @@ -0,0 +1,272 @@ +#!/usr/bin/env python3 +# -*- mode: python; coding: utf-8; python-indent-offset: 2; indent-tabs-mode: nil -*- +""" +1. CLI.py + 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) + * call the right function in dispatch + * print text from text.py when needed + * exit with the returned status code +""" + +import os, sys, argparse +from text import make_text +import dispatch + + +def build_arg_parser(program_name): + """ + 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_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 + + The subu path is: + + masu subu subu ... + + For example: + subu make Thomas S0 + subu make Thomas S0 S1 + """ + # 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") + ap.add_argument("path", nargs ="+") # [masu, subu, subu, ...] + + # remove: same path structure + ap = subparsers.add_parser("remove") + ap.add_argument("path", nargs ="+") # [masu, subu, subu, ...] + + # list + subparsers.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") + + # 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': + WG global + WG make + WG server_provided_public_key + WG info|information + WG up|down + """ + ap = subparsers.add_parser("WG") + ap.add_argument( + "wg_verb", + choices=[ + "global", + "make", + "server_provided_public_key", + "info", + "information", + "up", + "down", + ], + ) + ap.add_argument("arg1", nargs="?") + ap.add_argument("arg2", nargs="?") + + +def register_attach_commands(subparsers): + """ + Register attach and detach commands: + attach WG + detach WG + """ + ap = subparsers.add_parser("attach") + ap.add_argument("what", choices=["WG"]) + ap.add_argument("subu_id") + ap.add_argument("wg_id") + + ap = subparsers.add_parser("detach") + ap.add_argument("what", choices=["WG"]) + ap.add_argument("subu_id") + + +def register_network_commands(subparsers): + """ + Register network aggregate commands: + network up|down + """ + ap = subparsers.add_parser("network") + ap.add_argument("state", choices=["up","down"]) + ap.add_argument("subu_id") + + +def register_option_commands(subparsers): + """ + Register option commands: + option set|get|list ... + """ + 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="?") + + +def register_exec_commands(subparsers): + """ + Register exec command: + exec -- ... + """ + 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 + # 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. + """ + if argv is None: + argv = sys.argv[1:] + + # Determine the program name for text/help: + # + # 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 + else: + raw0 = sys.argv[0] or "subu" + program_name = os.path.basename(raw0) or "subu" + + text = make_text(program_name) + + # No arguments is the same as "help". + if not argv: + print(text.help(), end ="") + return 0 + + # Simple verbs that bypass argparse so they always work. + simple = { + "help": text.help, + "--help": text.help, + "-h": text.help, + "usage": text.usage, + "example": text.example, + "version": text.version, + } + if argv[0] in simple: + 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 ="") + 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) + + if ns.verb == "lo": + return dispatch.lo_toggle(ns.subu_id, ns.state) + + 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) + return 2 + if v == "global": + return dispatch.wg_global(ns.arg1) + if v == "make": + 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"): + return dispatch.wg_info(ns.arg1) + if v == "up": + return dispatch.wg_up(ns.arg1) + if v == "down": + return dispatch.wg_down(ns.arg1) + + if ns.verb == "attach": + if ns.what == "WG": + return dispatch.attach_wg(ns.subu_id, ns.wg_id) + + if ns.verb == "detach": + if ns.what == "WG": + return dispatch.detach_wg(ns.subu_id) + + if ns.verb == "network": + return dispatch.network_toggle(ns.subu_id, ns.state) + + if ns.verb == "option": + if ns.area == "Unix": + return dispatch.option_unix(ns.mode) + + if ns.verb == "exec": + if not ns.cmd: + print(f"{program_name} exec -- ...", 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 ="") + return 2 + + except Exception as e: + print(f"error: {e}", file =sys.stderr) + return 1 + +if __name__ == "__main__": + sys.exit(CLI()) diff --git a/release/manager/dispatch.py b/release/manager/dispatch.py new file mode 100644 index 0000000..48b7aeb --- /dev/null +++ b/release/manager/dispatch.py @@ -0,0 +1,173 @@ +# dispatch.py +# -*- mode: python; coding: utf-8; python-indent-offset: 2; indent-tabs-mode: nil -*- + +import os, sys +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): + """ + Handle: subu init + + 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 + """ + db_path = env.db_path() + if os.path.exists(db_path): + print("subu.db already exists; refusing to overwrite", file =sys.stderr) + return 1 + + conn = open_db(db_path) + try: + ensure_schema(conn) + finally: + conn.close() + return 0 + +def subu_make(path_tokens: list[str]) -> int: + """ + Handle: subu make [ ...] + + path_tokens is: + [masu, subu, subu, ...] + + Example: + subu make Thomas S0 + subu make Thomas S0 S1 + """ + if not path_tokens or len(path_tokens) < 2: + print( + "subu: make requires at least and one component", + file =sys.stderr, + ) + return 2 + + masu = path_tokens[0] + subu_path = path_tokens[1:] + + 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) + return 2 + except Exception as e: + print(f"error making subu: {e}", file =sys.stderr) + return 1 + +def subu_remove(path_tokens: list[str]) -> int: + """ + Handle: subu remove [ ...] + + path_tokens is: + [masu, subu, subu, ...] + """ + if not path_tokens or len(path_tokens) < 2: + print( + "subu: remove requires at least and one component", + file =sys.stderr, + ) + return 2 + + masu = path_tokens[0] + subu_path = path_tokens[1:] + + 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 + except Exception as e: + print(f"error removing subu: {e}", file =sys.stderr) + return 1 + +def option_unix(mode: str) -> int: + """ + Handle: subu option Unix dry|run + + Example: + subu option Unix dry + subu option Unix run + """ + 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}") + 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_list(): + raise NotImplementedError("subu_list is not yet made") + + +def subu_info(subu_id): + raise NotImplementedError("subu_info is not yet made") + + +def lo_toggle(subu_id, state): + raise NotImplementedError("lo_toggle is not yet made") + + +def wg_global(base_cidr): + raise NotImplementedError("wg_global is not yet made") + + +def wg_make(endpoint): + raise NotImplementedError("wg_make is not yet made") + + +def wg_server_public_key(wg_id, key): + raise NotImplementedError("wg_server_public_key is not yet made") + + +def wg_info(wg_id): + raise NotImplementedError("wg_info is not yet made") + + +def wg_up(wg_id): + raise NotImplementedError("wg_up is not yet made") + + +def wg_down(wg_id): + raise NotImplementedError("wg_down is not yet made") + + +def attach_wg(subu_id, wg_id): + raise NotImplementedError("attach_wg is not yet made") + + +def detach_wg(subu_id): + raise NotImplementedError("detach_wg is not yet made") + + +def network_toggle(subu_id, state): + raise NotImplementedError("network_toggle is not yet made") + + +def option_set(subu_id, name, value): + raise NotImplementedError("option_set is not yet made") + + +def option_get(subu_id, name): + raise NotImplementedError("option_get is not yet made") + + +def option_list(subu_id): + raise NotImplementedError("option_list is not yet made") + + +def exec(subu_id, cmd_argv): + raise NotImplementedError("exec is not yet made") diff --git a/release/manager/domain/exec.py b/release/manager/domain/exec.py new file mode 100644 index 0000000..0826560 --- /dev/null +++ b/release/manager/domain/exec.py @@ -0,0 +1,12 @@ +""" +4.5 domain/exec.py + +Run a command inside a subu’s namespace and UID. + +4.5.1 run_in_subu(subu: Subu, cmd_argv: list[str]) -> int +""" +def exec_in_subu(subu_id: str, cmd: list): + sid = int(subu_id.split("_")[1]) + with closing(_db()) as db: + ns = db.execute("SELECT netns FROM subu WHERE id=?", (sid,)).fetchone()[0] + os.execvp("ip", ["ip","netns","exec", ns] + cmd) diff --git a/release/manager/domain/network.py b/release/manager/domain/network.py new file mode 100644 index 0000000..2ea77b4 --- /dev/null +++ b/release/manager/domain/network.py @@ -0,0 +1,33 @@ +""" +4.3 domain/network.py + +Netns + device wiring, including aggregate “network up/down”. + +4.3.1 lo_toggle(subu: Subu, state: str) -> None +4.3.2 attach_wg(subu: Subu, wg: WG) -> None +4.3.3 detach_wg(subu: Subu) -> None +4.3.4 network_toggle(subu: Subu, state: str) -> None +""" +def network_toggle(subu_id: str, state: str): + sid = int(subu_id.split("_")[1]) + with closing(_db()) as db: + ns, wid = db.execute("SELECT netns,wg_id FROM subu WHERE id=?", (sid,)).fetchone() + # always make sure lo up on 'up' + if state == "up": + run(["ip", "netns", "exec", ns, "ip", "link", "set", "lo", "up"], check=False) + if wid is not None: + ifname = f"subu_{wid}" + run(["ip", "-n", ns, "link", "set", "dev", ifname, state], check=False) + with closing(_db()) as db: + db.execute("UPDATE subu SET network_state=? WHERE id=?", (state, sid)) + db.commit() + print(f"{subu_id}: network {state}") + +def _make_netns_for_subu(subu_id_num: int, netns_name: str): + """ + Create the network namespace & bring lo down. + """ + # ip netns add ns-subu_ + run(["ip", "netns", "add", netns_name]) + # ip netns exec ns-subu_ ip link set lo down + run(["ip", "netns", "exec", netns_name, "ip", "link", "set", "lo", "down"]) diff --git a/release/manager/domain/options.py b/release/manager/domain/options.py new file mode 100644 index 0000000..76fd97e --- /dev/null +++ b/release/manager/domain/options.py @@ -0,0 +1,31 @@ +""" +4.4 domain/options.py + +Per-subu options, backed by DB. + +4.4.1 set_option(subu_id: str, name: str, value: str) -> None +4.4.2 get_option(subu_id: str, name: str) -> str | None +4.4.3 list_options(subu_id: str) -> dict[str, str] +""" +def option_set(subu_id: str, name: str, value: str): + sid = int(subu_id.split("_")[1]) + with closing(_db()) as db: + db.execute("INSERT INTO options (subu_id,name,value) VALUES(?,?,?) " + "ON CONFLICT(subu_id,name) DO UPDATE SET value=excluded.value", + (sid, name, value)) + db.commit() + print("ok") + +def option_get(subu_id: str, name: str): + sid = int(subu_id.split("_")[1]) + with closing(_db()) as db: + row = db.execute("SELECT value FROM options WHERE subu_id=? AND name=?", (sid,name)).fetchone() + print(row[0] if row else "") + +def option_list(subu_id: str): + sid = int(subu_id.split("_")[1]) + with closing(_db()) as db: + rows = db.execute("SELECT name,value FROM options WHERE subu_id=?", (sid,)).fetchall() + for n,v in rows: + print(f"{n}={v}") + diff --git a/release/manager/domain/subu.py b/release/manager/domain/subu.py new file mode 100644 index 0000000..53f674d --- /dev/null +++ b/release/manager/domain/subu.py @@ -0,0 +1,124 @@ +# domain/subu.py +# -*- mode: python; coding: utf-8; python-indent-offset: 2; indent-tabs-mode: nil -*- + +from infrastructure.unix import ( + ensure_unix_user, + remove_unix_user_and_group, + user_exists, +) + + +def _validate_token(label: str, token: str): + """ + Validate a single path token (masu or subu). + + Rules: + - must be non-empty after stripping whitespace + - must not contain underscore '_' + """ + token_stripped = token.strip() + if not token_stripped: + raise SystemExit(f"subu: {label} name must be non-empty") + if "_" in token_stripped: + raise SystemExit( + f"subu: {label} name '{token_stripped}' must not contain underscore '_'" + ) + # dashes are fine; acronyms and proper nouns are fine. + return token_stripped + + +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" + + The path is: + masu subu subu ... + """ + masu_s = _validate_token("masu", masu).replace(" ", "_") + subu_parts = [] + for s in path_components: + subu_parts.append(_validate_token("subu", s).replace(" ", "_")) + parts = [masu_s] + subu_parts + return "_".join(parts) + + +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" + """ + if len(path_components) <= 1: + return None + # parent path is everything except last token + parent_path = path_components[:-1] + return subu_username(masu, parent_path) + + +def make_subu(masu: str, path_components: list[str]) -> str: + """ + Make the Unix user and group for this subu. + + The subu path is: + masu subu subu ... + + Rules: + - len(path_components) >= 1 + - 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 + + Returns: + Unix username, for example 'Thomas_S0' or 'Thomas_S0_S1'. + """ + if not path_components: + 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 + parent_uname = _parent_username(masu, path_components) + if parent_uname is None: + # Top-level subu: require the masu Unix user to exist + 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" + ) + 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" + ) + + # For now, group and user share the same name. + ensure_unix_user(username, username) + return username + + +def remove_subu(masu: str, path_components: list[str]) -> str: + """ + Remove the Unix user and group for this subu, if they exist. + + The subu path is: + masu subu subu ... + + Returns: + Unix username that was targeted. + """ + if not path_components: + raise SystemExit("subu: remove requires at least one subu component") + + username = subu_username(masu, path_components) + remove_unix_user_and_group(username) + return username diff --git a/release/manager/domain/wg.py b/release/manager/domain/wg.py new file mode 100644 index 0000000..0cf6126 --- /dev/null +++ b/release/manager/domain/wg.py @@ -0,0 +1,60 @@ +""" +4.2 domain/wg.py + +WireGuard objects, independent of subu. + +4.2.1 set_global_pool(base_cidr: str) -> None +4.2.2 make_wg(endpoint: str) -> WG +4.2.3 set_server_public_key(wg_id: str, key: str) -> None +4.2.4 get_wg(wg_id: str) -> WG +4.2.5 bring_up(wg_id: str) -> None +4.2.6 bring_down(wg_id: str) -> None +""" + +def wg_global(basecidr: str): + WG_GLOBAL_FILE.write_text(basecidr.strip()+"\n") + print(f"WG pool base = {basecidr}") + +def _alloc_ip(idx: int, base: str) -> str: + # simplistic /24 allocator: base must be x.y.z.0/24 + prefix = base.split("/")[0].rsplit(".", 1)[0] + host = 2 + idx + return f"{prefix}.{host}/32" + +def wg_make(endpoint: str) -> str: + if not WG_GLOBAL_FILE.exists(): + raise RuntimeError("set WG base with `subu WG global ` first") + base = WG_GLOBAL_FILE.read_text().strip() + with closing(_db()) as db: + c = db.cursor() + idx = c.execute("SELECT COUNT(*) FROM wg").fetchone()[0] + local_ip = _alloc_ip(idx, base) + c.execute("INSERT INTO wg (endpoint, local_ip, allowed_ips) VALUES (?, ?, ?)", + (endpoint, local_ip, "0.0.0.0/0")) + wid = c.lastrowid + db.commit() + print(f"WG_{wid} endpoint={endpoint} ip={local_ip}") + return f"WG_{wid}" + +def wg_set_pubkey(wg_id: str, key: str): + wid = int(wg_id.split("_")[1]) + with closing(_db()) as db: + db.execute("UPDATE wg SET pubkey=? WHERE id=?", (key, wid)) + db.commit() + print("ok") + +def wg_info(wg_id: str): + wid = int(wg_id.split("_")[1]) + with closing(_db()) as db: + row = db.execute("SELECT * FROM wg WHERE id=?", (wid,)).fetchone() + print(row if row else "not found") + +def wg_up(wg_id: str): + wid = int(wg_id.split("_")[1]) + # Admin-up of WG device handled via network_toggle once attached. + print(f"{wg_id}: up (noop until attached)") + +def wg_down(wg_id: str): + wid = int(wg_id.split("_")[1]) + print(f"{wg_id}: down (noop until attached)") + diff --git a/release/manager/env.py b/release/manager/env.py new file mode 100644 index 0000000..55b28a4 --- /dev/null +++ b/release/manager/env.py @@ -0,0 +1,55 @@ +# env.py +# -*- mode: python; coding: utf-8; python-indent-offset: 2; indent-tabs-mode: nil -*- + +from pathlib import Path + + +def version() -> str: + """ + Software / CLI version. + """ + return "0.3.3" + + +def db_schema_version() -> str: + """ + Database schema version (used in the DB filename). + + This only changes when the DB layout/semantics change, + not for every CLI code change. + """ + return "0.1" + + +def db_root_dir() -> Path: + """ + Default directory for the system-wide subu database. + + This is intentionally independent of the project/repo location. + """ + return Path("/opt/subu") + + +def db_filename() -> str: + """ + Default SQLite database filename, including schema version. + + subu_.sqlite3 + + Example: subu_0.1.sqlite3 + """ + return f"subu_{db_schema_version()}.sqlite3" + + +def db_path() -> str: + """ + Full path to the SQLite database file. + + Currently this is: + + /opt/subu/subu_.sqlite3 + + There is deliberately no environment override here; this path + defines the canonical system-wide DB used by all manager invocations. + """ + return str(db_root_dir() / db_filename()) diff --git a/release/manager/infrastructure/bpf.py b/release/manager/infrastructure/bpf.py new file mode 100644 index 0000000..16c4388 --- /dev/null +++ b/release/manager/infrastructure/bpf.py @@ -0,0 +1,60 @@ +""" +bpf.py + +Compile/load the BPF program. + +5.3.1 compile_bpf(source_path: str, output_path: str) -> None +5.3.2 load_bpf(obj_path: str) -> BpfHandle +""" + +def attach_wg(subu_id: str, wg_id: str): + ensure_mounts() + sid = int(subu_id.split("_")[1]); wid = int(wg_id.split("_")[1]) + with closing(_db()) as db: + r = db.execute("SELECT netns FROM subu WHERE id=?", (sid,)).fetchone() + if not r: raise ValueError("subu not found") + ns = r[0] + w = db.execute("SELECT endpoint, local_ip, pubkey FROM wg WHERE id=?", (wid,)).fetchone() + if not w: raise ValueError("WG not found") + endpoint, local_ip, pubkey = w + + ifname = f"subu_{wid}" + # make WG link in init ns, move to netns + run(["ip", "link", "add", ifname, "type", "wireguard"]) + run(["ip", "link", "set", ifname, "netns", ns]) + run(["ip", "-n", ns, "addr", "add", local_ip, "dev", ifname], check=False) + run(["ip", "-n", ns, "link", "set", "dev", ifname, "mtu", "1420"]) + run(["ip", "-n", ns, "link", "set", "dev", ifname, "down"]) # keep engine down until `network up` + + # install steering (MVP: make cgroup + attach bpf program) + try: + install_steering(subu_id, ns, ifname) + print(f"{subu_id}: eBPF steering installed -> {ifname}") + except BpfError as e: + print(f"{subu_id}: steering warning: {e}") + + with closing(_db()) as db: + db.execute("UPDATE subu SET wg_id=? WHERE id=?", (wid, sid)) + db.commit() + print(f"attached {wg_id} to {subu_id} in {ns} as {ifname}") + +def detach_wg(subu_id: str): + ensure_mounts() + sid = int(subu_id.split("_")[1]) + with closing(_db()) as db: + r = db.execute("SELECT netns,wg_id FROM subu WHERE id=?", (sid,)).fetchone() + if not r: print("not found"); return + ns, wid = r + if wid is None: + print("nothing attached"); return + ifname = f"subu_{wid}" + run(["ip", "-n", ns, "link", "del", ifname], check=False) + try: + remove_steering(subu_id) + except BpfError as e: + print(f"steering remove warn: {e}") + with closing(_db()) as db: + db.execute("UPDATE subu SET wg_id=NULL WHERE id=?", (sid,)) + db.commit() + print(f"detached WG_{wid} from {subu_id}") + diff --git a/release/manager/infrastructure/bpf_force_egress.c b/release/manager/infrastructure/bpf_force_egress.c new file mode 100644 index 0000000..628cc83 --- /dev/null +++ b/release/manager/infrastructure/bpf_force_egress.c @@ -0,0 +1,48 @@ +// -*- mode: c; c-basic-offset: 2; indent-tabs-mode: nil; tab-width: 8 -*- +// bpf_force_egress.c — MVP scaffold to validate UID and prep metadata +/* + bpf_force_egress.c + +5.5.1 no callable Python API; compiled/used via bpf.py. +*/ +#include +#include +#include + + +char LICENSE[] SEC("license") = "GPL"; + +struct { + __uint(type, BPF_MAP_TYPE_HASH); + __type(key, __u32); // tgid + __type(value, __u32); // reserved (target ifindex placeholder) + __uint(max_entries, 1024); +} subu_tgid2if SEC(".maps"); + +// Helper: return 0 = allow, <0 reject +static __always_inline int allow_uid(struct bpf_sock_addr *ctx) { + // MVP: just accept everyone; you can gate on UID 2017 with bpf_get_current_uid_gid() + // __u32 uid = (__u32)(bpf_get_current_uid_gid() & 0xffffffff); + // if (uid != 2017) return -1; + return 0; +} + +// Hook: cgroup/connect4 — runs before connect(2) proceeds +SEC("cgroup/connect4") +int subu_connect4(struct bpf_sock_addr *ctx) +{ + if (allow_uid(ctx) < 0) return -1; + // Future: read pinned map/meta, set SO_* via bpf_setsockopt when permitted + return 0; +} + +// Hook: cgroup/post_bind4 — runs after a local bind is chosen +SEC("cgroup/post_bind4") +int subu_post_bind4(struct bpf_sock *sk) +{ + // Future: enforce bound dev if kernel helper allows; record tgid->ifindex + __u32 tgid = bpf_get_current_pid_tgid() >> 32; + __u32 val = 0; + bpf_map_update_elem(&subu_tgid2if, &tgid, &val, BPF_ANY); + return 0; +} diff --git a/release/manager/infrastructure/bpf_worker.py b/release/manager/infrastructure/bpf_worker.py new file mode 100644 index 0000000..66aaf6d --- /dev/null +++ b/release/manager/infrastructure/bpf_worker.py @@ -0,0 +1,84 @@ +# -*- mode: python; coding: utf-8; python-indent-offset: 2; indent-tabs-mode: nil -*- +""" +bpf_worker.py + +Cgroup + BPF orchestration for per-subu steering. + +5.4.1 ensure_mounts() -> None +5.4.2 install_steering(subu: Subu, wg_iface: str) -> None +5.4.3 remove_steering(subu: Subu) -> None +5.4.4 class BpfError(Exception) +""" +import os, subprocess, json +from pathlib import Path + +class BpfError(RuntimeError): pass + +def run(cmd, check=True): + r = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) + if check and r.returncode != 0: + raise BpfError(f"cmd failed: {' '.join(cmd)}\n{r.stderr}") + return r.stdout.strip() + +def ensure_mounts(): + # ensure bpf and cgroup v2 are mounted + try: + Path("/sys/fs/bpf").mkdir(parents=True, exist_ok=True) + run(["mount","-t","bpf","bpf","/sys/fs/bpf"], check=False) + except Exception: + pass + try: + Path("/sys/fs/cgroup").mkdir(parents=True, exist_ok=True) + run(["mount","-t","cgroup2","none","/sys/fs/cgroup"], check=False) + except Exception: + pass + +def cgroup_path(subu_id: str) -> str: + return f"/sys/fs/cgroup/{subu_id}" + +def install_steering(subu_id: str, netns: str, ifname: str): + ensure_mounts() + cg = Path(cgroup_path(subu_id)) + cg.mkdir(parents=True, exist_ok=True) + + # compile BPF + obj = Path("./bpf_force_egress.o") + src = Path("./bpf_force_egress.c") + if not src.exists(): + raise BpfError("bpf_force_egress.c missing next to manager") + + # Build object (requires clang/llc/bpftool) + run(["clang","-O2","-g","-target","bpf","-c",str(src),"-o",str(obj)]) + + # Load program into bpffs; attach to cgroup/inet4_connect + inet4_post_bind (MVP) + pinned = f"/sys/fs/bpf/{subu_id}_egress" + run(["bpftool","prog","loadall",str(obj),pinned], check=True) + + # Attach to hooks (MVP validation hooks) + # NOTE: these are safe no-ops for now; they validate UID and stash ifindex map. + for hook in ("cgroup/connect4","cgroup/post_bind4"): + run(["bpftool","cgroup","attach",cgroup_path(subu_id),"attach",hook,"pinned",f"{pinned}/prog_0"], check=False) + + # Write metadata for ifname (saved for future prog versions) + meta = {"ifname": ifname} + Path(f"/sys/fs/bpf/{subu_id}_meta.json").write_text(json.dumps(meta)) + +def remove_steering(subu_id: str): + cg = cgroup_path(subu_id) + # Detach whatever is attached + for hook in ("cgroup/connect4","cgroup/post_bind4"): + subprocess.run(["bpftool","cgroup","detach",cg,"detach",hook], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) + # Remove pinned prog dir + pinned = Path(f"/sys/fs/bpf/{subu_id}_egress") + if pinned.exists(): + subprocess.run(["bpftool","prog","detach",str(pinned)], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) + try: + for p in pinned.glob("*"): p.unlink() + pinned.rmdir() + except Exception: + pass + # Remove cgroup dir + try: + Path(cg).rmdir() + except Exception: + pass diff --git a/release/manager/infrastructure/db.py b/release/manager/infrastructure/db.py new file mode 100644 index 0000000..9800c45 --- /dev/null +++ b/release/manager/infrastructure/db.py @@ -0,0 +1,38 @@ +# infrastructure/db.py +# -*- mode: python; coding: utf-8; python-indent-offset: 2; indent-tabs-mode: nil -*- + +import sqlite3 +from pathlib import Path +import env + + +def schema_path_default(): + """ + Path to schema.sql, assumed to live next to this file. + """ + return Path(__file__).with_name("schema.sql") + + +def open_db(path =None): + """ + Return a sqlite3.Connection with sensible pragmas. + Caller is responsible for closing. + """ + if path is None: + path = env.db_path() + conn = sqlite3.connect(path) + conn.row_factory = sqlite3.Row + conn.execute("PRAGMA foreign_keys = ON") + conn.execute("PRAGMA journal_mode = WAL") + conn.execute("PRAGMA synchronous = NORMAL") + return conn + + +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") + conn.executescript(sql) + conn.commit() diff --git a/release/manager/infrastructure/options_store.py b/release/manager/infrastructure/options_store.py new file mode 100644 index 0000000..913a383 --- /dev/null +++ b/release/manager/infrastructure/options_store.py @@ -0,0 +1,58 @@ +# infrastructure/options_store.py +# -*- mode: python; coding: utf-8; python-indent-offset: 2; indent-tabs-mode: nil -*- + +from pathlib import Path + +# Options file lives next to CLI in the manager release tree. +# In dev it will be the same relative layout. +OPTIONS_FILE = Path("subu.options") + + +def load_options(): + """ + Load options from subu.options into a dictionary. + + Lines are of the form: key=value + Lines starting with '#' or blank lines are ignored. + """ + opts = {} + if not OPTIONS_FILE.exists(): + return opts + text = OPTIONS_FILE.read_text(encoding="utf-8") + for line in text.splitlines(): + line = line.strip() + if not line or line.startswith("#"): + continue + if "=" not in line: + continue + k, v = line.split("=", 1) + opts[k.strip()] = v.strip() + return opts + + +def save_options(opts: dict): + """ + Save a dictionary of options back to subu.options. + """ + lines = [] + for k in sorted(opts.keys()): + v = opts[k] + lines.append(f"{k}={v}\n") + OPTIONS_FILE.write_text("".join(lines), encoding="utf-8") + + +def set_option(name: str, value: str): + """ + Set a single option key to a value. + """ + opts = load_options() + opts[name] = value + save_options(opts) + + +def get_option(name: str, default=None): + """ + Get an option value by name, or default if missing. + """ + opts = load_options() + return opts.get(name, default) diff --git a/release/manager/infrastructure/schema.sql b/release/manager/infrastructure/schema.sql new file mode 100644 index 0000000..ab8d80a --- /dev/null +++ b/release/manager/infrastructure/schema.sql @@ -0,0 +1,17 @@ +-- schema.sql +-- +-- 5.6.1 read and executed by db.ensure_schema + + + +CREATE TABLE subu ( + id INTEGER PRIMARY KEY, + owner TEXT NOT NULL, -- root user, e.g. 'Thomas' + name TEXT NOT NULL, -- leaf, e.g. 'US', 'Rabbit' + full_unix_name TEXT NOT NULL UNIQUE, -- e.g. 'Thomas_US_Rabbit' + path TEXT NOT NULL, -- e.g. 'Thomas US Rabbit' + netns_name TEXT NOT NULL, + wg_id INTEGER, -- nullable for now + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); diff --git a/release/manager/infrastructure/unix.py b/release/manager/infrastructure/unix.py new file mode 100644 index 0000000..6aa86cf --- /dev/null +++ b/release/manager/infrastructure/unix.py @@ -0,0 +1,69 @@ +# infrastructure/unix.py +# -*- mode: python; coding: utf-8; python-indent-offset: 2; indent-tabs-mode: nil -*- + +import subprocess, pwd, grp + + +def run(cmd, check =True): + """ + Run a Unix command, capturing output. + + Raises RuntimeError if check is True and the command fails. + """ + r = subprocess.run( + cmd, + stdout =subprocess.PIPE, + stderr =subprocess.PIPE, + text =True, + ) + if check and r.returncode != 0: + raise RuntimeError(f"cmd failed: {' '.join(cmd)}\n{r.stderr}") + return r + + +def group_exists(name: str) -> bool: + try: + grp.getgrnam(name) + return True + except KeyError: + return False + + +def user_exists(name: str) -> bool: + try: + pwd.getpwnam(name) + return True + except KeyError: + return False + + +def ensure_unix_group(name: str): + """ + Ensure a Unix group with this name exists. + """ + if not group_exists(name): + run(["groupadd", name]) + + +def ensure_unix_user(name: str, primary_group: str): + """ + Ensure a Unix user with this name exists and has the given primary group. + + The primary group is made if needed. + """ + ensure_unix_group(primary_group) + if not user_exists(name): + run(["useradd", "-m", "-g", primary_group, "-s", "/bin/bash", name]) + + +def remove_unix_user_and_group(name: str): + """ + Remove a Unix user and group that match this name, if they exist. + + The user is removed first, then the group. + """ + if user_exists(name): + # userdel returns non-zero if, for example, the user is logged in. + run(["userdel", name]) + if group_exists(name): + run(["groupdel", name]) diff --git a/release/manager/text.py b/release/manager/text.py new file mode 100644 index 0000000..012f087 --- /dev/null +++ b/release/manager/text.py @@ -0,0 +1,133 @@ +# text.py + +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 [_]* + {program_name} list + {program_name} info | {program_name} information + + {program_name} lo up|down + + {program_name} WG global + {program_name} WG make + {program_name} WG server_provided_public_key + {program_name} WG info|information + {program_name} WG up + {program_name} WG down + + {program_name} attach WG + {program_name} detach WG + + {program_name} network up|down + + {program_name} option set + {program_name} option get + {program_name} option list + + {program_name} exec -- ... +""" + + def help(self, verbose =False): + program_name = self.program_name + return f"""Subu manager (v{current_version()}) + +1) Init + {program_name} init + Makes ./subu.db. Refuses to run if db exists. + +2) Subu + {program_name} make [_]* + {program_name} list + {program_name} info + +3) Loopback + {program_name} lo up|down + +4) WireGuard objects (independent of subu) + {program_name} WG global # for example, 192.168.112.0/24 + {program_name} WG make # allocates next /32 + {program_name} WG server_provided_public_key + {program_name} WG info + {program_name} WG up / {program_name} WG down # administrative toggle after attached + +5) Attach or detach and eBPF steering + {program_name} attach WG + - Makes WireGuard device as subu_ inside ns-subu_, 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 + - Deletes device, removes cgroup and eBPF program + +6) Network aggregate + {program_name} network up|down + - Ensures loopback is up on 'up', toggles attached WireGuard interfaces + +7) Options + {program_name} option set|get|list ... + +8) Exec + {program_name} exec -- ... +""" + + 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) diff --git a/release/manager/uncatelogued/parser.py b/release/manager/uncatelogued/parser.py new file mode 100644 index 0000000..cf2dd84 --- /dev/null +++ b/release/manager/uncatelogued/parser.py @@ -0,0 +1,32 @@ +verbs = [ + "usage", + "help", + "example", + "version", + "init", + "make", + "make", + "info", + "information", + "WG", + "attach", + "detach", + "network", + "lo", + "option", + "exec", +] + +p_make = subparsers.add_parser( + "make", + help="Create a Subu with hierarchical name + Unix user/groups + netns", +) +p_make.add_argument( + "path", + nargs="+", + help="Full Subu path, e.g. 'Thomas US' or 'Thomas new-subu Rabbit'", +) + +elif args.verb == "make": + subu_id = core.make_subu(args.path) + print(subu_id) diff --git a/release/shell/.githolder b/release/shell/.githolder deleted file mode 100644 index e69de29..0000000 diff --git a/tester/manager/test.sh b/tester/manager/test.sh deleted file mode 100644 index 706250b..0000000 --- a/tester/manager/test.sh +++ /dev/null @@ -1,13 +0,0 @@ -#!/bin/env bash - -set -x -./CLI # -> USAGE (exit 0) -./CLI usage # -> USAGE -./CLI -h # -> HELP -./CLI --help # -> HELP -./CLI help # -> HELP -./CLI help WG # -> WG topic help (or full HELP if topic unknown) -./CLI example # -> EXAMPLE -./CLI version # -> 0.1.4 -./CLI -V # -> 0.1.4 - diff --git a/tester/manager/test_0.sh b/tester/manager/test_0.sh index ac354d3..348563a 100755 --- a/tester/manager/test_0.sh +++ b/tester/manager/test_0.sh @@ -1,11 +1,6 @@ +#!/usr/bin/env bash + +{ . test_0_driver.sh 2> >(tee /dev/stderr >&3); } 3>test_0_out.sh 1>&3 set -x -./subu.py # -> USAGE (exit 0) -./subu.py usage # -> USAGE -./subu.py -h # -> HELP -./subu.py --help # -> HELP -./subu.py help # -> HELP -./subu.py help WG # -> WG topic help (or full HELP if topic unknown) -./subu.py example # -> EXAMPLE -./subu.py version # -> 0.1.4 -./subu.py -V # -> 0.1.4 +diff -qr test_0_out.sh test_0_expected.sh set +x diff --git a/tester/manager/test_0_driver.sh b/tester/manager/test_0_driver.sh new file mode 100644 index 0000000..6e5f5b7 --- /dev/null +++ b/tester/manager/test_0_driver.sh @@ -0,0 +1,11 @@ +set -x +CLI.py # -> USAGE (exit 0) +CLI.py usage # -> USAGE +CLI.py -h # -> HELP +CLI.py --help # -> HELP +CLI.py help # -> HELP +CLI.py help WG # -> WG topic help (or full HELP if topic unknown) +CLI.py example # -> EXAMPLE +CLI.py version # -> 0.1.4 +CLI.py -V # -> 0.1.4 +set +x diff --git a/tester/manager/test_0_expected.sh b/tester/manager/test_0_expected.sh index 8e31ed5..a403bb1 100644 --- a/tester/manager/test_0_expected.sh +++ b/tester/manager/test_0_expected.sh @@ -1,353 +1,257 @@ -++ ./subu.py -usage: subu [-V] [] - -Quick verbs: - usage Show this usage summary - help [topic] Detailed help; same as -h / --help - example End-to-end example session - version Print version - -Main verbs: - init Initialize a new subu database (refuses if it exists) - create Create a minimal subu record (defaults only) - info | information Show details for a subu - WG WireGuard object operations - attach Attach a WG object to a subu (netns + cgroup/eBPF) - detach Detach WG from a subu - network Bring all attached ifaces up/down inside the subu netns - lo Bring loopback up/down inside the subu netns - option Persisted options (list/set/get for future policy) - exec Run a command inside the subu netns - -Tip: `subu help` (or `subu --help`) shows detailed help; `subu help WG` shows topic help. -++ ./subu.py usage -usage: subu [-V] [] - -Quick verbs: - usage Show this usage summary - help [topic] Detailed help; same as -h / --help - example End-to-end example session - version Print version - -Main verbs: - init Initialize a new subu database (refuses if it exists) - create Create a minimal subu record (defaults only) - info | information Show details for a subu - WG WireGuard object operations - attach Attach a WG object to a subu (netns + cgroup/eBPF) - detach Detach WG from a subu - network Bring all attached ifaces up/down inside the subu netns - lo Bring loopback up/down inside the subu netns - option Persisted options (list/set/get for future policy) - exec Run a command inside the subu netns - -Tip: `subu help` (or `subu --help`) shows detailed help; `subu help WG` shows topic help. -++ ./subu.py -h -subu — manage subu containers, namespaces, and WG attachments - -2.1 Core - - subu init - Create ./subu.db (tables: subu, wg, links, options, state). - Requires a 6-char token (e.g., dzkq7b). Refuses if DB already exists. - - subu create - Make a default subu with netns ns- containing lo only (down). - Returns subu_N. - - subu list - Columns: Subu_ID, Owner, Name, NetNS, WG_Attached?, Up/Down, Steer? - - subu info | subu information - Full record + attached WG(s) + options + iface states. - -2.2 Loopback - - subu lo up | subu lo down - Toggle loopback inside the subu’s netns. - -2.3 WireGuard objects (independent) - - subu WG global - e.g., 192.168.112.0/24; allocator hands out /32 peers sequentially. - Shows current base and next free on success. - - subu WG create - Creates WG object; allocates next /32 local IP; AllowedIPs=0.0.0.0/0. - Returns WG_M. - - subu WG server_provided_public_key - Stores server’s pubkey. - - subu WG info | subu WG information - Endpoint, allocated IP, pubkey set?, link state (admin/oper). - -2.4 Link WG ↔ subu, bring up/down - - subu attach WG - Creates/configures WG device inside ns-: - - device name: subu_ (M from WG_ID) - - set local /32, MTU 1420, accept_local=1 - - (no default route is added — steering uses eBPF) - - v1: enforce one WG per Subu; error if another attached - - subu detach WG - Remove WG device/config from the subu’s netns; keep WG object. - - subu WG up | subu WG down - Toggle interface admin state in the subu’s netns (must be attached). - - subu network up | subu network down - Only toggles admin state for all attached ifaces. On “up”, loopback - is brought up first automatically. No route manipulation. - -2.5 Execution & (future) steering - - subu exec -- … - Run a process inside the subu’s netns. - - subu steer enable | subu steer disable - (Future) Attach/detach eBPF cgroup programs to force SO_BINDTOIFINDEX=subu_ - for TCP/UDP. Default: disabled. - -2.6 Options (persist only, for future policy) - - subu option list - subu option get [name] - subu option set - -2.7 Meta - - subu usage - Short usage summary (also printed when no args are given). - - subu help [topic] - This help (or per-topic help such as `subu help WG`). - - subu example - A concrete end-to-end scenario. - - subu version - Print version (same as -V / --version). -++ ./subu.py --help -subu — manage subu containers, namespaces, and WG attachments - -2.1 Core - - subu init - Create ./subu.db (tables: subu, wg, links, options, state). - Requires a 6-char token (e.g., dzkq7b). Refuses if DB already exists. - - subu create - Make a default subu with netns ns- containing lo only (down). - Returns subu_N. - - subu list - Columns: Subu_ID, Owner, Name, NetNS, WG_Attached?, Up/Down, Steer? - - subu info | subu information - Full record + attached WG(s) + options + iface states. - -2.2 Loopback - - subu lo up | subu lo down - Toggle loopback inside the subu’s netns. - -2.3 WireGuard objects (independent) - - subu WG global - e.g., 192.168.112.0/24; allocator hands out /32 peers sequentially. - Shows current base and next free on success. - - subu WG create - Creates WG object; allocates next /32 local IP; AllowedIPs=0.0.0.0/0. - Returns WG_M. - - subu WG server_provided_public_key - Stores server’s pubkey. - - subu WG info | subu WG information - Endpoint, allocated IP, pubkey set?, link state (admin/oper). - -2.4 Link WG ↔ subu, bring up/down - - subu attach WG - Creates/configures WG device inside ns-: - - device name: subu_ (M from WG_ID) - - set local /32, MTU 1420, accept_local=1 - - (no default route is added — steering uses eBPF) - - v1: enforce one WG per Subu; error if another attached - - subu detach WG - Remove WG device/config from the subu’s netns; keep WG object. - - subu WG up | subu WG down - Toggle interface admin state in the subu’s netns (must be attached). - - subu network up | subu network down - Only toggles admin state for all attached ifaces. On “up”, loopback - is brought up first automatically. No route manipulation. - -2.5 Execution & (future) steering - - subu exec -- … - Run a process inside the subu’s netns. - - subu steer enable | subu steer disable - (Future) Attach/detach eBPF cgroup programs to force SO_BINDTOIFINDEX=subu_ - for TCP/UDP. Default: disabled. - -2.6 Options (persist only, for future policy) - - subu option list - subu option get [name] - subu option set - -2.7 Meta - - subu usage - Short usage summary (also printed when no args are given). - - subu help [topic] - This help (or per-topic help such as `subu help WG`). - - subu example - A concrete end-to-end scenario. - - subu version - Print version (same as -V / --version). -++ ./subu.py help -subu — manage subu containers, namespaces, and WG attachments - -2.1 Core - - subu init - Create ./subu.db (tables: subu, wg, links, options, state). - Requires a 6-char token (e.g., dzkq7b). Refuses if DB already exists. - - subu create - Make a default subu with netns ns- containing lo only (down). - Returns subu_N. - - subu list - Columns: Subu_ID, Owner, Name, NetNS, WG_Attached?, Up/Down, Steer? - - subu info | subu information - Full record + attached WG(s) + options + iface states. - -2.2 Loopback - - subu lo up | subu lo down - Toggle loopback inside the subu’s netns. - -2.3 WireGuard objects (independent) - - subu WG global - e.g., 192.168.112.0/24; allocator hands out /32 peers sequentially. - Shows current base and next free on success. - - subu WG create - Creates WG object; allocates next /32 local IP; AllowedIPs=0.0.0.0/0. - Returns WG_M. - - subu WG server_provided_public_key - Stores server’s pubkey. - - subu WG info | subu WG information - Endpoint, allocated IP, pubkey set?, link state (admin/oper). - -2.4 Link WG ↔ subu, bring up/down - - subu attach WG - Creates/configures WG device inside ns-: - - device name: subu_ (M from WG_ID) - - set local /32, MTU 1420, accept_local=1 - - (no default route is added — steering uses eBPF) - - v1: enforce one WG per Subu; error if another attached - - subu detach WG - Remove WG device/config from the subu’s netns; keep WG object. - - subu WG up | subu WG down - Toggle interface admin state in the subu’s netns (must be attached). - - subu network up | subu network down - Only toggles admin state for all attached ifaces. On “up”, loopback - is brought up first automatically. No route manipulation. - -2.5 Execution & (future) steering - - subu exec -- … - Run a process inside the subu’s netns. - - subu steer enable | subu steer disable - (Future) Attach/detach eBPF cgroup programs to force SO_BINDTOIFINDEX=subu_ - for TCP/UDP. Default: disabled. - -2.6 Options (persist only, for future policy) - - subu option list - subu option get [name] - subu option set - -2.7 Meta - - subu usage - Short usage summary (also printed when no args are given). - - subu help [topic] - This help (or per-topic help such as `subu help WG`). - - subu example - A concrete end-to-end scenario. - - subu version - Print version (same as -V / --version). -++ ./subu.py help WG -usage: subu WG [-h] - -options: - -h, --help show this help message and exit -++ ./subu.py example -# 0) Safe init (refuses if ./subu.db exists) -subu init dzkq7b -# -> created ./subu.db - -# 1) Create Subu -subu create Thomas US -# -> Subu_ID: subu_7 -# -> netns: ns-subu_7 with lo (down) - -# 2) Define WG pool (once per host) -subu WG global 192.168.112.0/24 -# -> base set; next free: 192.168.112.2/32 - -# 3) Create WG object with endpoint -subu WG create ReasoningTechnology.com:51820 -# -> WG_ID: WG_0 -# -> local IP: 192.168.112.2/32 -# -> AllowedIPs: 0.0.0.0/0 - -# 4) Add server public key -subu WG server_provided_public_key WG_0 ABCDEFG...xyz= -# -> saved - -# 5) Attach WG to Subu (device created/configured in ns) -subu attach WG subu_7 WG_0 -# -> device ns-subu_7/subu_0 configured (no default route) - -# 6) Bring network up (lo first, then attached ifaces) -subu network up subu_7 -# -> lo up; subu_0 admin up - -# 7) Start the WG engine inside the netns -subu WG up WG_0 -# -> up, handshakes should start - -# 8) Test from inside the subu -subu exec subu_7 -- curl -4v https://ifconfig.me -++ ./subu.py version -0.1.3 -++ ./subu.py -V -0.1.3 -++ set +x +++ CLI.py +Subu manager (v0.3.3) + +1) Init + CLI.py init + Makes ./subu.db. Refuses to run if db exists. + +2) Subu + CLI.py make [_]* + CLI.py list + CLI.py info + +3) Loopback + CLI.py lo up|down + +4) WireGuard objects (independent of subu) + CLI.py WG global # for example, 192.168.112.0/24 + CLI.py WG make # allocates next /32 + CLI.py WG server_provided_public_key + CLI.py WG info + CLI.py WG up / CLI.py WG down # administrative toggle after attached + +5) Attach or detach and eBPF steering + CLI.py attach WG + - Makes WireGuard device as subu_ inside ns-subu_, assigns /32, MTU 1420 + - Installs per-subu cgroup and loads eBPF scaffold (user identifier check, metadata map) + - Keeps device administrative-down until `CLI.py network up` + CLI.py detach WG + - Deletes device, removes cgroup and eBPF program + +6) Network aggregate + CLI.py network up|down + - Ensures loopback is up on 'up', toggles attached WireGuard interfaces + +7) Options + CLI.py option set|get|list ... + +8) Exec + CLI.py exec -- ... +++ CLI.py usage +CLI.py — Subu manager (v0.3.3) + +Usage: + CLI.py # usage + CLI.py help # detailed help + CLI.py example # example workflow + CLI.py version # print version + + CLI.py init + CLI.py make [_]* + CLI.py list + CLI.py info | CLI.py information + + CLI.py lo up|down + + CLI.py WG global + CLI.py WG make + CLI.py WG server_provided_public_key + CLI.py WG info|information + CLI.py WG up + CLI.py WG down + + CLI.py attach WG + CLI.py detach WG + + CLI.py network up|down + + CLI.py option set + CLI.py option get + CLI.py option list + + CLI.py exec -- ... +++ CLI.py -h +Subu manager (v0.3.3) + +1) Init + CLI.py init + Makes ./subu.db. Refuses to run if db exists. + +2) Subu + CLI.py make [_]* + CLI.py list + CLI.py info + +3) Loopback + CLI.py lo up|down + +4) WireGuard objects (independent of subu) + CLI.py WG global # for example, 192.168.112.0/24 + CLI.py WG make # allocates next /32 + CLI.py WG server_provided_public_key + CLI.py WG info + CLI.py WG up / CLI.py WG down # administrative toggle after attached + +5) Attach or detach and eBPF steering + CLI.py attach WG + - Makes WireGuard device as subu_ inside ns-subu_, assigns /32, MTU 1420 + - Installs per-subu cgroup and loads eBPF scaffold (user identifier check, metadata map) + - Keeps device administrative-down until `CLI.py network up` + CLI.py detach WG + - Deletes device, removes cgroup and eBPF program + +6) Network aggregate + CLI.py network up|down + - Ensures loopback is up on 'up', toggles attached WireGuard interfaces + +7) Options + CLI.py option set|get|list ... + +8) Exec + CLI.py exec -- ... +++ CLI.py --help +Subu manager (v0.3.3) + +1) Init + CLI.py init + Makes ./subu.db. Refuses to run if db exists. + +2) Subu + CLI.py make [_]* + CLI.py list + CLI.py info + +3) Loopback + CLI.py lo up|down + +4) WireGuard objects (independent of subu) + CLI.py WG global # for example, 192.168.112.0/24 + CLI.py WG make # allocates next /32 + CLI.py WG server_provided_public_key + CLI.py WG info + CLI.py WG up / CLI.py WG down # administrative toggle after attached + +5) Attach or detach and eBPF steering + CLI.py attach WG + - Makes WireGuard device as subu_ inside ns-subu_, assigns /32, MTU 1420 + - Installs per-subu cgroup and loads eBPF scaffold (user identifier check, metadata map) + - Keeps device administrative-down until `CLI.py network up` + CLI.py detach WG + - Deletes device, removes cgroup and eBPF program + +6) Network aggregate + CLI.py network up|down + - Ensures loopback is up on 'up', toggles attached WireGuard interfaces + +7) Options + CLI.py option set|get|list ... + +8) Exec + CLI.py exec -- ... +++ CLI.py help +Subu manager (v0.3.3) + +1) Init + CLI.py init + Makes ./subu.db. Refuses to run if db exists. + +2) Subu + CLI.py make [_]* + CLI.py list + CLI.py info + +3) Loopback + CLI.py lo up|down + +4) WireGuard objects (independent of subu) + CLI.py WG global # for example, 192.168.112.0/24 + CLI.py WG make # allocates next /32 + CLI.py WG server_provided_public_key + CLI.py WG info + CLI.py WG up / CLI.py WG down # administrative toggle after attached + +5) Attach or detach and eBPF steering + CLI.py attach WG + - Makes WireGuard device as subu_ inside ns-subu_, assigns /32, MTU 1420 + - Installs per-subu cgroup and loads eBPF scaffold (user identifier check, metadata map) + - Keeps device administrative-down until `CLI.py network up` + CLI.py detach WG + - Deletes device, removes cgroup and eBPF program + +6) Network aggregate + CLI.py network up|down + - Ensures loopback is up on 'up', toggles attached WireGuard interfaces + +7) Options + CLI.py option set|get|list ... + +8) Exec + CLI.py exec -- ... +++ CLI.py help WG +Subu manager (v0.3.3) + +1) Init + CLI.py init + Makes ./subu.db. Refuses to run if db exists. + +2) Subu + CLI.py make [_]* + CLI.py list + CLI.py info + +3) Loopback + CLI.py lo up|down + +4) WireGuard objects (independent of subu) + CLI.py WG global # for example, 192.168.112.0/24 + CLI.py WG make # allocates next /32 + CLI.py WG server_provided_public_key + CLI.py WG info + CLI.py WG up / CLI.py WG down # administrative toggle after attached + +5) Attach or detach and eBPF steering + CLI.py attach WG + - Makes WireGuard device as subu_ inside ns-subu_, assigns /32, MTU 1420 + - Installs per-subu cgroup and loads eBPF scaffold (user identifier check, metadata map) + - Keeps device administrative-down until `CLI.py network up` + CLI.py detach WG + - Deletes device, removes cgroup and eBPF program + +6) Network aggregate + CLI.py network up|down + - Ensures loopback is up on 'up', toggles attached WireGuard interfaces + +7) Options + CLI.py option set|get|list ... + +8) Exec + CLI.py exec -- ... +++ CLI.py example +# 0) Initialise the subu database (once per directory) +CLI.py init dzkq7b + +# 1) Make Subu +CLI.py make Thomas US +# -> subu_1 + +# 2) WireGuard pool once +CLI.py WG global 192.168.112.0/24 + +# 3) Make WireGuard object with endpoint +CLI.py WG make ReasoningTechnology.com:51820 +# -> WG_1 + +# 4) Server public key (placeholder) +CLI.py WG server_provided_public_key WG_1 ABCDEFG...xyz= + +# 5) Attach device and install cgroup and eBPF steering +CLI.py attach WG subu_1 WG_1 + +# 6) Bring network up (loopback and WireGuard) +CLI.py network up subu_1 + +# 7) Test inside namespace +CLI.py exec subu_1 -- curl -4v https://ifconfig.me +++ CLI.py version +0.3.3++ CLI.py -V +0.3.3++ set +x diff --git a/tester/manager/test_0_out.sh b/tester/manager/test_0_out.sh new file mode 100644 index 0000000..a403bb1 --- /dev/null +++ b/tester/manager/test_0_out.sh @@ -0,0 +1,257 @@ +++ CLI.py +Subu manager (v0.3.3) + +1) Init + CLI.py init + Makes ./subu.db. Refuses to run if db exists. + +2) Subu + CLI.py make [_]* + CLI.py list + CLI.py info + +3) Loopback + CLI.py lo up|down + +4) WireGuard objects (independent of subu) + CLI.py WG global # for example, 192.168.112.0/24 + CLI.py WG make # allocates next /32 + CLI.py WG server_provided_public_key + CLI.py WG info + CLI.py WG up / CLI.py WG down # administrative toggle after attached + +5) Attach or detach and eBPF steering + CLI.py attach WG + - Makes WireGuard device as subu_ inside ns-subu_, assigns /32, MTU 1420 + - Installs per-subu cgroup and loads eBPF scaffold (user identifier check, metadata map) + - Keeps device administrative-down until `CLI.py network up` + CLI.py detach WG + - Deletes device, removes cgroup and eBPF program + +6) Network aggregate + CLI.py network up|down + - Ensures loopback is up on 'up', toggles attached WireGuard interfaces + +7) Options + CLI.py option set|get|list ... + +8) Exec + CLI.py exec -- ... +++ CLI.py usage +CLI.py — Subu manager (v0.3.3) + +Usage: + CLI.py # usage + CLI.py help # detailed help + CLI.py example # example workflow + CLI.py version # print version + + CLI.py init + CLI.py make [_]* + CLI.py list + CLI.py info | CLI.py information + + CLI.py lo up|down + + CLI.py WG global + CLI.py WG make + CLI.py WG server_provided_public_key + CLI.py WG info|information + CLI.py WG up + CLI.py WG down + + CLI.py attach WG + CLI.py detach WG + + CLI.py network up|down + + CLI.py option set + CLI.py option get + CLI.py option list + + CLI.py exec -- ... +++ CLI.py -h +Subu manager (v0.3.3) + +1) Init + CLI.py init + Makes ./subu.db. Refuses to run if db exists. + +2) Subu + CLI.py make [_]* + CLI.py list + CLI.py info + +3) Loopback + CLI.py lo up|down + +4) WireGuard objects (independent of subu) + CLI.py WG global # for example, 192.168.112.0/24 + CLI.py WG make # allocates next /32 + CLI.py WG server_provided_public_key + CLI.py WG info + CLI.py WG up / CLI.py WG down # administrative toggle after attached + +5) Attach or detach and eBPF steering + CLI.py attach WG + - Makes WireGuard device as subu_ inside ns-subu_, assigns /32, MTU 1420 + - Installs per-subu cgroup and loads eBPF scaffold (user identifier check, metadata map) + - Keeps device administrative-down until `CLI.py network up` + CLI.py detach WG + - Deletes device, removes cgroup and eBPF program + +6) Network aggregate + CLI.py network up|down + - Ensures loopback is up on 'up', toggles attached WireGuard interfaces + +7) Options + CLI.py option set|get|list ... + +8) Exec + CLI.py exec -- ... +++ CLI.py --help +Subu manager (v0.3.3) + +1) Init + CLI.py init + Makes ./subu.db. Refuses to run if db exists. + +2) Subu + CLI.py make [_]* + CLI.py list + CLI.py info + +3) Loopback + CLI.py lo up|down + +4) WireGuard objects (independent of subu) + CLI.py WG global # for example, 192.168.112.0/24 + CLI.py WG make # allocates next /32 + CLI.py WG server_provided_public_key + CLI.py WG info + CLI.py WG up / CLI.py WG down # administrative toggle after attached + +5) Attach or detach and eBPF steering + CLI.py attach WG + - Makes WireGuard device as subu_ inside ns-subu_, assigns /32, MTU 1420 + - Installs per-subu cgroup and loads eBPF scaffold (user identifier check, metadata map) + - Keeps device administrative-down until `CLI.py network up` + CLI.py detach WG + - Deletes device, removes cgroup and eBPF program + +6) Network aggregate + CLI.py network up|down + - Ensures loopback is up on 'up', toggles attached WireGuard interfaces + +7) Options + CLI.py option set|get|list ... + +8) Exec + CLI.py exec -- ... +++ CLI.py help +Subu manager (v0.3.3) + +1) Init + CLI.py init + Makes ./subu.db. Refuses to run if db exists. + +2) Subu + CLI.py make [_]* + CLI.py list + CLI.py info + +3) Loopback + CLI.py lo up|down + +4) WireGuard objects (independent of subu) + CLI.py WG global # for example, 192.168.112.0/24 + CLI.py WG make # allocates next /32 + CLI.py WG server_provided_public_key + CLI.py WG info + CLI.py WG up / CLI.py WG down # administrative toggle after attached + +5) Attach or detach and eBPF steering + CLI.py attach WG + - Makes WireGuard device as subu_ inside ns-subu_, assigns /32, MTU 1420 + - Installs per-subu cgroup and loads eBPF scaffold (user identifier check, metadata map) + - Keeps device administrative-down until `CLI.py network up` + CLI.py detach WG + - Deletes device, removes cgroup and eBPF program + +6) Network aggregate + CLI.py network up|down + - Ensures loopback is up on 'up', toggles attached WireGuard interfaces + +7) Options + CLI.py option set|get|list ... + +8) Exec + CLI.py exec -- ... +++ CLI.py help WG +Subu manager (v0.3.3) + +1) Init + CLI.py init + Makes ./subu.db. Refuses to run if db exists. + +2) Subu + CLI.py make [_]* + CLI.py list + CLI.py info + +3) Loopback + CLI.py lo up|down + +4) WireGuard objects (independent of subu) + CLI.py WG global # for example, 192.168.112.0/24 + CLI.py WG make # allocates next /32 + CLI.py WG server_provided_public_key + CLI.py WG info + CLI.py WG up / CLI.py WG down # administrative toggle after attached + +5) Attach or detach and eBPF steering + CLI.py attach WG + - Makes WireGuard device as subu_ inside ns-subu_, assigns /32, MTU 1420 + - Installs per-subu cgroup and loads eBPF scaffold (user identifier check, metadata map) + - Keeps device administrative-down until `CLI.py network up` + CLI.py detach WG + - Deletes device, removes cgroup and eBPF program + +6) Network aggregate + CLI.py network up|down + - Ensures loopback is up on 'up', toggles attached WireGuard interfaces + +7) Options + CLI.py option set|get|list ... + +8) Exec + CLI.py exec -- ... +++ CLI.py example +# 0) Initialise the subu database (once per directory) +CLI.py init dzkq7b + +# 1) Make Subu +CLI.py make Thomas US +# -> subu_1 + +# 2) WireGuard pool once +CLI.py WG global 192.168.112.0/24 + +# 3) Make WireGuard object with endpoint +CLI.py WG make ReasoningTechnology.com:51820 +# -> WG_1 + +# 4) Server public key (placeholder) +CLI.py WG server_provided_public_key WG_1 ABCDEFG...xyz= + +# 5) Attach device and install cgroup and eBPF steering +CLI.py attach WG subu_1 WG_1 + +# 6) Bring network up (loopback and WireGuard) +CLI.py network up subu_1 + +# 7) Test inside namespace +CLI.py exec subu_1 -- curl -4v https://ifconfig.me +++ CLI.py version +0.3.3++ CLI.py -V +0.3.3++ set +x diff --git a/tester/manager/test_1_driver.sh b/tester/manager/test_1_driver.sh new file mode 100644 index 0000000..313f9bd --- /dev/null +++ b/tester/manager/test_1_driver.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env bash +# test_1_driver.sh — smoke test: Unix dry mode + make + +set -x + +# 1) Put Unix handling into dry-run mode. +# In this mode we expect *no real* user/group changes. +CLI.py option Unix dry + +# 2) Make a first-level subu +CLI.py make Thomas S0 + +# 3) Make a second-level subu under the first +CLI.py make Thomas S0 S1 + +# 4) Return Unix handling to run mode (for future tests) +CLI.py option Unix run + +set +x diff --git a/tester/tool/env b/tester/tool/env index e73741c..65bc61b 100644 --- a/tester/tool/env +++ b/tester/tool/env @@ -1,47 +1,7 @@ #!/usr/bin/env bash script_afp=$(realpath "${BASH_SOURCE[0]}") -# input guards - - env_must_be="tool_shared/bespoke/env" - error=false - if [ "$ENV" != "$env_must_be" ]; then - echo "$(script_fp):: error: must be run in the $env_must_be environment" - error=true - fi - if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then - echo "$script_afp:: This script must be sourced, not executed." - error=true + manager="$REPO_HOME/release/manager" + if [[ ":$PATH:" != *":$manager:"* ]]; then + export PATH="$manager:$PATH" fi - if $error; then exit 1; fi - -# so we can do testing - -export PATH=\ -"$REPO_HOME"/tester/tool/\ -:"$REPO_HOME"/tool_shared/bespoke/\ -:"$JAVA_HOME"/bin\ -:"$PATH" - -export CLASSPATH=\ -"$JAVA_HOME"/lib\ -:"$REPO_HOME"/release/"$PROJECT".jar\ -:"$REPO_HOME"/tester/jvm/Test_"$PROJECT".jar\ -:"$CLASSPATH" - -export SOURCEPATH=\ -"$REPO_HOME"/tester/javac/\ -:"$REPO_HOME"/developer/scratchpad/\ - - -# misc - - # make .githolder and .gitignore visible - alias ls="ls -a" - -# some feedback to show all went well - - export PROMPT_DECOR="$PROJECT"_tester - export ENV=$(script_fp) - echo ENV "$ENV" - cd "$REPO_HOME"/tester/ diff --git a/tool_shared/bespoke/.githolder b/tool_shared/bespoke/.githolder new file mode 100644 index 0000000..e69de29 diff --git a/tool_shared/bespoke/bashrc b/tool_shared/bespoke/bashrc deleted file mode 100644 index 0914cfc..0000000 --- a/tool_shared/bespoke/bashrc +++ /dev/null @@ -1,51 +0,0 @@ -# ssh login will fail if .bashrc writes to stdout, so we write to "bash_error.txt" -# set -x -# in F37 something seems to be caching PATH, which can be annoying - -# If not running interactively, don't do anything - case $- in - *i*) ;; - *) return;; - esac - -# This should also be the default from login.defs, because gnome ignores -# .login, .profile, etc. and uses systemd to launch applications from the desktop, - umask 0077 - -# - note the variable $PROMPT_DECOR, that is how the project name ends up in the prompt. -# - without -i bash will clear PS1, just because, so we set PPS1, ,PPS2 to not lose the profit. -# - use $(pwd) instead of \w or it will prefix '~' which confuses dirtrack when the -# user is changed using su - export PPS1='\n$($iseq/Z)[$PROMPT_DECOR]\n\u@\h§$(pwd)§\n> ' - export PPS2='>> ' - export PS1="$PPS1" - export PS2="$PPS2" - -# sort the output of printenv, show newlines as environment variable values as \n - alias printenv='printenv | awk '\''{gsub(/\n/, "\\n")}1'\'' | sort' - -# iso time in ls -l, show hidden files, human readable sizes - alias ls='ls -a -h --time-style=long-iso' - -# iso time for all Linux programs, which they will all ignore, but at least we -# tried, perhaps someday ... - export TZ=UTC - export TIME_STYLE=long-iso - export LC_ALL=en_DK.UTF-8 - -# -l don't truncate long lins -# -p show pids - alias pstree='pstree -lp' - -# - make bash gp to sleep, revealing the calling shell -# - useful for job control of multiple bash shells from a controlling shell - alias zzz="kill -STOP \$\$" - -# The one true operating system. -# Proof that an OS can be as small as an editor. - export EDITOR=emacs - -# check the window size after each command and, if necessary, update the values -# of LINES and COLUMNS. - shopt -s checkwinsize - diff --git a/tool_shared/bespoke/cat_w_fn b/tool_shared/bespoke/cat_w_fn deleted file mode 100755 index 3308525..0000000 --- a/tool_shared/bespoke/cat_w_fn +++ /dev/null @@ -1,30 +0,0 @@ -#!/usr/bin/env bash -script_afp=$(realpath "${BASH_SOURCE[0]}") - -# Check if at least one file is provided -if [ $# -eq 0 ]; then - echo "Usage: $(script_fp) [filename2] ..." - exit 1 -fi - -# Loop through all the provided files -for file in "$@"; do - # Check if the file exists - if [ ! -f "$file" ]; then - echo "Error: File '$file' not found!" - continue - fi - - # Print 80 dashes - printf '%.0s-' {1..80} - echo - - # Print the filename and a colon - echo "$file:" - - # Print the contents of the file - cat "$file" - - # Print a newline for spacing between files - echo -done diff --git a/tool_shared/bespoke/deprecate b/tool_shared/bespoke/deprecate deleted file mode 100755 index 4713db5..0000000 --- a/tool_shared/bespoke/deprecate +++ /dev/null @@ -1,20 +0,0 @@ -#!/usr/bin/env bash -script_afp=$(realpath "${BASH_SOURCE[0]}") - -# cp subtree at under file path , and make all the copied -# files read-only. The intended use case is for moving files to a `deprecated` -# directory. This helps prevent subsequent accidental editing. - -if [ "$#" -lt 2 ]; then - echo "Usage: $script_afp " - exit 1 -fi -SRC="$1" -DEST="$2" - -mkdir -p "$DEST" -mv "$SRC" "$DEST" - -# make stuff readonly -cd "$DEST" || exit -chmod -R u-w,go-rwx "$DEST" diff --git a/tool_shared/bespoke/env b/tool_shared/bespoke/env index 4fa561b..0d47fca 100644 --- a/tool_shared/bespoke/env +++ b/tool_shared/bespoke/env @@ -5,6 +5,12 @@ if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then exit 1 fi +# without this bash takes non-matching globs literally +shopt -s nullglob + +# does not presume sharing or world permissions +umask 0077 + # -------------------------------------------------------------------------------- # project definition @@ -26,10 +32,33 @@ fi # set the prompt decoration to the name of the project PROMPT_DECOR=$PROJECT + export REPO_HOME PROJECT PROMPT_DECOR + # -------------------------------------------------------------------------------- -# The project administrator sets up the following tools for all roles to use: +# Project wide Tool setup # - export JAVA_HOME="$REPO_HOME/tool_shared/third_party/jdk-11" + +export VIRTUAL_ENV="$REPO_HOME/tool_shared/third_party/Python" +export PYTHON_HOME="$VIRTUAL_ENV" +unset PYTHONHOME + + +# -------------------------------------------------------------------------------- +# PATH +# precedence: last defined, first discovered + + PATH="$REPO_HOME/tool_shared/third_party/RT-project-share/release/bash:$PATH" + PATH="$REPO_HOME/tool_shared/third_party/RT-project-share/release/amd64:$PATH" + PATH="$REPO_HOME/tool_shared/third_party:$PATH" + PATH="$REPO_HOME/tool_shared/customized:$PATH" + PATH="$REPO_HOME"/tool_shared/bespoke:"$PATH" + + # Remove duplicates + clean_path() { + PATH=$(echo ":$PATH" | awk -v RS=: -v ORS=: '!seen[$0]++' | sed 's/^://; s/:$//') + } + clean_path + export PATH # -------------------------------------------------------------------------------- # the following functions are provided for other scripts to use. @@ -52,14 +81,50 @@ fi dirname "$(script_fp)" } -# -------------------------------------------------------------------------------- -# Exports -# Bash has no 'closure' hence when exporting a function, one must also export all the pieces. -# do not export script_afp - - export REPO_HOME PROJECT PROMPT_DECOR export -f script_adp script_fn script_dp script_fp - export ENV=$(script_fp) - echo ENV "$ENV" +#-------------------------------------------------------------------------------- +# used by release scripts +# + + install_file() { + if [ "$#" -lt 3 ]; then + echo "env::install_file usage: install_file ... " + return 1 + fi + + perms="${@: -1}" # Last argument is permissions + target_dp="${@: -2:1}" # Second-to-last argument is the target directory + sources=("${@:1:$#-2}") # All other arguments are source files + + if [ ! -d "$target_dp" ]; then + echo "env::install_file no install done: target directory '$target_dp' does not exist." + return 1 + fi + + for source_fp in "${sources[@]}"; do + if [ ! -f "$source_fp" ]; then + echo "env::install_file: source file '$source_fp' does not exist." + return 1 + fi + + target_file="$target_dp/$(basename "$source_fp")" + + if ! install -m "$perms" "$source_fp" "$target_file"; then + echo "env::install_file: Failed to install $(basename "$source_fp") to $target_dp" + return 1 + else + echo "env::install_file: installed $(basename "$source_fp") to $target_dp with permissions $perms" + fi + done + } + + export -f install_file + +# -------------------------------------------------------------------------------- +# closing +# + if [[ -z "$ENV" ]]; then + export ENV=$(script_fp) + fi diff --git a/tool_shared/bespoke/githolder b/tool_shared/bespoke/githolder deleted file mode 100755 index 49fb12b..0000000 --- a/tool_shared/bespoke/githolder +++ /dev/null @@ -1,63 +0,0 @@ -#!/bin/env /bin/bash - -# Description: Descends from $1, or pwd, looking for empty directories and adds a `.githolder` to them. -# does not descend into hidden directories. - -# examples: -# > git_holder -# > git_holder --dry-run - -set -e - -find_empty_dirs() { - local dir="$1" - local dry_run="$2" - - # Skip `.git` specifically - if [[ "$(basename "$dir")" == ".git" ]]; then - return - fi - - # Check if the directory is empty (including hidden files, excluding `.` and `..`) - if [[ -z $(find "$dir" -mindepth 1 -maxdepth 1 -print -quit) ]]; then - if [[ "$dry_run" == "true" ]]; then - echo "Dry-run: Would add .githolder in $dir" - else - echo "Adding .githolder to $dir" - touch "$dir/.githolder" - fi - else - # Recurse into subdirectories - for subdir in "$dir"/*/ "$dir"/.[!.]/; do - if [[ -d "$subdir" && "$subdir" != "$dir/.[!.]/" ]]; then - find_empty_dirs "$subdir" "$dry_run" - fi - done - fi -} - -# Default parameters -dry_run="false" -target_dir="." - -# Parse arguments -while [[ $# -gt 0 ]]; do - case "$1" in - --dry-run) - dry_run="true" - shift - ;; - *) - if [[ -d "$1" ]]; then - target_dir="$1" - shift - else - echo "Invalid argument: $1 is not a directory" - exit 1 - fi - ;; - esac -done - -# Run the function -find_empty_dirs "$target_dir" "$dry_run" diff --git a/tool_shared/bespoke/scratchpad b/tool_shared/bespoke/scratchpad new file mode 100755 index 0000000..f14f140 --- /dev/null +++ b/tool_shared/bespoke/scratchpad @@ -0,0 +1,225 @@ +#!/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, subprocess + +HELP = """usage: scratchpad {ls|clear|help|make|write|size|find|lock|unlock} [ARGS] + ls List scratchpad in an indented tree with perms and owner (quiet if missing). + clear Remove all contents of scratchpad/ except top-level .gitignore. + clear NAME Remove scratchpad/NAME only. + make [NAME] Ensure scratchpad/ exists with .gitignore; with NAME, mkdir scratchpad/NAME. + write SRC [DST] Copy file/dir SRC into scratchpad (to DST if given; parents created). + size Print 'empty' if only .gitignore; else total bytes and item count. + find [OPTS...] Run system 'find' rooted at scratchpad/ with OPTS (omit literal 'scratchpad'). + lock PATH... Attempt 'chattr +i' on given paths under scratchpad/ (no state kept). + unlock PATH... Attempt 'chattr -i' on given paths under scratchpad/. + +Examples: + scratchpad make + scratchpad write ~/Downloads/test.tar.gz + scratchpad find -type f -mtime +30 -print # files older than 30 days + scratchpad lock some/dir important.txt + scratchpad unlock some/dir important.txt +""" + +CWD = os.getcwd() +SP = os.path.join(CWD, "scratchpad") +GITIGNORE = os.path.join(SP, ".gitignore") + +def have_sp() -> bool: + return os.path.isdir(SP) + +def ensure_sp(): + os.makedirs(SP, exist_ok=True) + ensure_gitignore() + +def ensure_gitignore(): + os.makedirs(SP, exist_ok=True) + if not os.path.isfile(GITIGNORE): + with open(GITIGNORE, "w", encoding="utf-8") as f: + f.write("*\n!.gitignore\n") + +def filemode(mode: int) -> str: + try: + return stat.filemode(mode) + except Exception: + return oct(mode & 0o777) + +def owner_group(st) -> str: + try: + return f"{pwd.getpwuid(st.st_uid).pw_name}:{grp.getgrgid(st.st_gid).gr_name}" + except Exception: + return f"{st.st_uid}:{st.st_gid}" + +def rel_depth(base: str, root: str) -> int: + rel = os.path.relpath(base, root) + return 0 if rel == "." else rel.count(os.sep) + 1 + +def ls_tree(root: str) -> None: + if not have_sp(): + return + print("scratchpad/") + + def walk(path: str, indent: str, is_root: bool) -> None: + try: + it = list(os.scandir(path)) + except FileNotFoundError: + return + + dirs = [e for e in it if e.is_dir(follow_symlinks=False)] + files = [e for e in it if not e.is_dir(follow_symlinks=False)] + dirs.sort(key=lambda e: e.name) + files.sort(key=lambda e: e.name) + + if is_root: + # 1) root-level hidden files first + for f in (e for e in files if e.name.startswith(".")): + st = os.lstat(f.path) + print(f"{filemode(st.st_mode)} {owner_group(st)} {indent}{f.name}") + # 2) then directories (and recurse so children sit under the parent) + for d in dirs: + st = os.lstat(d.path) + print(f"{filemode(st.st_mode)} {owner_group(st)} {indent}{d.name}/") + walk(d.path, indent + ' ', False) + # 3) then non-hidden files + for f in (e for e in files if not e.name.startswith(".")): + st = os.lstat(f.path) + print(f"{filemode(st.st_mode)} {owner_group(st)} {indent}{f.name}") + else: + # subdirs: keep previous order (dirs first, then files; dotfiles naturally sort first) + for d in dirs: + st = os.lstat(d.path) + print(f"{filemode(st.st_mode)} {owner_group(st)} {indent}{d.name}/") + walk(d.path, indent + ' ', False) + for f in files: + st = os.lstat(f.path) + print(f"{filemode(st.st_mode)} {owner_group(st)} {indent}{f.name}") + + walk(root, " ", True) + + +def clear_all() -> None: + if not have_sp(): + return + for name in os.listdir(SP): + p = os.path.join(SP, name) + if name == ".gitignore" and os.path.isfile(p): + continue # preserve only top-level .gitignore + if os.path.isdir(p) and not os.path.islink(p): + shutil.rmtree(p, ignore_errors=True) + else: + try: os.unlink(p) + except FileNotFoundError: pass + +def clear_subdir(sub: str) -> None: + if not have_sp(): + return + target = os.path.normpath(os.path.join(SP, sub)) + try: + if os.path.commonpath([SP]) != os.path.commonpath([SP, target]): + return + except Exception: + return + if os.path.isdir(target) and not os.path.islink(target): + shutil.rmtree(target, ignore_errors=True) + +def cmd_make(args): + ensure_sp() + if args: + os.makedirs(os.path.join(SP, args[0]), exist_ok=True) + +def cmd_write(args): + if len(args) < 1: + print(HELP); return + if not have_sp(): + ensure_sp() + src = args[0] + dst = args[1] if len(args) >= 2 else (os.path.basename(src.rstrip(os.sep)) or "untitled") + dst_path = os.path.normpath(os.path.join(SP, dst)) + try: + if os.path.commonpath([SP]) != os.path.commonpath([SP, dst_path]): + print("refusing to write outside scratchpad", file=sys.stderr); return + except Exception: + print("invalid destination", file=sys.stderr); return + os.makedirs(os.path.dirname(dst_path), exist_ok=True) + if os.path.isdir(src): + if os.path.exists(dst_path): + shutil.rmtree(dst_path, ignore_errors=True) + shutil.copytree(src, dst_path, dirs_exist_ok=False) + else: + shutil.copy2(src, dst_path) + +def cmd_size(): + if not have_sp(): + return + names = os.listdir(SP) + if [n for n in names if n != ".gitignore"] == []: + print("empty"); return + total = 0; count = 0 + for base, dirs, files in os.walk(SP): + for fn in files: + if fn == ".gitignore": + continue + p = os.path.join(base, fn) + try: + total += os.path.getsize(p); count += 1 + except OSError: + pass + print(f"bytes={total} items={count}") + +def cmd_find(args): + if not have_sp(): + return + try: + subprocess.run(["find", SP] + args, check=False) + except FileNotFoundError: + print("find not available", file=sys.stderr) + +def cmd_chattr(flag: str, paths): + if not have_sp() or not paths: + return + try: + subprocess.run(["chattr", "-V"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=False) + except FileNotFoundError: + print("chattr not available; lock/unlock skipped", file=sys.stderr); return + for rel in paths: + target = os.path.normpath(os.path.join(SP, rel)) + try: + if os.path.commonpath([SP]) != os.path.commonpath([SP, target]): + continue + except Exception: + continue + try: + subprocess.run(["chattr", flag, target], check=False) + except Exception: + pass + +def CLI(): + if len(sys.argv) < 2: + print(HELP); return + cmd, *args = sys.argv[1:] + if cmd == "ls": + if have_sp(): ls_tree(SP) + else: return + elif cmd == "clear": + if len(args) >= 1: clear_subdir(args[0]) + else: clear_all() + elif cmd == "help": + print(HELP) + elif cmd == "make": + cmd_make(args) + elif cmd == "write": + cmd_write(args) + elif cmd == "size": + cmd_size() + elif cmd == "find": + cmd_find(args) + elif cmd == "lock": + cmd_chattr("+i", args) + elif cmd == "unlock": + cmd_chattr("-i", args) + else: + print(HELP) + +if __name__ == "__main__": + CLI() diff --git a/tool_shared/bespoke/test_env b/tool_shared/bespoke/test_env deleted file mode 100755 index 18d75f9..0000000 --- a/tool_shared/bespoke/test_env +++ /dev/null @@ -1,24 +0,0 @@ -#!/bin/env bash -script_afp=$(realpath "${BASH_SOURCE[0]}") - -# try both running and sourcing this test - -echo -echo "--------------------------------------------------------------------------------" -echo "from within test_shared/bespoke/test_env:" -echo -echo "REPO_HOME:" "$REPO_HOME" -echo "PROJECT:" "$PROJECT" -echo "script_afp:" "$script_afp" -echo "script_adp:" "$(script_adp)" -echo "script_fn:" "$(script_fn)" -echo "script_fp:" "$(script_fp)" -echo "script_dp:" "$(script_dp)" -echo "ENV:" "$ENV" -echo "-----------------------" -echo "the BASH_SOURCE stack:" - - top_index=$(( ${#BASH_SOURCE[@]} - 1 )) - for (( i=0; i<=top_index; i++ )); do - echo "$i: ${BASH_SOURCE[$i]}" - done diff --git a/tool_shared/bespoke/version b/tool_shared/bespoke/version index 1c3b3e1..9d91a98 100755 --- a/tool_shared/bespoke/version +++ b/tool_shared/bespoke/version @@ -1,6 +1,5 @@ #!/bin/env bash script_afp=$(realpath "${BASH_SOURCE[0]}") -# 2024-11-20T06:45:43Z v2 - subu invoked using machinectl -echo v2.0 +echo "Harmony v0.1 2025-05-19" diff --git a/tool_shared/bespoke/vl b/tool_shared/bespoke/vl deleted file mode 100755 index 2c968d3..0000000 --- a/tool_shared/bespoke/vl +++ /dev/null @@ -1,18 +0,0 @@ -#!/usr/bin/env bash -script_afp=$(realpath "${BASH_SOURCE[0]}") -# vl 'vertical list' - -# Check if the command is provided -if [ -z "$1" ]; then - echo "Usage: vl [args...]" - exit 1 -fi - -# Capture the command and its arguments -cmd=$1 -shift - -# Run the command with the remaining arguments and replace colons or spaces with newlines -"$cmd" "$@" | tr ' :' '\n' - -exit 0 diff --git a/tool_shared/bespoke/wipe_release b/tool_shared/bespoke/wipe_release deleted file mode 100755 index 5bac0e7..0000000 --- a/tool_shared/bespoke/wipe_release +++ /dev/null @@ -1,18 +0,0 @@ -#!/usr/bin/env bash -script_afp=$(realpath "${BASH_SOURCE[0]}") -# remove all files in the release directory -set -e - -script_name=$(basename ${BASH_SOURCE[0]}) -if [ -z "$REPO_HOME" ]; then - echo "$script_name:: REPO_HOME is not set." - exit 1 -fi - -set -x -cd "$REPO_HOME" -rm -rf release/* -set +x - -echo "$script_name done." - diff --git a/tool_shared/customized/.githolder b/tool_shared/customized/.githolder deleted file mode 100644 index e69de29..0000000 diff --git a/tool_shared/document/install b/tool_shared/document/install deleted file mode 100644 index f50cd49..0000000 --- a/tool_shared/document/install +++ /dev/null @@ -1,19 +0,0 @@ - -1. resource - -`resource` has the core makefile environment and targets for compiling C and C++ -programs, as well as seme utility programs. - - > echo $REPO_HOME - /var/user_data/Thomas-developer/subu - - > cd $REPO_HOME/tool_shared/third_party - > git clone git@github.com:Thomas-Walker-Lynch/resource.git - -`resource` is also available from git.reasoningtechnology.com - -2. sqlite - - I suggest punting ... - - # dnf install sqlite sqlite-devel diff --git a/tool_shared/document/install_Python.org b/tool_shared/document/install_Python.org new file mode 100644 index 0000000..4725c04 --- /dev/null +++ b/tool_shared/document/install_Python.org @@ -0,0 +1,75 @@ +#+TITLE: Installing Python in Harmony +#+AUTHOR: Thomas Walker Lynch +#+OPTIONS: toc:2 num:nil + +* Overview + +This document describes how to install a project-local Python environment under: + +#+begin_src bash +tool_shared/third_party/Python +#+end_src + +This environment is shared across the =developer= and =tester= roles and is automatically activated through their respective =env_= scripts. + +* Precondition + +Ensure the following: + +- You are in a POSIX shell with =python3= installed. +- The =python3-venv= package is available (on Debian: =sudo apt install python3-venv=). +- You have sourced the Harmony environment via =env_toolsmith= to initialize =REPO_HOME= and related variables. + +* Step-by-Step Installation + +1. Source the Harmony environment: + #+begin_src bash + source env_toolsmith + #+end_src + +2. Create the virtual environment: + #+begin_src bash + python3 -m venv "$REPO_HOME/tool_shared/third_party/Python" + #+end_src + +3. Activate it temporarily to install required packages: + #+begin_src bash + source "$REPO_HOME/tool_shared/third_party/Python/bin/activate" + pip install --upgrade pip + pip install pytest # Add any shared packages here + deactivate + #+end_src + +4. Rename Python's default activate and deactivate: + Harmony provides its own role-aware environment management. Using Python’s default activation scripts may interfere with prompt logic, PATH order, and role-specific behavior. + + Disable the default scripts by renaming them: + #+begin_src bash + mv "$REPO_HOME/tool_shared/third_party/Python/bin/activate" \ + "$REPO_HOME/tool_shared/third_party/Python/bin/activate_deprecated" + #+end_src + + This ensures that accidental sourcing of Python’s =activate= script won't override Harmony's environment setup. + +5. Verify installation: + #+begin_src bash + ls "$REPO_HOME/tool_shared/third_party/Python/bin/python3" + #+end_src + + The binary should exist and report a working Python interpreter when run. + +* Notes + +- The virtual environment is deliberately named =Python=, not =venv=, to reflect its role as a shared system component. +- Harmony environment scripts define and control =VIRTUAL_ENV=, =PYTHON_HOME=, and =PATH=, making Python activation seamless and uniform. +- There is no need to use Python’s =bin/activate= directly — it is fully replaced by Harmony’s environment logic. + +* Related Files + +- =tool_shared/bespoke/env= +- =tool_shared/bespoke/env_source= +- =env_developer=, =env_tester=, =env_toolsmith= + +* Last Verified + +2025-05-19 :: Activate/deactivate renamed post-install. Requires Harmony environment sourcing prior to execution. diff --git a/tool_shared/document/install_generic.org b/tool_shared/document/install_generic.org new file mode 100644 index 0000000..add47df --- /dev/null +++ b/tool_shared/document/install_generic.org @@ -0,0 +1,81 @@ + +This is the generic install.org doc that comes with the skeleton. + +1. $REPO_HOME/tool_shared/third_party/.gitignore: + + * + !/.gitignore + !/patch + + The only things from the third party directory that will be pushed to the repo origin is the .gitignore file and the patches. + + +2. downloaded tar files etc. go into the directory `upstream` + + $REPO_HOME/tool_shared/upstream + + Typically the contents of upstream are deleted after the install. + +3. for the base install + + cd $REPO_HOME/tool_shared/third_party + do whatever it takes to install tool, as examples: + git clone + tar -xzf ../upstream/tar + ... + + Be sure to add the path to the tool executable(s) in the $REPO_HOME/env_$ROLE files for the $ROLE who uses the tool. + + Assuming you are not also developing the tool, for safety + change each installed git project to a local branch: + + b=__local_$USER + git switch -c "$b" + + +4. Define some variables to simplify our discussion. Lowercase variable names + are not exported from the shell. + + # already set in the environment + # REPO_HOME + # PROJECT + # USER + + # example tool names: 'RT_gcc' 'RT-project share` etc. + tool= + tool_dpath="$REPO_HOME/tool_shared/third_party/$tool" + patch_dpath="$REPO_HOME/tool_shared/patch/" + + +5. create a patch series (from current vendor state → your local edits) + + # this can be repeated and will create an encompassing diff file + + # optionally crate a new branch after cloning the third party tool repo and work from there. You won't make any commits, but in case you plan to ever check the changes in, or have a the bad habit of doing ommits burned into your brain-stem, making a brnch will help. + + # make changes + + cd "$tool_dpath" + + # do your edits + + # Stage edits. Do not commit them!! Be sure you are in the third party + # tool directory when doing `git add -A` and `git diff` commands. + git add -A + + # diff the stage from the current repo to create the patch file + git diff --staged > "$patch_dpath/$tool" + + # the diff file can be added to the project and checked in at the project level. + + +6. how to apply an existing patch + + Get a fresh clone of the tool into $tool_dpath. + + cd "$tool_dpath" + git apply "$patch_dpath/$tool" + + You can see what `git apply` would do by running + + git apply --check /path/to/your/patch_dpath/$tool diff --git a/tool_shared/patch/.githolder b/tool_shared/patch/.githolder new file mode 100644 index 0000000..e69de29 diff --git a/tool_shared/third_party/.gitignore b/tool_shared/third_party/.gitignore index 525ec6b..0de97f0 100644 --- a/tool_shared/third_party/.gitignore +++ b/tool_shared/third_party/.gitignore @@ -1,4 +1,8 @@ +# Ignore all files * + +# But don't ignore the .gitignore file itself !/.gitignore -# upstream has a .gitignore file in it, edit to keep anything precious + +# keep the upstream directory !/upstream diff --git a/tool_shared/third_party/upstream/.gitignore b/tool_shared/third_party/upstream/.gitignore index 120f485..aa0e8eb 100644 --- a/tool_shared/third_party/upstream/.gitignore +++ b/tool_shared/third_party/upstream/.gitignore @@ -1,2 +1,2 @@ * -!/.gitignore +!/.gitignore \ No newline at end of file