This commit is contained in:
Andrew Hurley 2026-07-05 17:16:24 +08:00
parent 4c15e16a3a
commit 4e39381b83
4 changed files with 217 additions and 191 deletions

7
.gitignore vendored
View File

@ -4,3 +4,10 @@ __pycache__/
*.egg-info/ *.egg-info/
dist/ dist/
build/ build/
# Snapcraft build artifacts
*.snap
parts/
stage/
prime/
.snapcraft/

View File

@ -26,7 +26,7 @@ _lxcd_completion() {
case "${subcommand}" in case "${subcommand}" in
create) create)
if [[ "$cur" == -* ]]; then if [[ "$cur" == -* ]]; then
COMPREPLY=($(compgen -W "--nested --git --certs --aptcache --ssh-client -i --image -m --minimal" -- "$cur")) COMPREPLY=($(compgen -W "--nested --aptcache -i --image -m --minimal -w --workspace" -- "$cur"))
else else
COMPREPLY=($(compgen -W "ubuntu: ubuntu/24.04 ubuntu/22.04 debian/12 debian/11" -- "$cur")) COMPREPLY=($(compgen -W "ubuntu: ubuntu/24.04 ubuntu/22.04 debian/12 debian/11" -- "$cur"))
fi fi
@ -52,7 +52,7 @@ _lxcd_completion() {
if [ $cword -eq 2 ]; then if [ $cword -eq 2 ]; then
COMPREPLY=($(compgen -W "${containers}" -- "$cur")) COMPREPLY=($(compgen -W "${containers}" -- "$cur"))
elif [[ "$cur" == -* ]]; then elif [[ "$cur" == -* ]]; then
COMPREPLY=($(compgen -W "--nested --git --certs --aptcache --ssh-client" -- "$cur")) COMPREPLY=($(compgen -W "--nested --aptcache --map --unmap" -- "$cur"))
fi fi
;; ;;

370
lxcd.py
View File

@ -6,8 +6,75 @@ import re
import subprocess import subprocess
import sys import sys
import time import time
import json
from pathlib import Path from pathlib import Path
VERBOSE = False
def config_path():
sudo_user = os.environ.get("SUDO_USER")
home = Path(f"/home/{sudo_user}") if sudo_user else Path.home()
return home / ".lxcdrc"
def ensure_config():
path = config_path()
if path.exists():
return
config_content = """# lxcd configuration
#
# [workspace] sets the default workspace directory name used by 'lxcd create'
# and 'lxcd enter'.
# Additional sections (e.g. [git], [opencode]) define paths to mount
# into containers via 'lxcd add --map=<section>'.
[workspace]
Code
[git]
.gitconfig
[opencode]
.config/opencode
.local/share/opencode
"""
try:
path.write_text(config_content)
print(f"Created {path}")
except Exception as e:
print(f"Warning: Could not create config at {path}: {e}", file=sys.stderr)
def load_config():
path = config_path()
if not path.exists():
return {}
config = {}
section = None
values = []
try:
for line in path.read_text().splitlines():
line = line.strip()
if not line or line.startswith("#"):
continue
m = re.match(r"^\[(.+)\]$", line)
if m:
if section is not None:
config[section] = values
section = m.group(1)
values = []
elif section is not None:
values.append(line)
if section is not None:
config[section] = values
except Exception as e:
print(f"Warning: Could not read config from {path}: {e}", file=sys.stderr)
return config
def get_real_user_info(): def get_real_user_info():
""" """
Detects the real user when running under sudo. Detects the real user when running under sudo.
@ -28,6 +95,9 @@ def get_real_user_info():
) )
def detect_timezone(): def detect_timezone():
if os.environ.get("SNAP"):
return "UTC"
tz = "UTC" tz = "UTC"
tz_file = Path("/etc/timezone") tz_file = Path("/etc/timezone")
if tz_file.exists(): if tz_file.exists():
@ -40,19 +110,17 @@ def detect_timezone():
m = re.search(r"Time zone:\s+(\S+)", result.stdout) m = re.search(r"Time zone:\s+(\S+)", result.stdout)
if m: if m:
tz = m.group(1) tz = m.group(1)
except FileNotFoundError: except Exception:
pass pass
return tz return tz
def detect_locale(): def detect_locale():
return os.environ.get("LANG", "en_US.UTF-8") return os.environ.get("LANG", "en_US.UTF-8")
VERBOSE = False
def lxc_prefix(): def lxc_prefix():
"""Return ['sudo'] if the user isn't root and isn't in the lxd group.""" """Return [] if running inside a Snap, or if the user is root or in the lxd group."""
if os.environ.get("SNAP"):
return []
if os.getuid() == 0: if os.getuid() == 0:
return [] return []
try: try:
@ -84,6 +152,17 @@ def lxc_subprocess(args, **kwargs):
print(f"+ {' '.join(cmd)}", file=sys.stderr) print(f"+ {' '.join(cmd)}", file=sys.stderr)
return subprocess.run(cmd, **kwargs) return subprocess.run(cmd, **kwargs)
def set_label(name, feature, value="true"):
"""Set a user.lxcd.<feature> config key on a container."""
lxc_run(["config", "set", name, f"user.lxcd.{feature}", value], check=False)
def container_exists(name):
result = lxc_subprocess(
["info", name],
capture_output=True, text=True, check=False
)
return result.returncode == 0
def handle_list(args): def handle_list(args):
print("Fetching lxcd container environments...") print("Fetching lxcd container environments...")
@ -95,13 +174,14 @@ def handle_list(args):
print("Error communicating with LXD.", file=sys.stderr) print("Error communicating with LXD.", file=sys.stderr)
return return
import json
containers = json.loads(result.stdout) containers = json.loads(result.stdout)
print(f"\n{'CONTAINER NAME':<25} {'STATUS':<12} {'IPV4 ADDRESS':<25} {'ACTIVE OPTIONS':<25}") print(f"\n{'CONTAINER NAME':<25} {'STATUS':<12} {'IPV4 ADDRESS':<25} {'ACTIVE OPTIONS':<25}")
print("-" * 87) print("-" * 87)
count = 0 count = 0
cfg = load_config()
for c in containers: for c in containers:
name = c["name"] name = c["name"]
status = c["status"] status = c["status"]
@ -115,7 +195,6 @@ def handle_list(args):
if addrs: if addrs:
ipv4 = addrs[0] ipv4 = addrs[0]
# Read lxcd feature labels via individual config get
def get_config(key): def get_config(key):
return lxc_subprocess( return lxc_subprocess(
["config", "get", name, key], ["config", "get", name, key],
@ -124,14 +203,17 @@ def handle_list(args):
active_opts = [] active_opts = []
# Check nesting via security.nesting (native LXD config)
if get_config("security.nesting") == "true": if get_config("security.nesting") == "true":
active_opts.append("nesting") active_opts.append("nesting")
# Check labels for other features if get_config("user.lxcd.aptcache") == "true":
for feature in ("certs", "git", "aptcache", "ssh-client"): active_opts.append("aptcache")
if get_config(f"user.lxcd.{feature}") == "true":
active_opts.append(feature) for section in cfg.keys():
if section == "workspace":
continue
if get_config(f"user.lxcd.map.{section}") == "true":
active_opts.append(section)
opts_str = ", ".join(active_opts) if active_opts else "none" opts_str = ", ".join(active_opts) if active_opts else "none"
print(f"{name:<25} {status:<12} {ipv4:<25} {opts_str:<25}") print(f"{name:<25} {status:<12} {ipv4:<25} {opts_str:<25}")
@ -144,30 +226,28 @@ def handle_list(args):
def handle_clone(args): def handle_clone(args):
print(f"Cloning container '{args.source}' to '{args.name}'...") print(f"Cloning container '{args.source}' to '{args.name}'...")
# 1. Native LXD copy (duplicates the filesystem, mounts, and tags) lxc_run(["copy", args.source, args.name])
lxc_run([ "copy", args.source, args.name])
# 2. Start the new cloned container
print(f"Starting cloned container '{args.name}'...") print(f"Starting cloned container '{args.name}'...")
lxc_run([ "start", args.name]) lxc_run(["start", args.name])
lxc_run(["exec", args.name, "--", "cloud-init", "status", "--wait"], check=False)
# 3. Wait for network/cloud-init stabilization
lxc_run([ "exec", args.name, "--", "cloud-init", "status", "--wait"], check=False)
print(f"Container '{args.name}' cloned successfully from '{args.source}'!") print(f"Container '{args.name}' cloned successfully from '{args.source}'!")
def handle_create(args): def handle_create(args):
ensure_config()
cfg = load_config()
workspace = args.workspace or (cfg.get("workspace")[0] if cfg.get("workspace") else "Coder")
host_username, uid, gid, host_home = get_real_user_info() host_username, uid, gid, host_home = get_real_user_info()
if args.minimal: if args.minimal:
args.image = "ubuntu-minimal:" args.image = "ubuntu-minimal:"
host_timezone = detect_timezone() host_timezone = detect_timezone()
host_locale = detect_locale() host_locale = detect_locale()
# Paths for targeted workspace sharing host_coder_dir = host_home / workspace
host_coder_dir = host_home / "Coder" container_coder_path = f"/home/{host_username}/{workspace}"
container_coder_path = f"/home/{host_username}/Coder"
# Ensure the 'Coder' directory exists on the host machine
if not host_coder_dir.exists(): if not host_coder_dir.exists():
print(f"Creating missing directory on host: {host_coder_dir}") print(f"Creating missing directory on host: {host_coder_dir}")
host_coder_dir.mkdir(parents=True, exist_ok=True) host_coder_dir.mkdir(parents=True, exist_ok=True)
@ -175,18 +255,11 @@ def handle_create(args):
print(f"Creating LXD container '{args.name}' from image '{args.image}'...") print(f"Creating LXD container '{args.name}' from image '{args.image}'...")
# Cloud-init configuration
apt_proxy_cmd = ( apt_proxy_cmd = (
f" - mkdir -p /etc/apt/apt.conf.d\n" f" - mkdir -p /etc/apt/apt.conf.d\n"
f" - echo 'Acquire::http::Proxy \"{args.aptcache}\";' > /etc/apt/apt.conf.d/00lxcd-proxy\n" f" - echo 'Acquire::http::Proxy \"{args.aptcache}\";' > /etc/apt/apt.conf.d/00lxcd-proxy\n"
) if args.aptcache else "" ) if args.aptcache else ""
ssh_client_packages = "packages:\n - openssh-client\n" if args.ssh_client else ""
ssh_client_cmd = (
f" - mkdir -p /home/{host_username}/.ssh\n"
f" - chmod 700 /home/{host_username}/.ssh\n"
) if args.ssh_client else ""
cloud_config = f"""#cloud-config cloud_config = f"""#cloud-config
timezone: {host_timezone} timezone: {host_timezone}
locale: {host_locale} locale: {host_locale}
@ -203,91 +276,64 @@ users:
groups: groups:
- {host_username} - {host_username}
{ssh_client_packages}bootcmd: bootcmd:
- mkdir -p /home/{host_username} - mkdir -p /home/{host_username}
- mkdir -p /home/{host_username}/.config /home/{host_username}/.local /home/{host_username}/.local/share
- chown {uid}:{gid} /home/{host_username}/.config /home/{host_username}/.local /home/{host_username}/.local/share || true
- chown {uid}:{gid} /home/{host_username} - chown {uid}:{gid} /home/{host_username}
runcmd: runcmd:
- cp -rpn /etc/skel/. /home/{host_username}/ - cp -rpn /etc/skel/. /home/{host_username}/
- mkdir -p {container_coder_path} - mkdir -p {container_coder_path}
{ssh_client_cmd} - chown -R {host_username}:{host_username} /home/{host_username} - chown -R {host_username}:{host_username} /home/{host_username}
- echo "SSH left at default image state" - echo "SSH left at default image state"
{apt_proxy_cmd}""" {apt_proxy_cmd}"""
# Initialize container lxc_run(["init", args.image, args.name])
lxc_run([ "init", args.image, args.name])
# Tag the container so lxcd knows it owns it
print("Tagging container as managed by lxcd...") print("Tagging container as managed by lxcd...")
lxc_run([ "config", "set", args.name, "user.lxcd", "true"]) lxc_run(["config", "set", args.name, "user.lxcd", "true"])
# Inject Cloud-init settings print(f"Injecting cloud-init config for user '{host_username}'..." )
print(f"Injecting cloud-init config for user '{host_username}'...") lxc_run(["config", "set", args.name, "cloud-init.user-data", cloud_config])
lxc_run([ "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)...") print(f"Mounting host {host_coder_dir} -> {container_coder_path} (shift=true)...")
lxc_run(["config", "device", "add", args.name, "host-coder", "disk", lxc_run(["config", "device", "add", args.name, "host-coder", "disk",
f"source={host_coder_dir}", f"source={host_coder_dir}",
f"path={container_coder_path}", f"path={container_coder_path}",
"shift=true"]) "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)...")
lxc_run(["config", "device", "add", args.name, "gitconfig", "disk",
f"source={gitconfig_path}",
f"path={container_git_path}",
"shift=true"])
set_label(args.name, "git")
else:
print(f"Warning: --git requested, but {gitconfig_path} was not found on host.", file=sys.stderr)
# Handle --nested option
if args.nested: if args.nested:
print("Enabling nesting capabilities...") print("Enabling nesting capabilities...")
lxc_run([ "config", "set", args.name, "security.nesting", "true"]) lxc_run(["config", "set", args.name, "security.nesting", "true"])
lxc_run([ "config", "set", args.name, "security.syscalls.intercept.mknod", "true"]) lxc_run(["config", "set", args.name, "security.syscalls.intercept.mknod", "true"])
set_label(args.name, "nested") set_label(args.name, "nested")
# Handle --aptcache option
if args.aptcache: if args.aptcache:
print(f"APT cache/proxy will be configured to {args.aptcache} via cloud-init...") print(f"APT cache/proxy will be configured to {args.aptcache} via cloud-init...")
set_label(args.name, "aptcache") set_label(args.name, "aptcache")
# Handle --ssh-client option
if args.ssh_client:
print(f"SSH client and ~/.ssh directory will be set up for '{host_username}' via cloud-init...")
set_label(args.name, "ssh-client")
# Handle --certs option
if args.certs:
host_certs = Path("/etc/ssl/certs/ca-certificates.crt")
if host_certs.exists():
print("Mounting host ca-certificates bundle securely into container...")
lxc_run(["config", "device", "add", args.name, "host-certs", "disk",
f"source={host_certs}",
f"path={host_certs}",
"shift=true"])
set_label(args.name, "certs")
else:
print("Warning: Host cert bundle path not found. Skipping certificate mount configuration.")
# Start container and await cloud-init provisioning
print("Starting container and executing cloud-init user provisioning...") print("Starting container and executing cloud-init user provisioning...")
lxc_run([ "start", args.name]) lxc_run(["start", args.name])
lxc_run([ "exec", args.name, "--", "cloud-init", "status", "--wait"], check=False) lxc_run(["exec", args.name, "--", "cloud-init", "status", "--wait"], check=False)
print(f"Container '{args.name}' is ready! Workspace mounted at {container_coder_path}") print(f"Container '{args.name}' is ready! Workspace mounted at {container_coder_path}")
def handle_enter(args): def handle_enter(args):
cfg = load_config()
workspace = (cfg.get("workspace")[0] if cfg.get("workspace") else "Coder")
_, uid, gid, host_home = get_real_user_info() _, uid, gid, host_home = get_real_user_info()
host_cwd = os.getcwd()
state = lxc_subprocess(
["info", args.name],
capture_output=True, text=True, check=False
)
if "Status: RUNNING" not in state.stdout:
print(f"Container '{args.name}' is not running. Starting it...")
lxc_run(["start", args.name])
lxc_run(["exec", args.name, "--", "cloud-init", "status", "--wait"], check=False)
print(f"Container '{args.name}' started.")
# Dynamically find the user matching your UID inside the container
try: try:
result = lxc_subprocess( result = lxc_subprocess(
["exec", args.name, "--", "id", "-nu", str(uid)], ["exec", args.name, "--", "id", "-nu", str(uid)],
@ -298,17 +344,8 @@ def handle_enter(args):
container_username = "root" container_username = "root"
container_home = f"/home/{container_username}" container_home = f"/home/{container_username}"
container_coder_dir = f"{container_home}/{workspace}"
# Smart CWD Mapping: Ensure the host directory actually exists in the container target_cwd = container_coder_dir
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 = ( env_prefix = (
f"HOME={container_home} " f"HOME={container_home} "
@ -320,17 +357,8 @@ def handle_enter(args):
if display: if display:
env_prefix += f"DISPLAY={display} " env_prefix += f"DISPLAY={display} "
# Check if container has the ssh-client feature (set by --ssh-client)
has_ssh_client = lxc_subprocess(
["config", "get", args.name, "user.lxcd.ssh-client"],
capture_output=True, text=True, check=False
).stdout.strip() == "true"
# Execute using native LXD flags for user, group, and working directory
prefix = lxc_prefix() prefix = lxc_prefix()
shell_cmd = "exec /bin/bash --login" shell_cmd = "exec /bin/bash --login"
if has_ssh_client:
shell_cmd = "eval $(ssh-agent) && ssh-add; " + shell_cmd
exec_cmd = [ exec_cmd = [
"lxc", "exec", args.name, "lxc", "exec", args.name,
"--cwd", target_cwd, "--cwd", target_cwd,
@ -347,40 +375,28 @@ def handle_enter(args):
def handle_stop(args): def handle_stop(args):
print(f"Stopping container '{args.name}'...") print(f"Stopping container '{args.name}'...")
lxc_run([ "stop", args.name], check=False) lxc_run(["stop", args.name], check=False)
print(f"Container '{args.name}' stopped.") print(f"Container '{args.name}' stopped.")
def handle_start(args): def handle_start(args):
print(f"Starting container '{args.name}'...") print(f"Starting container '{args.name}'...")
lxc_run([ "start", args.name]) lxc_run(["start", args.name])
lxc_run([ "exec", args.name, "--", "cloud-init", "status", "--wait"], check=False) lxc_run(["exec", args.name, "--", "cloud-init", "status", "--wait"], check=False)
print(f"Container '{args.name}' started.") print(f"Container '{args.name}' started.")
def handle_restart(args): def handle_restart(args):
print(f"Restarting container '{args.name}'...") print(f"Restarting container '{args.name}'...")
lxc_run([ "restart", args.name], check=False) lxc_run(["restart", args.name], check=False)
lxc_run([ "exec", args.name, "--", "cloud-init", "status", "--wait"], check=False) lxc_run(["exec", args.name, "--", "cloud-init", "status", "--wait"], check=False)
print(f"Container '{args.name}' restarted.") print(f"Container '{args.name}' restarted.")
def handle_delete(args): def handle_delete(args):
print(f"Stopping container '{args.name}'...") print(f"Stopping container '{args.name}'...")
lxc_run([ "stop", args.name], check=False) lxc_run(["stop", args.name], check=False)
print(f"Deleting container '{args.name}'...") print(f"Deleting container '{args.name}'...")
lxc_run([ "delete", args.name]) lxc_run(["delete", args.name])
print(f"Container '{args.name}' deleted successfully.") print(f"Container '{args.name}' deleted successfully.")
def set_label(name, feature, value="true"):
"""Set a user.lxcd.<feature> config key on a container."""
lxc_run([ "config", "set", name, f"user.lxcd.{feature}", value], check=False)
def container_exists(name):
result = lxc_subprocess(
["info", name],
capture_output=True, text=True, check=False
)
return result.returncode == 0
def handle_add(args): def handle_add(args):
host_username, uid, gid, host_home = get_real_user_info() host_username, uid, gid, host_home = get_real_user_info()
@ -388,9 +404,9 @@ def handle_add(args):
print(f"Error: Container '{args.name}' does not exist.", file=sys.stderr) print(f"Error: Container '{args.name}' does not exist.", file=sys.stderr)
sys.exit(1) sys.exit(1)
if not args.nested and not args.git and not args.certs and not args.aptcache and not args.ssh_client: if not args.nested and not args.aptcache and not args.map and not args.unmap:
print("Error: Specify at least one option to add.\n" print("Error: Specify at least one option to add.\n"
"Example: lxcd add <name> --git --nested", file=sys.stderr) "Example: lxcd add <name> --nested --aptcache", file=sys.stderr)
sys.exit(1) sys.exit(1)
state = lxc_subprocess( state = lxc_subprocess(
@ -405,58 +421,48 @@ def handle_add(args):
if args.nested: if args.nested:
print(f"Enabling nesting support for '{args.name}'...") print(f"Enabling nesting support for '{args.name}'...")
lxc_run([ "config", "set", args.name, "security.nesting", "true"]) lxc_run(["config", "set", args.name, "security.nesting", "true"])
lxc_run([ "config", "set", args.name, "security.syscalls.intercept.mknod", "true"]) lxc_run(["config", "set", args.name, "security.syscalls.intercept.mknod", "true"])
set_label(args.name, "nested") set_label(args.name, "nested")
if args.git:
was_running = not was_stopped
host_gitconfig = host_home / ".gitconfig"
if host_gitconfig.exists():
print(f"Mapping host .gitconfig into '{args.name}' as a device mount...")
lxc_subprocess(["stop", args.name], check=False)
lxc_subprocess(["config", "device", "remove", args.name, "gitconfig"],
capture_output=True, text=True, check=False)
lxc_run(["config", "device", "add", args.name, "gitconfig", "disk",
f"source={host_gitconfig}",
f"path=/home/{host_username}/.gitconfig",
"shift=true"])
set_label(args.name, "git")
if was_running:
lxc_run([ "start", args.name])
lxc_run([ "exec", args.name, "--", "cloud-init", "status", "--wait"], check=False)
else:
print(f"Warning: Host ~/.gitconfig not found. Skipping git config mapping.")
if args.certs:
host_certs = Path("/etc/ssl/certs/ca-certificates.crt")
if host_certs.exists():
print(f"Mounting host ca-certificates bundle into '{args.name}'...")
lxc_subprocess(["config", "device", "remove", args.name, "host-certs"],
capture_output=True, text=True, check=False)
lxc_run(["config", "device", "add", args.name, "host-certs", "disk",
f"source={host_certs}",
f"path={host_certs}",
"shift=true"])
set_label(args.name, "certs")
else:
print("Warning: Host cert bundle path not found. Skipping certificate configuration.")
if args.aptcache: if args.aptcache:
print(f"Configuring APT proxy to {args.aptcache} inside '{args.name}'...") print(f"Configuring APT proxy to {args.aptcache} inside '{args.name}'...")
lxc_run([ "exec", args.name, "--", "mkdir", "-p", "/etc/apt/apt.conf.d"]) lxc_run(["exec", args.name, "--", "mkdir", "-p", "/etc/apt/apt.conf.d"])
lxc_run([ "exec", args.name, "--", "sh", "-c", lxc_run(["exec", args.name, "--", "sh", "-c",
f"echo 'Acquire::http::Proxy \"{args.aptcache}\";' > /etc/apt/apt.conf.d/00lxcd-proxy"]) f"echo 'Acquire::http::Proxy \"{args.aptcache}\";' > /etc/apt/apt.conf.d/00lxcd-proxy"])
set_label(args.name, "aptcache") set_label(args.name, "aptcache")
if args.ssh_client: if args.map:
print(f"Installing SSH client and setting up ~/.ssh inside '{args.name}'...") cfg = load_config()
lxc_run([ "exec", args.name, "--", "env", "DEBIAN_FRONTEND=noninteractive", "apt-get", "update", "-y"]) paths = cfg.get(args.map)
lxc_run([ "exec", args.name, "--", "env", "DEBIAN_FRONTEND=noninteractive", "apt-get", "install", "-y", "openssh-client"]) if paths:
lxc_run([ "exec", args.name, "--", "mkdir", "-p", f"/home/{host_username}/.ssh"]) for idx, path in enumerate(paths):
lxc_run([ "exec", args.name, "--", "chmod", "700", f"/home/{host_username}/.ssh"]) host_path = host_home / path
lxc_run([ "exec", args.name, "--", "chown", f"{host_username}:{host_username}", f"/home/{host_username}/.ssh"]) if host_path.exists():
set_label(args.name, "ssh-client") container_path = f"/home/{host_username}/{path}"
lxc_subprocess(["config", "device", "remove", args.name, f"map-{args.map}-{idx}"],
capture_output=True, text=True, check=False)
lxc_run(["config", "device", "add", args.name, f"map-{args.map}-{idx}", "disk",
f"source={host_path}",
f"path={container_path}",
"shift=true"])
print(f"Mapped {host_path} -> {container_path}")
else:
print(f"Warning: {host_path} not found, skipping", file=sys.stderr)
set_label(args.name, f"map.{args.map}")
else:
print(f"Warning: Section '{args.map}' not found in ~/.lxcdrc", file=sys.stderr)
if args.unmap:
idx = 0
while True:
result = lxc_subprocess(["config", "device", "remove", args.name, f"map-{args.unmap}-{idx}"],
capture_output=True, text=True, check=False)
if result.returncode != 0:
break
idx += 1
set_label(args.name, f"map.{args.unmap}", "")
print(f"Unmapped section '{args.unmap}'")
if was_stopped: if was_stopped:
print(f"Returning '{args.name}' to its original stopped state...") print(f"Returning '{args.name}' to its original stopped state...")
@ -464,33 +470,26 @@ def handle_add(args):
print(f"\nSuccessfully updated container '{args.name}' with requested features!") print(f"\nSuccessfully updated container '{args.name}' with requested features!")
def handle_rename(args): def handle_rename(args):
print(f"Preparing to rename '{args.old_name}' to '{args.new_name}'...") 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}'...") print(f"Stopping '{args.old_name}'...")
# Using stdout/stderr redirection internally to hide the error if it's already stopped
lxc_subprocess( lxc_subprocess(
["stop", args.old_name], ["stop", args.old_name],
stdout=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL stderr=subprocess.DEVNULL
) )
# 2. Execute the rename lxc_run(["rename", args.old_name, args.new_name])
lxc_run([ "rename", args.old_name, args.new_name])
# 3. Restart the container under its new name
print(f"Starting renamed container '{args.new_name}'...") print(f"Starting renamed container '{args.new_name}'...")
lxc_run([ "start", args.new_name]) lxc_run(["start", args.new_name])
lxc_run(["exec", args.new_name, "--", "cloud-init", "status", "--wait"], check=False)
# 4. Wait for it to stabilize
lxc_run([ "exec", args.new_name, "--", "cloud-init", "status", "--wait"], check=False)
print(f"Container successfully renamed to '{args.new_name}'!") print(f"Container successfully renamed to '{args.new_name}'!")
def main(): def main():
parser = argparse.ArgumentParser(description="lxcd: A Distrobox-like wrapper for LXD (Sudo Optimized)") parser = argparse.ArgumentParser(description="lxcd: A Distrobox-like wrapper for LXD containers")
parser.add_argument("-v", "--verbose", action="store_true", help="Show verbose command output") parser.add_argument("-v", "--verbose", action="store_true", help="Show verbose command output")
subparsers = parser.add_subparsers(dest="command", required=True) subparsers = parser.add_subparsers(dest="command", required=True)
@ -499,12 +498,9 @@ def main():
parser_create.add_argument("name", help="Name of the 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("-i", "--image", default="ubuntu:", help="LXD image to use (defaults to 'ubuntu:')")
parser_create.add_argument("-m", "--minimal", action="store_true", help="Use ubuntu-minimal: image instead") parser_create.add_argument("-m", "--minimal", action="store_true", help="Use ubuntu-minimal: image instead")
parser_create.add_argument("-w", "--workspace", help="Workspace directory name (default: Coder or ~/.lxcdrc [workspace])")
parser_create.add_argument("--nested", action="store_true", help="Enable nesting") 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")
parser_create.add_argument("--certs", action="store_true", help="Mount host CA certificates bundle")
parser_create.add_argument("--aptcache", help="URL for an APT cache/proxy (e.g. http://10.0.0.1:3142)") parser_create.add_argument("--aptcache", help="URL for an APT cache/proxy (e.g. http://10.0.0.1:3142)")
parser_create.add_argument("--ssh-client", action="store_true",
help="Install SSH client and set up ~/.ssh for remote access from within the container")
# Clone command # Clone command
parser_clone = subparsers.add_parser("clone", help="Clone an existing LXD container") parser_clone = subparsers.add_parser("clone", help="Clone an existing LXD container")
@ -543,11 +539,9 @@ def main():
parser_add = subparsers.add_parser("add", help="Add features to an existing container") parser_add = subparsers.add_parser("add", help="Add features to an existing container")
parser_add.add_argument("name", help="Name of the container") parser_add.add_argument("name", help="Name of the container")
parser_add.add_argument("--nested", action="store_true", help="Enable nesting") parser_add.add_argument("--nested", action="store_true", help="Enable nesting")
parser_add.add_argument("--git", action="store_true", help="Map host ~/.gitconfig into the container")
parser_add.add_argument("--certs", action="store_true", help="Mount host CA certificates bundle")
parser_add.add_argument("--aptcache", help="URL for an APT cache/proxy (e.g. http://10.0.0.1:3142)") parser_add.add_argument("--aptcache", help="URL for an APT cache/proxy (e.g. http://10.0.0.1:3142)")
parser_add.add_argument("--ssh-client", action="store_true", parser_add.add_argument("--map", help="Map paths from a ~/.lxcdrc section into the container")
help="Install SSH client and set up ~/.ssh for remote access from within the container") parser_add.add_argument("--unmap", help="Remove a previously mapped section")
args = parser.parse_args() args = parser.parse_args()
@ -577,5 +571,3 @@ def main():
if __name__ == "__main__": if __name__ == "__main__":
main() main()

27
snap/snapcraft.yaml Normal file
View File

@ -0,0 +1,27 @@
name: lxcd
base: core22
version: git
summary: A Distrobox-like wrapper for LXD
description: |
lxcd provides a Distrobox-like experience using LXD containers.
It creates, manages, and enters LXD containers with features
like nesting, host CA certificate mounts, APT caching, and
SSH client setup.
grade: stable
confinement: classic
apps:
lxcd:
command: bin/lxcd
completer: lxcd.completion
parts:
lxcd:
plugin: dump
source: .
organize:
lxcd.py: bin/lxcd
prime:
- bin/lxcd
- lxcd.completion