commit 46a9a7e144a9f8a43b7ee286d3f9c2cae3162346 Author: Andrew Hurley Date: Sat Jun 20 16:11:09 2026 +0800 first diff --git a/lxcd.py b/lxcd.py new file mode 100755 index 0000000..52db575 --- /dev/null +++ b/lxcd.py @@ -0,0 +1,268 @@ +#!/usr/bin/env python3 +import argparse +import os +import subprocess +import sys +from pathlib import Path + +def get_real_user_info(): + """ + Detects the real user when running under sudo. + Falls back to normal environment variables if not running under sudo. + """ + sudo_user = os.environ.get("SUDO_USER") + sudo_uid = os.environ.get("SUDO_UID") + sudo_gid = os.environ.get("SUDO_GID") + + if sudo_user and sudo_uid and sudo_gid: + return sudo_user, int(sudo_uid), int(sudo_gid), Path(f"/home/{sudo_user}") + else: + return ( + os.environ.get("USER", "root"), + os.getuid(), + os.getgid(), + Path.home() + ) + +def run_cmd(cmd, check=True): + """Helper to run shell commands.""" + try: + return subprocess.run(cmd, check=check, text=True) + except subprocess.CalledProcessError as e: + print(f"Error executing command: {' '.join(cmd)}", file=sys.stderr) + sys.exit(e.returncode) + +def handle_list(args): + # Pass the user.lxcd=true filter directly to LXD's native listing engine + run_cmd(["lxc", "list", "user.lxcd=true"]) + +def handle_clone(args): + print(f"Cloning container '{args.source}' to '{args.name}'...") + + # 1. Native LXD copy (duplicates the filesystem, mounts, and tags) + run_cmd(["lxc", "copy", args.source, args.name]) + + # 2. Start the new cloned container + print(f"Starting cloned container '{args.name}'...") + run_cmd(["lxc", "start", args.name]) + + # 3. Wait for network/cloud-init stabilization + run_cmd(["lxc", "exec", args.name, "--", "cloud-init", "status", "--wait"], check=False) + + print(f"Container '{args.name}' cloned successfully from '{args.source}'!") + +def handle_create(args): + host_username, uid, gid, host_home = get_real_user_info() + + # Paths for targeted workspace sharing + host_coder_dir = host_home / "Coder" + container_coder_path = f"/home/{host_username}/Coder" + + # Ensure the 'Coder' directory exists on the host machine + if not host_coder_dir.exists(): + print(f"Creating missing directory on host: {host_coder_dir}") + host_coder_dir.mkdir(parents=True, exist_ok=True) + os.chown(host_coder_dir, uid, gid) + + print(f"Creating LXD container '{args.name}' from image '{args.image}'...") + + # Cloud-init configuration that drops stock 'ubuntu' user and seeds defaults + cloud_config = f"""#cloud-config +users: + - name: {host_username} + gecos: lxcd user + primary_group: {host_username} + groups: [adm, sudo, plugdev] + shell: /bin/bash + sudo: ALL=(ALL) NOPASSWD:ALL + lock_passwd: true + homedir: /home/{host_username} + +groups: + - {host_username} + +runcmd: + # Copy missing skeleton files (without overwriting existing mounts) and fix ownership + - cp -rpn /etc/skel/. /home/{host_username}/ + - chown -R {host_username}:{host_username} /home/{host_username}/ +""" + + # Initialize container + run_cmd(["lxc", "init", args.image, args.name]) + + # Tag the container so lxcd knows it owns it + print("Tagging container as managed by lxcd...") + run_cmd(["lxc", "config", "set", args.name, "user.lxcd", "true"]) + + # Inject Cloud-init settings + print(f"Injecting cloud-init config for user '{host_username}'...") + run_cmd(["lxc", "config", "set", args.name, "cloud-init.user-data", cloud_config]) + + # Mount host's Coder directory using shift=true + print(f"Mounting host {host_coder_dir} -> {container_coder_path} (shift=true)...") + run_cmd([ + "lxc", "config", "device", "add", args.name, "host-coder", "disk", + f"source={host_coder_dir}", + f"path={container_coder_path}", + "shift=true" + ]) + + # Handle --git option + if args.git: + gitconfig_path = host_home / ".gitconfig" + if gitconfig_path.exists(): + container_git_path = f"/home/{host_username}/.gitconfig" + print(f"Mapping host git configurations to {container_git_path} (shift=true)...") + run_cmd([ + "lxc", "config", "device", "add", args.name, "gitconfig", "disk", + f"source={gitconfig_path}", + f"path={container_git_path}", + "shift=true" + ]) + else: + print(f"Warning: --git requested, but {gitconfig_path} was not found on host.", file=sys.stderr) + + # Handle --nested option + if args.nested: + print("Enabling nesting capabilities...") + run_cmd(["lxc", "config", "set", args.name, "security.nesting", "true"]) + run_cmd(["lxc", "config", "set", args.name, "security.syscalls.intercept.mknod", "true"]) + + # Start container and await cloud-init provisioning + print("Starting container and executing cloud-init user provisioning...") + run_cmd(["lxc", "start", args.name]) + run_cmd(["lxc", "exec", args.name, "--", "cloud-init", "status", "--wait"], check=False) + + print(f"Container '{args.name}' is ready! Workspace mounted at {container_coder_path}") + +def handle_enter(args): + _, uid, gid, host_home = get_real_user_info() + host_cwd = os.getcwd() + + # Dynamically find the user matching your UID inside the container + try: + result = subprocess.run( + ["lxc", "exec", args.name, "--", "id", "-nu", str(uid)], + capture_output=True, text=True, check=True + ) + container_username = result.stdout.strip() + except subprocess.CalledProcessError: + container_username = "root" + + container_home = f"/home/{container_username}" + + # Smart CWD Mapping: Ensure the host directory actually exists in the container + host_coder_dir = str(host_home / "Coder") + container_coder_dir = f"{container_home}/Coder" + + # If the user is inside the mapped Coder directory on the host, translate the path + if host_cwd.startswith(host_coder_dir): + target_cwd = host_cwd.replace(host_coder_dir, container_coder_dir, 1) + else: + # Fallback to the container's home directory if executed from an unmapped host path + target_cwd = container_home + + env_prefix = ( + f"HOME={container_home} " + f"TERM={os.environ.get('TERM', 'xterm-256color')} " + f"LANG={os.environ.get('LANG', 'C.UTF-8')} " + ) + + display = os.environ.get("DISPLAY") + if display: + env_prefix += f"DISPLAY={display} " + + # Execute using native LXD flags for user, group, and working directory + exec_cmd = [ + "lxc", "exec", args.name, + "--cwd", target_cwd, + "--user", str(uid), + "--group", str(gid), + "--", + "sh", "-c", f"env {env_prefix} /bin/bash --login" + ] + + os.execvp("lxc", exec_cmd) + +def handle_delete(args): + print(f"Stopping container '{args.name}'...") + run_cmd(["lxc", "stop", args.name], check=False) + print(f"Deleting container '{args.name}'...") + run_cmd(["lxc", "delete", args.name]) + print(f"Container '{args.name}' deleted successfully.") + +def handle_rename(args): + print(f"Preparing to rename '{args.old_name}' to '{args.new_name}'...") + + # 1. LXD requires containers to be stopped before renaming + print(f"Stopping '{args.old_name}'...") + # Using stdout/stderr redirection internally to hide the error if it's already stopped + subprocess.run( + ["lxc", "stop", args.old_name], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL + ) + + # 2. Execute the rename + run_cmd(["lxc", "rename", args.old_name, args.new_name]) + + # 3. Restart the container under its new name + print(f"Starting renamed container '{args.new_name}'...") + run_cmd(["lxc", "start", args.new_name]) + + # 4. Wait for it to stabilize + run_cmd(["lxc", "exec", args.new_name, "--", "cloud-init", "status", "--wait"], check=False) + + print(f"Container successfully renamed to '{args.new_name}'!") + +def main(): + parser = argparse.ArgumentParser(description="lxcd: A Distrobox-like wrapper for LXD (Sudo Optimized)") + subparsers = parser.add_subparsers(dest="command", required=True) + + # Create command + parser_create = subparsers.add_parser("create", help="Create a new LXD container") + parser_create.add_argument("name", help="Name of the container") + parser_create.add_argument("-i", "--image", default="ubuntu:", help="LXD image to use (defaults to 'ubuntu:')") + parser_create.add_argument("--nested", action="store_true", help="Enable nesting") + parser_create.add_argument("--git", action="store_true", help="Map host ~/.gitconfig into the container") + + # Clone command + parser_clone = subparsers.add_parser("clone", help="Clone an existing LXD container") + parser_clone.add_argument("source", help="Name of the source container") + parser_clone.add_argument("name", help="Name of the new cloned container") + + # Rename command + parser_rename = subparsers.add_parser("rename", aliases=["mv"], help="Rename an existing LXD container") + parser_rename.add_argument("old_name", help="Current name of the container") + parser_rename.add_argument("new_name", help="New name for the container") + + # Enter command + parser_enter = subparsers.add_parser("enter", help="Enter an existing LXD container") + parser_enter.add_argument("name", help="Name of the container to enter") + + # List command + parser_list = subparsers.add_parser("list", aliases=["ls"], help="List all managed LXD containers") + + # Delete command + parser_delete = subparsers.add_parser("delete", help="Delete an LXD container") + parser_delete.add_argument("name", help="Name of the container to delete") + + args = parser.parse_args() + + if args.command == "create": + handle_create(args) + elif args.command == "clone": + handle_clone(args) + elif args.command in ["rename", "mv"]: + handle_rename(args) + elif args.command == "enter": + handle_enter(args) + elif args.command in ["list", "ls"]: + handle_list(args) + elif args.command == "delete": + handle_delete(args) + +if __name__ == "__main__": + main() + +