massive amazing changes
This commit is contained in:
parent
1a5ac06ec0
commit
eddf850c4f
|
|
@ -4,7 +4,9 @@ _lxcd_completion() {
|
||||||
local cur prev words cword
|
local cur prev words cword
|
||||||
_init_completion || return
|
_init_completion || return
|
||||||
|
|
||||||
local commands="create clone rename enter list delete add stop start restart"
|
local commands="create clone rename enter list delete stop start restart"
|
||||||
|
local init_opts="-f --force -w --workspace -e --edit -p --packages -n --nested -i --image --aptcache --ssh"
|
||||||
|
local create_opts="--nested --aptcache -i --image -w --workspace -p --packages --ssh"
|
||||||
|
|
||||||
if [ $cword -eq 1 ]; then
|
if [ $cword -eq 1 ]; then
|
||||||
COMPREPLY=($(compgen -W "-v --verbose ${commands}" -- "$cur"))
|
COMPREPLY=($(compgen -W "-v --verbose ${commands}" -- "$cur"))
|
||||||
|
|
@ -24,9 +26,15 @@ _lxcd_completion() {
|
||||||
local containers=$($LXC list user.lxcd=true -c n --format csv 2>/dev/null | tr '\n' ' ')
|
local containers=$($LXC list user.lxcd=true -c n --format csv 2>/dev/null | tr '\n' ' ')
|
||||||
|
|
||||||
case "${subcommand}" in
|
case "${subcommand}" in
|
||||||
|
init)
|
||||||
|
if [[ "$cur" == -* ]]; then
|
||||||
|
COMPREPLY=($(compgen -W "${init_opts}" -- "$cur"))
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
|
||||||
create)
|
create)
|
||||||
if [[ "$cur" == -* ]]; then
|
if [[ "$cur" == -* ]]; then
|
||||||
COMPREPLY=($(compgen -W "--nested --aptcache -i --image -m --minimal -w --workspace" -- "$cur"))
|
COMPREPLY=($(compgen -W "${create_opts}" -- "$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
|
||||||
|
|
@ -44,15 +52,17 @@ _lxcd_completion() {
|
||||||
fi
|
fi
|
||||||
;;
|
;;
|
||||||
|
|
||||||
enter|delete|stop|start|restart)
|
enter)
|
||||||
COMPREPLY=($(compgen -W "${containers}" -- "$cur"))
|
|
||||||
;;
|
|
||||||
|
|
||||||
add)
|
|
||||||
if [ $cword -eq 2 ]; then
|
if [ $cword -eq 2 ]; then
|
||||||
COMPREPLY=($(compgen -W "${containers}" -- "$cur"))
|
COMPREPLY=($(compgen -W "${containers}" -- "$cur"))
|
||||||
elif [[ "$cur" == -* ]]; then
|
fi
|
||||||
COMPREPLY=($(compgen -W "--nested --aptcache --map --unmap" -- "$cur"))
|
;;
|
||||||
|
|
||||||
|
delete|stop|start|restart)
|
||||||
|
if [[ "$cur" == -* ]]; then
|
||||||
|
COMPREPLY=($(compgen -W "-a --all" -- "$cur"))
|
||||||
|
else
|
||||||
|
COMPREPLY=($(compgen -W "${containers}" -- "$cur"))
|
||||||
fi
|
fi
|
||||||
;;
|
;;
|
||||||
|
|
||||||
|
|
|
||||||
503
lxcd.py
503
lxcd.py
|
|
@ -4,7 +4,6 @@ import os
|
||||||
import re
|
import re
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
import time
|
|
||||||
import json
|
import json
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
@ -15,27 +14,46 @@ def config_path():
|
||||||
home = Path(f"/home/{sudo_user}") if sudo_user else Path.home()
|
home = Path(f"/home/{sudo_user}") if sudo_user else Path.home()
|
||||||
return home / ".lxcdrc"
|
return home / ".lxcdrc"
|
||||||
|
|
||||||
def ensure_config():
|
def ensure_config(force=False, workspace="Coder", packages=None, nested=False,
|
||||||
|
image=None, aptcache=None, ssh=None):
|
||||||
path = config_path()
|
path = config_path()
|
||||||
if path.exists():
|
if path.exists() and not force:
|
||||||
return
|
return
|
||||||
|
|
||||||
config_content = """# lxcd configuration
|
packages = packages or []
|
||||||
|
packages_block = "\n".join(packages) if packages else ""
|
||||||
|
nested_value = "true" if nested else ""
|
||||||
|
image_value = image or "ubuntu:"
|
||||||
|
aptcache_value = aptcache or ""
|
||||||
|
ssh_value = ssh or ""
|
||||||
|
|
||||||
|
config_content = f"""# lxcd configuration
|
||||||
#
|
#
|
||||||
# [workspace] sets the default workspace directory name used by 'lxcd create'
|
# [options] holds key=value defaults applied by 'lxcd create'.
|
||||||
# and 'lxcd enter'.
|
# workspace: default workspace directory name
|
||||||
# Additional sections (e.g. [git], [opencode]) define paths to mount
|
# image: LXD image to use
|
||||||
# into containers via 'lxcd add --map=<section>'.
|
# nested: enable nesting (e.g. true) or leave blank
|
||||||
|
# aptcache: APT cache/proxy URL or leave blank
|
||||||
|
# ssh: SSH public key to authorize for the container user; blank
|
||||||
|
# disables the SSH server
|
||||||
|
#
|
||||||
|
# [packages] lists packages installed by default in new containers via
|
||||||
|
# 'lxcd create'.
|
||||||
|
#
|
||||||
|
# [mappings] lists paths (relative to ~/) mounted into containers when they
|
||||||
|
# are entered via 'lxcd enter'.
|
||||||
|
|
||||||
[workspace]
|
[options]
|
||||||
Code
|
workspace={workspace}
|
||||||
|
image={image_value}
|
||||||
|
nested={nested_value}
|
||||||
|
aptcache={aptcache_value}
|
||||||
|
ssh={ssh_value}
|
||||||
|
|
||||||
[git]
|
[packages]
|
||||||
.gitconfig
|
{packages_block}
|
||||||
|
|
||||||
[opencode]
|
[mappings]
|
||||||
.config/opencode
|
|
||||||
.local/share/opencode
|
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
path.write_text(config_content)
|
path.write_text(config_content)
|
||||||
|
|
@ -43,6 +61,12 @@ Code
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Warning: Could not create config at {path}: {e}", file=sys.stderr)
|
print(f"Warning: Could not create config at {path}: {e}", file=sys.stderr)
|
||||||
|
|
||||||
|
def require_config():
|
||||||
|
path = config_path()
|
||||||
|
if not path.exists():
|
||||||
|
print(f"Error: Config file {path} not found. Run 'lxcd init' to create it.", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
def load_config():
|
def load_config():
|
||||||
path = config_path()
|
path = config_path()
|
||||||
if not path.exists():
|
if not path.exists():
|
||||||
|
|
@ -50,8 +74,25 @@ def load_config():
|
||||||
|
|
||||||
config = {}
|
config = {}
|
||||||
section = None
|
section = None
|
||||||
values = []
|
entries = []
|
||||||
|
|
||||||
|
def flush():
|
||||||
|
nonlocal entries
|
||||||
|
if section is None:
|
||||||
|
return
|
||||||
|
kv = {}
|
||||||
|
is_dict = False
|
||||||
|
for e in entries:
|
||||||
|
if "=" in e:
|
||||||
|
key, _, value = e.partition("=")
|
||||||
|
kv[key.strip()] = value.strip()
|
||||||
|
is_dict = True
|
||||||
|
if is_dict:
|
||||||
|
config[section] = kv
|
||||||
|
else:
|
||||||
|
config[section] = list(entries)
|
||||||
|
entries = []
|
||||||
|
|
||||||
try:
|
try:
|
||||||
for line in path.read_text().splitlines():
|
for line in path.read_text().splitlines():
|
||||||
line = line.strip()
|
line = line.strip()
|
||||||
|
|
@ -60,15 +101,13 @@ def load_config():
|
||||||
|
|
||||||
m = re.match(r"^\[(.+)\]$", line)
|
m = re.match(r"^\[(.+)\]$", line)
|
||||||
if m:
|
if m:
|
||||||
if section is not None:
|
flush()
|
||||||
config[section] = values
|
|
||||||
section = m.group(1)
|
section = m.group(1)
|
||||||
values = []
|
entries = []
|
||||||
elif section is not None:
|
elif section is not None:
|
||||||
values.append(line)
|
entries.append(line)
|
||||||
|
|
||||||
if section is not None:
|
flush()
|
||||||
config[section] = values
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Warning: Could not read config from {path}: {e}", file=sys.stderr)
|
print(f"Warning: Could not read config from {path}: {e}", file=sys.stderr)
|
||||||
|
|
||||||
|
|
@ -162,47 +201,82 @@ def handle_list(args):
|
||||||
|
|
||||||
containers = json.loads(result.stdout)
|
containers = json.loads(result.stdout)
|
||||||
|
|
||||||
print(f"\n{'CONTAINER NAME':<25} {'STATUS':<12} {'IPV4 ADDRESS':<25} {'ACTIVE OPTIONS':<25}")
|
COLUMNS = {
|
||||||
print("-" * 87)
|
"name": ("CONTAINER NAME", 25),
|
||||||
|
"status": ("STATUS", 12),
|
||||||
|
"ipv4": ("IPV4 ADDRESS", 22),
|
||||||
|
"image": ("IMAGE", 18),
|
||||||
|
"packages": ("PACKAGES", 25),
|
||||||
|
"opts": ("ACTIVE OPTIONS", 25),
|
||||||
|
}
|
||||||
|
|
||||||
|
if args.brief is not None:
|
||||||
|
cols = [c.strip() for c in args.brief.split(",")] if args.brief.strip() else ["ipv4"]
|
||||||
|
else:
|
||||||
|
cols = ["name", "status", "ipv4", "image", "packages", "opts"]
|
||||||
|
|
||||||
|
if "name" not in cols:
|
||||||
|
cols = ["name", *cols]
|
||||||
|
|
||||||
|
seen = set()
|
||||||
|
cols = [c for c in cols if not (c in seen or seen.add(c))]
|
||||||
|
|
||||||
|
chosen = {c: COLUMNS[c] for c in cols if c in COLUMNS}
|
||||||
|
|
||||||
|
header = " ".join(f"{label:<{w}}" for label, w in chosen.values())
|
||||||
|
print(f"\n{header}")
|
||||||
|
print("-" * sum(w for _, w in chosen.values()))
|
||||||
|
|
||||||
|
def get_config(name, key):
|
||||||
|
return lxc_subprocess(
|
||||||
|
["config", "get", name, key],
|
||||||
|
capture_output=True, text=True, check=False
|
||||||
|
).stdout.strip()
|
||||||
|
|
||||||
count = 0
|
count = 0
|
||||||
cfg = load_config()
|
cfg = load_config()
|
||||||
|
map_sections = [k for k in cfg.keys() if k not in ("options", "packages")]
|
||||||
|
|
||||||
for c in containers:
|
for container in containers:
|
||||||
name = c["name"]
|
name = container["name"]
|
||||||
status = c["status"]
|
status = container["status"]
|
||||||
ipv4 = "N/A"
|
ipv4 = "N/A"
|
||||||
state = c.get("state")
|
container_state = container.get("state")
|
||||||
if state:
|
if container_state:
|
||||||
networks = state.get("network") or {}
|
networks = container_state.get("network") or {}
|
||||||
eth0 = networks.get("eth0", {})
|
eth0 = networks.get("eth0", {})
|
||||||
addrs = [a["address"] for a in eth0.get("addresses", [])
|
addrs = [a["address"] for a in eth0.get("addresses", [])
|
||||||
if a.get("family") == "inet"]
|
if a.get("family") == "inet"]
|
||||||
if addrs:
|
if addrs:
|
||||||
ipv4 = addrs[0]
|
ipv4 = addrs[0]
|
||||||
|
|
||||||
def get_config(key):
|
cells = {
|
||||||
return lxc_subprocess(
|
"name": name,
|
||||||
["config", "get", name, key],
|
"status": status,
|
||||||
capture_output=True, text=True, check=False
|
"ipv4": ipv4,
|
||||||
).stdout.strip()
|
}
|
||||||
|
|
||||||
active_opts = []
|
if "opts" in cols:
|
||||||
|
active = []
|
||||||
|
if get_config(name, "security.nesting") == "true":
|
||||||
|
active.append("nesting")
|
||||||
|
if get_config(name, "user.lxcd.aptcache") == "true":
|
||||||
|
active.append("aptcache")
|
||||||
|
if get_config(name, "user.lxcd.ssh") == "true":
|
||||||
|
active.append("ssh")
|
||||||
|
for section in map_sections:
|
||||||
|
if get_config(name, f"user.lxcd.map.{section}") == "true":
|
||||||
|
active.append(section)
|
||||||
|
cells["opts"] = ", ".join(active) if active else "none"
|
||||||
|
|
||||||
if get_config("security.nesting") == "true":
|
if "image" in cols:
|
||||||
active_opts.append("nesting")
|
cells["image"] = get_config(name, "user.lxcd.image") or "N/A"
|
||||||
|
|
||||||
if get_config("user.lxcd.aptcache") == "true":
|
if "packages" in cols:
|
||||||
active_opts.append("aptcache")
|
cells["packages"] = get_config(name, "user.lxcd.packages") or "none"
|
||||||
|
|
||||||
for section in cfg.keys():
|
row = " ".join(f"{cells[c]:<{w}}" for c, (label, w) in chosen.items())
|
||||||
if section == "workspace":
|
print(row)
|
||||||
continue
|
|
||||||
if get_config(f"user.lxcd.map.{section}") == "true":
|
|
||||||
active_opts.append(section)
|
|
||||||
|
|
||||||
opts_str = ", ".join(active_opts) if active_opts else "none"
|
|
||||||
print(f"{name:<25} {status:<12} {ipv4:<25} {opts_str:<25}")
|
|
||||||
count += 1
|
count += 1
|
||||||
|
|
||||||
if count == 0:
|
if count == 0:
|
||||||
|
|
@ -220,14 +294,38 @@ def handle_clone(args):
|
||||||
|
|
||||||
print(f"Container '{args.name}' cloned successfully from '{args.source}'!")
|
print(f"Container '{args.name}' cloned successfully from '{args.source}'!")
|
||||||
|
|
||||||
|
def handle_init(args):
|
||||||
|
if args.edit:
|
||||||
|
path = config_path()
|
||||||
|
if not path.exists():
|
||||||
|
packages = [p.strip() for p in args.packages.split(",")] if args.packages else None
|
||||||
|
ensure_config(force=True, workspace=args.workspace, packages=packages,
|
||||||
|
nested=args.nested, image=args.image,
|
||||||
|
aptcache=args.aptcache, ssh=args.ssh)
|
||||||
|
editor = os.environ.get("EDITOR", os.environ.get("VISUAL", "vi"))
|
||||||
|
subprocess.run([editor, str(path)])
|
||||||
|
return
|
||||||
|
if config_path().exists():
|
||||||
|
if args.force:
|
||||||
|
print(f"Overwriting existing config at {config_path()}...")
|
||||||
|
else:
|
||||||
|
print(f"Error: Config file {config_path()} already exists. Use --force to overwrite.", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
packages = [p.strip() for p in args.packages.split(",")] if args.packages else None
|
||||||
|
ensure_config(force=args.force, workspace=args.workspace, packages=packages,
|
||||||
|
nested=args.nested, image=args.image,
|
||||||
|
aptcache=args.aptcache, ssh=args.ssh)
|
||||||
|
|
||||||
def handle_create(args):
|
def handle_create(args):
|
||||||
ensure_config()
|
|
||||||
cfg = load_config()
|
cfg = load_config()
|
||||||
workspace = args.workspace or (cfg.get("workspace")[0] if cfg.get("workspace") else "Coder")
|
options = cfg.get("options") if isinstance(cfg.get("options"), dict) else {}
|
||||||
|
workspace = args.workspace or options.get("workspace") or "Coder"
|
||||||
|
nested = args.nested or bool(options.get("nested"))
|
||||||
|
aptcache = args.aptcache or options.get("aptcache") or None
|
||||||
|
image = args.image or options.get("image") or "ubuntu:"
|
||||||
|
ssh_key = args.ssh or options.get("ssh") or None
|
||||||
|
|
||||||
host_username, uid, gid, host_home = get_real_user_info()
|
host_username, uid, gid, host_home = get_real_user_info()
|
||||||
if args.minimal:
|
|
||||||
args.image = "ubuntu-minimal:"
|
|
||||||
host_timezone = detect_timezone()
|
host_timezone = detect_timezone()
|
||||||
host_locale = detect_locale()
|
host_locale = detect_locale()
|
||||||
|
|
||||||
|
|
@ -239,17 +337,37 @@ def handle_create(args):
|
||||||
host_coder_dir.mkdir(parents=True, exist_ok=True)
|
host_coder_dir.mkdir(parents=True, exist_ok=True)
|
||||||
os.chown(host_coder_dir, uid, gid)
|
os.chown(host_coder_dir, uid, gid)
|
||||||
|
|
||||||
print(f"Creating LXD container '{args.name}' from image '{args.image}'...")
|
print(f"Creating LXD container '{args.name}' from image '{image}'...")
|
||||||
|
|
||||||
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 \"{aptcache}\";' > /etc/apt/apt.conf.d/00lxcd-proxy\n"
|
||||||
) if args.aptcache else ""
|
) if aptcache else ""
|
||||||
|
|
||||||
|
if args.packages:
|
||||||
|
packages = [p.strip() for p in args.packages.split(",") if p.strip()]
|
||||||
|
else:
|
||||||
|
packages = cfg.get("packages") or []
|
||||||
|
if ssh_key and not any("openssh-server" in p for p in packages):
|
||||||
|
packages = [*packages, "openssh-server"]
|
||||||
|
|
||||||
|
packages_cmd = ""
|
||||||
|
if packages:
|
||||||
|
pkg_list = "\n".join(f" - {pkg}" for pkg in packages)
|
||||||
|
packages_cmd = f"packages:\n{pkg_list}\n"
|
||||||
|
|
||||||
|
if ssh_key:
|
||||||
|
ssh_auth_cmd = f" ssh_authorized_keys:\n - {ssh_key}\n"
|
||||||
|
ssh_runcmd = " - systemctl enable --now sshd || systemctl enable --now ssh || true"
|
||||||
|
print("Enabling SSH server and authorizing provided public key...")
|
||||||
|
else:
|
||||||
|
ssh_auth_cmd = ""
|
||||||
|
ssh_runcmd = " - systemctl disable --now sshd 2>/dev/null || systemctl disable --now ssh 2>/dev/null || true"
|
||||||
|
|
||||||
cloud_config = f"""#cloud-config
|
cloud_config = f"""#cloud-config
|
||||||
timezone: {host_timezone}
|
timezone: {host_timezone}
|
||||||
locale: {host_locale}
|
locale: {host_locale}
|
||||||
users:
|
{packages_cmd}users:
|
||||||
- name: {host_username}
|
- name: {host_username}
|
||||||
gecos: lxcd user
|
gecos: lxcd user
|
||||||
primary_group: {host_username}
|
primary_group: {host_username}
|
||||||
|
|
@ -258,7 +376,7 @@ users:
|
||||||
sudo: ALL=(ALL) NOPASSWD:ALL
|
sudo: ALL=(ALL) NOPASSWD:ALL
|
||||||
lock_passwd: true
|
lock_passwd: true
|
||||||
homedir: /home/{host_username}
|
homedir: /home/{host_username}
|
||||||
|
{ssh_auth_cmd}
|
||||||
groups:
|
groups:
|
||||||
- {host_username}
|
- {host_username}
|
||||||
|
|
||||||
|
|
@ -272,13 +390,15 @@ 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}
|
||||||
- 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"
|
{ssh_runcmd}
|
||||||
{apt_proxy_cmd}"""
|
{apt_proxy_cmd}"""
|
||||||
|
|
||||||
lxc_run(["init", args.image, args.name])
|
lxc_run(["init", image, args.name])
|
||||||
|
|
||||||
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"])
|
||||||
|
lxc_run(["config", "set", args.name, "user.lxcd.image", image])
|
||||||
|
lxc_run(["config", "set", args.name, "user.lxcd.packages", ",".join(packages)])
|
||||||
|
|
||||||
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])
|
||||||
|
|
@ -289,26 +409,50 @@ runcmd:
|
||||||
f"path={container_coder_path}",
|
f"path={container_coder_path}",
|
||||||
"shift=true"])
|
"shift=true"])
|
||||||
|
|
||||||
if args.nested:
|
if 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")
|
||||||
|
|
||||||
if args.aptcache:
|
if aptcache:
|
||||||
print(f"APT cache/proxy will be configured to {args.aptcache} via cloud-init...")
|
print(f"APT cache/proxy will be configured to {aptcache} via cloud-init...")
|
||||||
set_label(args.name, "aptcache")
|
set_label(args.name, "aptcache")
|
||||||
|
|
||||||
|
if ssh_key:
|
||||||
|
set_label(args.name, "ssh")
|
||||||
|
|
||||||
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 apply_mappings(name, host_username, host_home):
|
||||||
|
cfg = load_config()
|
||||||
|
paths = cfg.get("mappings")
|
||||||
|
if not isinstance(paths, list) or not paths:
|
||||||
|
return
|
||||||
|
for idx, path in enumerate(paths):
|
||||||
|
host_path = host_home / path
|
||||||
|
container_path = f"/home/{host_username}/{path}"
|
||||||
|
lxc_subprocess(["config", "device", "remove", name, f"map-{idx}"],
|
||||||
|
capture_output=True, text=True, check=False)
|
||||||
|
if host_path.exists():
|
||||||
|
lxc_run(["config", "device", "add", name, f"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(name, "map.mappings")
|
||||||
|
|
||||||
def handle_enter(args):
|
def handle_enter(args):
|
||||||
cfg = load_config()
|
cfg = load_config()
|
||||||
workspace = (cfg.get("workspace")[0] if cfg.get("workspace") else "Coder")
|
options = cfg.get("options") if isinstance(cfg.get("options"), dict) else {}
|
||||||
_, uid, gid, host_home = get_real_user_info()
|
workspace = options.get("workspace") or "Coder"
|
||||||
|
host_username, uid, gid, host_home = get_real_user_info()
|
||||||
|
|
||||||
state = lxc_subprocess(
|
state = lxc_subprocess(
|
||||||
["info", args.name],
|
["info", args.name],
|
||||||
|
|
@ -319,6 +463,8 @@ def handle_enter(args):
|
||||||
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.")
|
||||||
|
|
||||||
|
apply_mappings(args.name, host_username, host_home)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
result = lxc_subprocess(
|
result = lxc_subprocess(
|
||||||
|
|
@ -343,114 +489,71 @@ def handle_enter(args):
|
||||||
if display:
|
if display:
|
||||||
env_prefix += f"DISPLAY={display} "
|
env_prefix += f"DISPLAY={display} "
|
||||||
|
|
||||||
shell_cmd = "exec /bin/bash --login"
|
|
||||||
exec_cmd = [
|
exec_cmd = [
|
||||||
"lxc", "exec", args.name,
|
"lxc", "exec", args.name,
|
||||||
"--cwd", target_cwd,
|
"--cwd", target_cwd,
|
||||||
"--user", str(uid),
|
"--user", str(uid),
|
||||||
"--group", str(gid),
|
"--group", str(gid),
|
||||||
"--",
|
"--",
|
||||||
"sh", "-c", f"env {env_prefix} sh -c '{shell_cmd}'"
|
"sh", "-c", f"env {env_prefix} sh -c 'exec /bin/bash --login'"
|
||||||
]
|
]
|
||||||
|
|
||||||
os.execvp("lxc", exec_cmd)
|
os.execvp("lxc", exec_cmd)
|
||||||
|
|
||||||
|
def resolve_targets(args):
|
||||||
|
try:
|
||||||
|
names = list(args.names)
|
||||||
|
except AttributeError:
|
||||||
|
names = [args.name]
|
||||||
|
if args.all:
|
||||||
|
result = lxc_subprocess(
|
||||||
|
["list", "user.lxcd=true", "-c", "n", "--format", "csv"],
|
||||||
|
capture_output=True, text=True, check=False
|
||||||
|
)
|
||||||
|
if result.returncode != 0:
|
||||||
|
print("Error communicating with LXD.", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
names += [n.strip() for n in result.stdout.splitlines() if n.strip()]
|
||||||
|
|
||||||
|
seen = set()
|
||||||
|
unique = []
|
||||||
|
for n in names:
|
||||||
|
if n not in seen:
|
||||||
|
seen.add(n)
|
||||||
|
unique.append(n)
|
||||||
|
|
||||||
|
if not unique:
|
||||||
|
print("Error: Specify at least one container name or use --all.", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
return unique
|
||||||
|
|
||||||
def handle_stop(args):
|
def handle_stop(args):
|
||||||
print(f"Stopping container '{args.name}'...")
|
for name in resolve_targets(args):
|
||||||
lxc_run(["stop", args.name], check=False)
|
print(f"Stopping container '{name}'...")
|
||||||
print(f"Container '{args.name}' stopped.")
|
lxc_run(["stop", name], check=False)
|
||||||
|
print(f"Container '{name}' stopped.")
|
||||||
|
|
||||||
def handle_start(args):
|
def handle_start(args):
|
||||||
print(f"Starting container '{args.name}'...")
|
for name in resolve_targets(args):
|
||||||
lxc_run(["start", args.name])
|
print(f"Starting container '{name}'...")
|
||||||
lxc_run(["exec", args.name, "--", "cloud-init", "status", "--wait"], check=False)
|
lxc_run(["start", name])
|
||||||
print(f"Container '{args.name}' started.")
|
lxc_run(["exec", name, "--", "cloud-init", "status", "--wait"], check=False)
|
||||||
|
print(f"Container '{name}' started.")
|
||||||
|
|
||||||
def handle_restart(args):
|
def handle_restart(args):
|
||||||
print(f"Restarting container '{args.name}'...")
|
for name in resolve_targets(args):
|
||||||
lxc_run(["restart", args.name], check=False)
|
print(f"Restarting container '{name}'...")
|
||||||
lxc_run(["exec", args.name, "--", "cloud-init", "status", "--wait"], check=False)
|
lxc_run(["restart", name], check=False)
|
||||||
print(f"Container '{args.name}' restarted.")
|
lxc_run(["exec", name, "--", "cloud-init", "status", "--wait"], check=False)
|
||||||
|
print(f"Container '{name}' restarted.")
|
||||||
|
|
||||||
def handle_delete(args):
|
def handle_delete(args):
|
||||||
print(f"Stopping container '{args.name}'...")
|
for name in resolve_targets(args):
|
||||||
lxc_run(["stop", args.name], check=False)
|
print(f"Stopping container '{name}'...")
|
||||||
print(f"Deleting container '{args.name}'...")
|
lxc_run(["stop", name], check=False)
|
||||||
lxc_run(["delete", args.name])
|
print(f"Deleting container '{name}'...")
|
||||||
print(f"Container '{args.name}' deleted successfully.")
|
lxc_run(["delete", name])
|
||||||
|
print(f"Container '{name}' deleted successfully.")
|
||||||
def handle_add(args):
|
|
||||||
host_username, uid, gid, host_home = get_real_user_info()
|
|
||||||
|
|
||||||
if not container_exists(args.name):
|
|
||||||
print(f"Error: Container '{args.name}' does not exist.", file=sys.stderr)
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
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"
|
|
||||||
"Example: lxcd add <name> --nested --aptcache", file=sys.stderr)
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
state = lxc_subprocess(
|
|
||||||
["info", args.name],
|
|
||||||
capture_output=True, text=True, check=False
|
|
||||||
)
|
|
||||||
was_stopped = "Status: RUNNING" not in state.stdout
|
|
||||||
if was_stopped:
|
|
||||||
print(f"Starting container '{args.name}' temporarily to apply system operations...")
|
|
||||||
lxc_subprocess(["start", args.name], check=False)
|
|
||||||
time.sleep(2)
|
|
||||||
|
|
||||||
if args.nested:
|
|
||||||
print(f"Enabling nesting support for '{args.name}'...")
|
|
||||||
lxc_run(["config", "set", args.name, "security.nesting", "true"])
|
|
||||||
lxc_run(["config", "set", args.name, "security.syscalls.intercept.mknod", "true"])
|
|
||||||
set_label(args.name, "nested")
|
|
||||||
|
|
||||||
if args.aptcache:
|
|
||||||
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, "--", "sh", "-c",
|
|
||||||
f"echo 'Acquire::http::Proxy \"{args.aptcache}\";' > /etc/apt/apt.conf.d/00lxcd-proxy"])
|
|
||||||
set_label(args.name, "aptcache")
|
|
||||||
|
|
||||||
if args.map:
|
|
||||||
cfg = load_config()
|
|
||||||
paths = cfg.get(args.map)
|
|
||||||
if paths:
|
|
||||||
for idx, path in enumerate(paths):
|
|
||||||
host_path = host_home / path
|
|
||||||
if host_path.exists():
|
|
||||||
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:
|
|
||||||
print(f"Returning '{args.name}' to its original stopped state...")
|
|
||||||
lxc_subprocess(["stop", args.name], check=False)
|
|
||||||
|
|
||||||
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}'...")
|
||||||
|
|
@ -471,18 +574,57 @@ def handle_rename(args):
|
||||||
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 containers")
|
description = """lxcd: A Distrobox-like wrapper for LXD containers.
|
||||||
|
|
||||||
|
This tool simplifies managing LXD-based development environments.
|
||||||
|
It supports configuration-driven workspace names and dynamic path mapping
|
||||||
|
controlled by a ~/.lxcdrc configuration file.
|
||||||
|
|
||||||
|
Configuration file (~/.lxcdrc) format:
|
||||||
|
[options]
|
||||||
|
workspace=Coder
|
||||||
|
nested=
|
||||||
|
aptcache=
|
||||||
|
|
||||||
|
[packages]
|
||||||
|
vim
|
||||||
|
git
|
||||||
|
|
||||||
|
[mappings]
|
||||||
|
.gitconfig
|
||||||
|
|
||||||
|
Usage Workflow:
|
||||||
|
1. Create a container: lxcd create myenv
|
||||||
|
2. Enter container: lxcd enter myenv
|
||||||
|
(paths in [mappings] are mounted automatically on enter)"""
|
||||||
|
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description=description,
|
||||||
|
formatter_class=argparse.RawDescriptionHelpFormatter
|
||||||
|
)
|
||||||
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)
|
||||||
|
|
||||||
|
# Init command
|
||||||
|
parser_init = subparsers.add_parser("init", help="Write the ~/.lxcdrc config file")
|
||||||
|
parser_init.add_argument("-f", "--force", action="store_true", help="Overwrite an existing config file")
|
||||||
|
parser_init.add_argument("-w", "--workspace", default="Coder", help="Default workspace directory name (default: Coder)")
|
||||||
|
parser_init.add_argument("-e", "--edit", action="store_true", help="Edit the config file with $EDITOR (creates it first if missing)")
|
||||||
|
parser_init.add_argument("-p", "--packages", help="Comma-separated list of packages installed by default in new containers")
|
||||||
|
parser_init.add_argument("-n", "--nested", action="store_true", help="Enable nesting by default in new containers")
|
||||||
|
parser_init.add_argument("-i", "--image", help="LXD image to use by default in new containers (default: ubuntu:)")
|
||||||
|
parser_init.add_argument("--aptcache", help="URL for an APT cache/proxy stored as a default (e.g. http://10.0.0.1:3142)")
|
||||||
|
parser_init.add_argument("--ssh", help="SSH public key authorized for the container user by default")
|
||||||
|
|
||||||
# Create command
|
# Create command
|
||||||
parser_create = subparsers.add_parser("create", help="Create a new LXD container")
|
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("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", help="LXD image to use (default: ubuntu: or ~/.lxcdrc [options] image=)")
|
||||||
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: ~/.lxcdrc [options] workspace=)")
|
||||||
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("--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("-p", "--packages", help="Comma-separated packages to install (overrides ~/.lxcdrc [packages])")
|
||||||
|
parser_create.add_argument("--ssh", help="SSH public key to authorize (default: ~/.lxcdrc [options] ssh=; blank disables SSH)")
|
||||||
|
|
||||||
# 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")
|
||||||
|
|
@ -500,37 +642,40 @@ def main():
|
||||||
|
|
||||||
# List command
|
# List command
|
||||||
parser_list = subparsers.add_parser("list", aliases=["ls"], help="List all managed LXD containers")
|
parser_list = subparsers.add_parser("list", aliases=["ls"], help="List all managed LXD containers")
|
||||||
|
parser_list.add_argument("-b", "--brief", nargs="?", const="", metavar="COLS",
|
||||||
|
help="Only show name plus a comma-separated subset of status,ipv4,image,packages,opts (default: ipv4)")
|
||||||
|
|
||||||
# Stop command
|
# Stop command
|
||||||
parser_stop = subparsers.add_parser("stop", help="Stop a running container")
|
parser_stop = subparsers.add_parser("stop", help="Stop one or more running containers")
|
||||||
parser_stop.add_argument("name", help="Name of the container to stop")
|
parser_stop.add_argument("names", nargs="*", help="Names of containers to stop")
|
||||||
|
parser_stop.add_argument("-a", "--all", action="store_true", help="Stop all lxcd-managed containers")
|
||||||
|
|
||||||
# Start command
|
# Start command
|
||||||
parser_start = subparsers.add_parser("start", help="Start a stopped container")
|
parser_start = subparsers.add_parser("start", help="Start one or more stopped containers")
|
||||||
parser_start.add_argument("name", help="Name of the container to start")
|
parser_start.add_argument("names", nargs="*", help="Names of containers to start")
|
||||||
|
parser_start.add_argument("-a", "--all", action="store_true", help="Start all lxcd-managed containers")
|
||||||
|
|
||||||
# Restart command
|
# Restart command
|
||||||
parser_restart = subparsers.add_parser("restart", help="Restart a container")
|
parser_restart = subparsers.add_parser("restart", help="Restart one or more containers")
|
||||||
parser_restart.add_argument("name", help="Name of the container to restart")
|
parser_restart.add_argument("names", nargs="*", help="Names of containers to restart")
|
||||||
|
parser_restart.add_argument("-a", "--all", action="store_true", help="Restart all lxcd-managed containers")
|
||||||
|
|
||||||
# Delete command
|
# Delete command
|
||||||
parser_delete = subparsers.add_parser("delete", help="Delete an LXD container")
|
parser_delete = subparsers.add_parser("delete", help="Delete one or more LXD containers")
|
||||||
parser_delete.add_argument("name", help="Name of the container to delete")
|
parser_delete.add_argument("names", nargs="*", help="Names of containers to delete")
|
||||||
|
parser_delete.add_argument("-a", "--all", action="store_true", help="Delete all lxcd-managed containers")
|
||||||
# Add command
|
|
||||||
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("--nested", action="store_true", help="Enable nesting")
|
|
||||||
parser_add.add_argument("--aptcache", help="URL for an APT cache/proxy (e.g. http://10.0.0.1:3142)")
|
|
||||||
parser_add.add_argument("--map", help="Map paths from a ~/.lxcdrc section into the container")
|
|
||||||
parser_add.add_argument("--unmap", help="Remove a previously mapped section")
|
|
||||||
|
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
global VERBOSE
|
global VERBOSE
|
||||||
VERBOSE = args.verbose
|
VERBOSE = args.verbose
|
||||||
|
|
||||||
if args.command == "create":
|
if args.command != "init":
|
||||||
|
require_config()
|
||||||
|
|
||||||
|
if args.command == "init":
|
||||||
|
handle_init(args)
|
||||||
|
elif args.command == "create":
|
||||||
handle_create(args)
|
handle_create(args)
|
||||||
elif args.command == "clone":
|
elif args.command == "clone":
|
||||||
handle_clone(args)
|
handle_clone(args)
|
||||||
|
|
@ -548,8 +693,6 @@ def main():
|
||||||
handle_restart(args)
|
handle_restart(args)
|
||||||
elif args.command == "delete":
|
elif args.command == "delete":
|
||||||
handle_delete(args)
|
handle_delete(args)
|
||||||
elif args.command == "add":
|
|
||||||
handle_add(args)
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
main()
|
main()
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue