Integrate rvctl with rv32i and host debug profiles

This commit is contained in:
mpabi
2026-05-01 22:23:08 +02:00
parent 339d7d6872
commit 2204f82268
5 changed files with 505 additions and 106 deletions
+318 -4
View File
@@ -52,6 +52,7 @@ class WorkspaceConfig:
token_path: Path
tools_root_candidates: list[Path]
tools_root: Path
env_tool_url: str
defaults: dict[str, str]
git: GitConfig
@@ -109,6 +110,19 @@ REPO_PERMISSION_FIELDS = [
("r", "read"),
]
ENV_PROFILES = {
"rv32i": {
"service": "env",
"image": "edu-inf/rv32i-hazard3-env:latest",
"purpose": "RV32I/Hazard3 nvim + gdb-multiarch",
},
"host": {
"service": "host",
"image": "edu-inf/rv32i-hazard3-host-env:latest",
"purpose": "native host clang/gcc + gdb/lldb",
},
}
def load_config(config_path: Path) -> WorkspaceConfig:
raw = json.loads(config_path.read_text(encoding="utf-8"))
@@ -116,6 +130,8 @@ def load_config(config_path: Path) -> WorkspaceConfig:
original_root = expand_path(raw.get("original_root", "~/dev/edu/repos/rv"), base_dir)
workspace_root = expand_path(raw.get("workspace_root", "~/dev/workspace/rv"), base_dir)
git_raw = raw.get("git", {})
base_url = git_raw.get("base_url", "http://77.90.8.171:3001").rstrip("/")
original_series_root = expand_path(raw.get("original_series_root", str(original_root / "series")), base_dir)
series_root = expand_path(raw.get("series_root", str(workspace_root / "series")), base_dir)
socket_root = expand_path(raw.get("socket_root", str(workspace_root / "sockets")), base_dir)
@@ -128,15 +144,16 @@ def load_config(config_path: Path) -> WorkspaceConfig:
candidate_values = raw.get("tools_root_candidates")
if not candidate_values:
candidate_values = [
raw.get("tools_root", str(workspace_root / "tools" / "rv32i-hazard3-env")),
raw.get("tools_root", str(workspace_root / "tools" / "rv32i-hazard3-student-env")),
str(workspace_root / "tools" / "rv32i-hazard3-env"),
str(original_root / "rv32i-hazard3-student-env"),
str(original_root / "rv32i-hazard3-env"),
]
tools_root_candidates = [expand_path(value, base_dir) for value in candidate_values]
tools_root = next((path for path in tools_root_candidates if path.exists()), tools_root_candidates[0])
env_tool_url = raw.get("env_tool_url", f"{base_url}/edu-inf/rv32i-hazard3-student-env.git")
defaults = raw.get("defaults", {})
git_raw = raw.get("git", {})
base_url = git_raw.get("base_url", "http://77.90.8.171:3001").rstrip("/")
workspace_info_url = raw.get("workspace_info_url", f"{base_url}/edu-workspace/workspace-info.git")
git = GitConfig(
base_url=base_url,
@@ -162,6 +179,7 @@ def load_config(config_path: Path) -> WorkspaceConfig:
token_path=token_path,
tools_root_candidates=tools_root_candidates,
tools_root=tools_root,
env_tool_url=env_tool_url,
defaults=defaults,
git=git,
)
@@ -3316,6 +3334,251 @@ def run_tmux_container(config: WorkspaceConfig, args: argparse.Namespace) -> Non
print(f"Attach with: tmux attach -t {session_name}")
def normalize_env_profile(profile: str) -> str:
aliases = {
"riscv": "rv32i",
"riscv32": "rv32i",
"native": "host",
}
normalized = aliases.get(profile, profile)
if normalized not in ENV_PROFILES:
available = ", ".join(sorted(ENV_PROFILES))
raise SystemExit(f"Unsupported environment profile '{profile}'. Available: {available}")
return normalized
def env_tool_command_path(config: WorkspaceConfig) -> Path:
return config.tools_root / "rv"
def ensure_env_tool(config: WorkspaceConfig) -> Path:
rv_script = env_tool_command_path(config)
if rv_script.is_file():
return rv_script
candidates = "\n".join(f" - {path}" for path in config.tools_root_candidates)
raise SystemExit(
"Missing rv32i-hazard3-student-env launcher script 'rv'.\n"
f"Checked:\n{candidates}\n"
"Run: ./rvctl env sync"
)
def env_sync_target(config: WorkspaceConfig) -> Path:
return config.workspace_root / "tools" / "rv32i-hazard3-student-env"
def sync_env_tool(config: WorkspaceConfig, dry_run: bool = False) -> dict[str, str]:
target = env_sync_target(config)
if dry_run:
command = "pull" if (target / ".git").is_dir() else "clone"
return {
"env_tool_path": str(target),
"env_tool_url": config.env_tool_url,
"command": command,
"status": "dry-run",
}
target.parent.mkdir(parents=True, exist_ok=True)
if (target / ".git").is_dir():
subprocess.run(["git", "-C", str(target), "pull", "--ff-only"], check=True)
config.tools_root = target
return {
"env_tool_path": str(target),
"env_tool_url": config.env_tool_url,
"command": "pull",
"status": "ready",
}
if target.exists():
raise SystemExit(f"Environment tool path exists but is not a git repo: {target}")
subprocess.run(["git", "clone", config.env_tool_url, str(target)], check=True)
config.tools_root = target
return {
"env_tool_path": str(target),
"env_tool_url": config.env_tool_url,
"command": "clone",
"status": "ready",
}
def task_selector_like(value: str) -> bool:
return bool(re.fullmatch(r"(?i)(task[-_]?)?\d+.*|t\d+", value.strip()))
def resolve_env_target(
config: WorkspaceConfig,
values: list[str],
task_override: str | None = None,
) -> tuple[str, str, str]:
if len(values) > 3:
raise SystemExit("Pass '<task>', '<card> [task]' or '<series> <card> [task]'.")
if not values:
series_name, card_name = resolve_selector(config, None, None)
card_path = resolve_workspace_card_path(config, series_name, card_name)
task_name = resolve_submission_task(config, card_path, task_override)
return series_name, card_name, task_name
if len(values) == 1:
if task_selector_like(values[0]):
series_name, card_name = resolve_selector(config, None, None)
task_selector = task_override or values[0]
else:
series_name, card_name = resolve_selector(config, values[0], None)
task_selector = task_override
card_path = resolve_workspace_card_path(config, series_name, card_name)
task_name = resolve_submission_task(config, card_path, task_selector)
return series_name, card_name, task_name
if len(values) == 2:
if task_selector_like(values[1]):
series_name, card_name = resolve_selector(config, values[0], None)
task_selector = task_override or values[1]
else:
series_name, card_name = resolve_selector(config, values[0], values[1])
task_selector = task_override
card_path = resolve_workspace_card_path(config, series_name, card_name)
task_name = resolve_submission_task(config, card_path, task_selector)
return series_name, card_name, task_name
series_name, card_name = resolve_selector(config, values[0], values[1])
card_path = resolve_workspace_card_path(config, series_name, card_name)
task_name = resolve_submission_task(config, card_path, task_override or values[2])
return series_name, card_name, task_name
def default_env_instance(profile: str, series_name: str, card_name: str, task_name: str, action: str) -> str:
task_part = task_branch_part(task_name).lower()
return slug_value(f"{profile}-{series_name}-{card_name}-{task_part}-{action}", "environment instance")
def env_action_command(
config: WorkspaceConfig,
profile: str,
action: str,
series_name: str | None = None,
card_name: str | None = None,
task_name: str | None = None,
instance: str | None = None,
editor: str | None = None,
) -> tuple[str, dict[str, str]]:
profile = normalize_env_profile(profile)
rv_script = ensure_env_tool(config)
env_vars = {
"WORKSPACE_ROOT": str(config.workspace_root),
"RV_CARDS_ROOT": str(config.series_root),
"RV_PROFILE": profile,
}
command_args = ["bash", "./rv"]
card_path: Path | None = None
selector = ""
if series_name and card_name:
card_path = resolve_workspace_card_path(config, series_name, card_name)
selector = f"{series_name}/{card_name}"
env_vars.update(
{
"RV_REPO_PATH": str(card_path),
"RV_REPO_HOST": str(card_path),
"RV_CARD": card_path.name,
}
)
if instance:
env_vars["RV_INSTANCE"] = instance
elif series_name and card_name and task_name:
env_vars["RV_INSTANCE"] = default_env_instance(profile, series_name, card_name, task_name, action)
if editor:
env_vars["RV_EDITOR"] = editor
if action == "build":
command_args.append("build")
elif action == "shell":
command_args.append("shell")
elif action == "debug":
if not task_name:
raise SystemExit("debug requires a task selector.")
if profile == "rv32i":
command_args.extend(["nvim", task_name])
else:
command_args.extend(["debug", task_name])
elif action == "run":
if not task_name:
raise SystemExit("run requires a task selector.")
if profile == "rv32i":
raise SystemExit("rv32i run is not separate yet. Use: ./rvctl debug rv32i ...")
command_args.extend(["run", task_name])
else:
raise SystemExit(f"Unsupported environment action: {action}")
env_prefix = " ".join(f"{key}={shlex.quote(value)}" for key, value in env_vars.items())
shell_command = " && ".join(
[
f"cd {shlex.quote(str(rv_script.parent))}",
" ".join([env_prefix, *[shlex.quote(part) for part in command_args]]),
]
)
details = {
"profile": profile,
"service": ENV_PROFILES[profile]["service"],
"selector": selector,
"task": task_name or "",
"card_path": str(card_path or ""),
"tools_root": str(config.tools_root),
"instance": env_vars.get("RV_INSTANCE", ""),
"command": shell_command,
}
return shell_command, details
def print_env_plan(details: dict[str, str]) -> None:
for key in ["profile", "service", "selector", "task", "card_path", "tools_root", "instance", "command"]:
print(f"{key}\t{details.get(key, '')}")
def run_env(config: WorkspaceConfig, args: argparse.Namespace) -> None:
if args.env_command == "list":
print("profile\tservice\timage\tstatus\tpurpose")
for profile, info in ENV_PROFILES.items():
print(
f"{profile}\t{info['service']}\t{info['image']}\t"
f"{'ready' if env_tool_command_path(config).is_file() else 'missing'}\t{info['purpose']}"
)
print(f"tools_root\t{config.tools_root}")
print(f"env_tool_url\t{config.env_tool_url}")
return
if args.env_command == "sync":
print_key_values(sync_env_tool(config, dry_run=args.dry_run))
return
if args.env_command == "build":
profile = normalize_env_profile(args.profile)
shell_command, details = env_action_command(config, profile, "build")
if args.dry_run:
print_env_plan(details)
return
subprocess.run(["bash", "-lc", shell_command], check=True)
return
raise SystemExit(f"Unsupported env command: {args.env_command}")
def run_profile_action(config: WorkspaceConfig, args: argparse.Namespace, action: str) -> None:
profile = normalize_env_profile(args.profile)
series_name, card_name, task_name = resolve_env_target(config, args.selector, getattr(args, "task", None))
shell_command, details = env_action_command(
config,
profile,
action,
series_name=series_name,
card_name=card_name,
task_name=task_name,
instance=getattr(args, "instance", None),
editor=getattr(args, "editor", None),
)
if args.dry_run:
print_env_plan(details)
return
subprocess.run(["bash", "-lc", shell_command], check=True)
def workspace_info_status(config: WorkspaceConfig) -> str:
info_root = workspace_info_root(config)
if (info_root / ".git").is_dir():
@@ -3395,6 +3658,7 @@ def print_config(config: WorkspaceConfig) -> None:
print(f"socket_root\t{config.socket_root}")
print(f"token_path\t{config.token_path}")
print(f"tools_root\t{config.tools_root}")
print(f"env_tool_url\t{config.env_tool_url}")
print(f"git_base_url\t{config.git.base_url}")
print(f"git_source_org\t{config.git.source_org}")
print(f"git_answer_org\t{config.git.answer_org}")
@@ -3474,6 +3738,10 @@ def print_main_overview(config_path: Path) -> None:
["workspace", "show|sync", "show or update meta/workspace-info"],
["series", "list|cards|fetch|tasks", "work with series, cards and tasks"],
["tasks", "list|show|switch", "shortcut for tasks in the default card"],
["env", "list|sync|build", "manage rv32i/host container environment"],
["debug", "rv32i|host [selector]", "open nvim + gdb in the selected container profile"],
["run", "host [selector]", "build and run selected task natively in host container"],
["shell", "rv32i|host [selector]", "open a shell in the selected container profile"],
["list-series", "", "compat alias for series list"],
["list-cards", "[series]", "compat alias for series cards list"],
["tmux-container", "[series] [card]", "start tmux with container in pane 0"],
@@ -3493,6 +3761,8 @@ def print_main_overview(config_path: Path) -> None:
["5", "./rvctl series cards fetch inf bss", "fetch card and prepare r1a"],
["6", "./rvctl tasks list", "list tasks from the default card"],
["7", "./rvctl tasks switch 4", "switch to the answer branch for task 4"],
["8", "./rvctl debug rv32i 4", "debug task 4 in RV32I/Hazard3"],
["9", "./rvctl debug host 4", "debug task 4 natively in a host container"],
],
)
print()
@@ -3502,6 +3772,7 @@ def print_main_overview(config_path: Path) -> None:
print(" doc/rvctl.md")
print(" doc/tokens.md")
print(" doc/tokens.schema.json")
print(" doc/containers.md")
print(" doc/workspace.md")
@@ -3598,6 +3869,37 @@ def build_parser() -> argparse.ArgumentParser:
workspace_sync_parser = workspace_subparsers.add_parser("sync", help="Clone or update workspace-info into meta/.")
workspace_sync_parser.add_argument("--dry-run", action="store_true", help="Print plan without cloning or pulling.")
env_parser = subparsers.add_parser("env", help="Manage rv32i/host container environments.")
env_subparsers = env_parser.add_subparsers(dest="env_command", required=True)
env_subparsers.add_parser("list", help="List supported environment profiles.")
env_sync_parser = env_subparsers.add_parser("sync", help="Clone or update rv32i-hazard3-student-env into tools/.")
env_sync_parser.add_argument("--dry-run", action="store_true", help="Print plan without cloning or pulling.")
env_build_parser = env_subparsers.add_parser("build", help="Build one container profile.")
env_build_parser.add_argument("profile", choices=sorted(ENV_PROFILES), help="Environment profile.")
env_build_parser.add_argument("--dry-run", action="store_true", help="Print command without building.")
debug_parser = subparsers.add_parser("debug", help="Open selected task in nvim + debugger inside a container profile.")
debug_parser.add_argument("profile", choices=sorted(ENV_PROFILES), help="Environment profile: rv32i or host.")
debug_parser.add_argument("selector", nargs="*", help="Optional selector: [task], [card task] or [series card task].")
debug_parser.add_argument("--task", help="Task override. Defaults to selector or defaults.task.")
debug_parser.add_argument("--instance", help="Container/tmux instance name.")
debug_parser.add_argument("--editor", choices=["nvim", "vim"], help="Editor inside the container.")
debug_parser.add_argument("--dry-run", action="store_true", help="Print command without starting Docker.")
run_parser = subparsers.add_parser("run", help="Build and run selected task inside a container profile.")
run_parser.add_argument("profile", choices=sorted(ENV_PROFILES), help="Environment profile. Currently host supports run.")
run_parser.add_argument("selector", nargs="*", help="Optional selector: [task], [card task] or [series card task].")
run_parser.add_argument("--task", help="Task override. Defaults to selector or defaults.task.")
run_parser.add_argument("--instance", help="Container instance name.")
run_parser.add_argument("--dry-run", action="store_true", help="Print command without starting Docker.")
shell_parser = subparsers.add_parser("shell", help="Open a shell inside a container profile.")
shell_parser.add_argument("profile", choices=sorted(ENV_PROFILES), help="Environment profile: rv32i or host.")
shell_parser.add_argument("selector", nargs="*", help="Optional selector: [card] or [series card].")
shell_parser.add_argument("--task", help="Task used only for instance naming.")
shell_parser.add_argument("--instance", help="Container instance name.")
shell_parser.add_argument("--dry-run", action="store_true", help="Print command without starting Docker.")
tasks_parser = subparsers.add_parser("tasks", help="Shortcut for tasks in the default card.")
tasks_subparsers = tasks_parser.add_subparsers(dest="tasks_command", required=True)
@@ -3836,7 +4138,7 @@ def main() -> int:
parser = build_parser()
args = parser.parse_args()
config = load_config(args.config)
if args.command in {"list-series", "list-cards", "series", "tasks", "tmux-container", "submission"}:
if args.command in {"list-series", "list-cards", "series", "tasks", "tmux-container", "submission", "debug", "run", "shell"}:
ensure_workspace_info(config)
if args.command == "show-config":
@@ -3851,6 +4153,18 @@ def main() -> int:
if args.command == "workspace":
run_workspace(config, args)
return 0
if args.command == "env":
run_env(config, args)
return 0
if args.command == "debug":
run_profile_action(config, args, "debug")
return 0
if args.command == "run":
run_profile_action(config, args, "run")
return 0
if args.command == "shell":
run_profile_action(config, args, "shell")
return 0
if args.command == "tasks":
run_tasks(config, args)
return 0