#!/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()