|
```bash bin/spawn.sh |
|
#!/bin/bash |
|
# spawn.sh - Create and manage systemd-nspawn containers in userspace |
|
# Usage: ./spawn.sh create <container-name> |
|
# ./spawn.sh start <container-name> |
|
# ./spawn.sh shell <container-name> |
|
# ./spawn.sh stop <container-name> |
|
# ./spawn.sh list |
|
|
|
set -e |
|
|
|
# Configuration |
|
CONTAINER_BASE="$HOME/.local/containers" |
|
CONTAINER_CONFIG="$HOME/.config/systemd-nspawn" |
|
|
|
# Colors for output |
|
RED='\033[0;31m' |
|
GREEN='\033[0;32m' |
|
YELLOW='\033[1;33m' |
|
NC='\033[0m' |
|
|
|
log_info() { echo -e "${GREEN}[INFO]${NC} $1"; } |
|
log_warn() { echo -e "${YELLOW}[WARN]${NC} $1"; } |
|
log_error() { echo -e "${RED}[ERROR]${NC} $1"; } |
|
|
|
check_dependencies() { |
|
local missing=() |
|
for cmd in pacstrap systemd-nspawn machinectl; do |
|
if ! command -v $cmd &> /dev/null; then |
|
missing+=($cmd) |
|
fi |
|
done |
|
|
|
if [ ${#missing[@]} -gt 0 ]; then |
|
log_error "Missing dependencies: ${missing[*]}" |
|
log_info "Install with: sudo pacman -S arch-install-scripts systemd-container" |
|
exit 1 |
|
fi |
|
} |
|
|
|
setup_directories() { |
|
mkdir -p "$CONTAINER_BASE" |
|
mkdir -p "$CONTAINER_CONFIG" |
|
log_info "Container base: $CONTAINER_BASE" |
|
} |
|
|
|
create_container() { |
|
local name=$1 |
|
local mount_src="${2:-}" |
|
local mount_dst="${3:-/mnt/shared}" |
|
local permission_arg="${4:-}" |
|
local container_path="$CONTAINER_BASE/$name" |
|
|
|
if [ -d "$container_path" ]; then |
|
log_error "Container '$name' already exists" |
|
exit 1 |
|
fi |
|
|
|
# Determine Mount Mode |
|
local mount_mode="ro" |
|
if [ "$permission_arg" == "--rw" ]; then |
|
mount_mode="rw" |
|
fi |
|
|
|
# Validate mount source if provided |
|
if [ -n "$mount_src" ]; then |
|
# Expand tilde to home directory |
|
mount_src="${mount_src/#\~/$HOME}" |
|
|
|
if [ ! -d "$mount_src" ]; then |
|
log_error "Mount source directory does not exist: $mount_src" |
|
exit 1 |
|
fi |
|
|
|
if [ "$mount_mode" == "rw" ]; then |
|
log_warn "Will mount (READ-WRITE): $mount_src -> $mount_dst" |
|
else |
|
log_info "Will mount (READ-ONLY): $mount_src -> $mount_dst" |
|
fi |
|
fi |
|
|
|
log_info "Creating container '$name' at $container_path" |
|
|
|
# Create container directory |
|
mkdir -p "$container_path" |
|
|
|
# Create mount point inside container |
|
if [ -n "$mount_src" ]; then |
|
mkdir -p "$container_path$mount_dst" |
|
fi |
|
|
|
# Bootstrap minimal Arch Linux |
|
log_info "Bootstrapping Arch Linux (requires sudo for pacstrap)..." |
|
local max_attempts=3 |
|
local attempt=1 |
|
|
|
while [ $attempt -le $max_attempts ]; do |
|
log_info "Attempt $attempt of $max_attempts..." |
|
if sudo pacstrap -c "$container_path" base base-devel python python-pip git helix go zig --noconfirm; then |
|
log_info "Bootstrap completed successfully!" |
|
break |
|
else |
|
if [ $attempt -eq $max_attempts ]; then |
|
log_error "Failed to bootstrap. Try: sudo pacman-mirrors --fasttrack" |
|
sudo rm -rf "$container_path" |
|
exit 1 |
|
fi |
|
sleep 3 |
|
attempt=$((attempt + 1)) |
|
fi |
|
done |
|
|
|
# Create configuration with specific mount mode |
|
create_nspawn_config "$name" "$mount_src" "$mount_dst" "$mount_mode" |
|
|
|
# Set hostname |
|
echo "$name" | sudo tee "$container_path/etc/hostname" > /dev/null |
|
|
|
# Setup networking |
|
sudo tee "$container_path/etc/systemd/network/80-container-host0.network" > /dev/null <<EOF |
|
[Match] |
|
Name=host0 |
|
|
|
[Network] |
|
DHCP=yes |
|
EOF |
|
|
|
# Enable networkd |
|
sudo ln -sf /usr/lib/systemd/system/systemd-networkd.service \ |
|
"$container_path/etc/systemd/system/multi-user.target.wants/systemd-networkd.service" |
|
sudo ln -sf /usr/lib/systemd/system/systemd-resolved.service \ |
|
"$container_path/etc/systemd/system/multi-user.target.wants/systemd-resolved.service" |
|
|
|
# Create user |
|
log_info "Setting up 'agent' user..." |
|
sudo systemd-nspawn -D "$container_path" --pipe useradd -m -G wheel -s /bin/bash agent |
|
echo "agent:agent" | sudo systemd-nspawn -D "$container_path" --pipe chpasswd |
|
echo "%wheel ALL=(ALL:ALL) NOPASSWD: ALL" | sudo tee "$container_path/etc/sudoers.d/wheel" > /dev/null |
|
|
|
# Install Python packages |
|
log_info "Installing Python packages..." |
|
sudo systemd-nspawn -D "$container_path" --pipe \ |
|
bash -c "python -m pip install --break-system-packages mistral-vibe" || \ |
|
log_warn "Failed to install packages." |
|
|
|
log_info "Container '$name' created!" |
|
} |
|
|
|
create_nspawn_config() { |
|
local name=$1 |
|
local mount_src="${2:-}" |
|
local mount_dst="${3:-}" |
|
local mode="${4:-ro}" |
|
local config_file="$CONTAINER_CONFIG/$name.nspawn" |
|
|
|
cat > "$config_file" <<EOF |
|
[Exec] |
|
Boot=yes |
|
PrivateUsers=pick |
|
|
|
[Network] |
|
VirtualEthernet=yes |
|
Private=yes |
|
|
|
[Files] |
|
PrivateUsersOwnership=auto |
|
EOF |
|
|
|
# Add bind mount based on mode |
|
if [ -n "$mount_src" ]; then |
|
if [ "$mode" == "rw" ]; then |
|
echo "Bind=$mount_src:$mount_dst" >> "$config_file" |
|
log_warn "Configured READ-WRITE bind: $mount_src -> $mount_dst" |
|
else |
|
echo "BindReadOnly=$mount_src:$mount_dst" >> "$config_file" |
|
log_info "Configured READ-ONLY bind: $mount_src -> $mount_dst" |
|
fi |
|
fi |
|
|
|
log_info "Created nspawn config: $config_file" |
|
} |
|
|
|
start_container() { |
|
local name=$1 |
|
local container_path="$CONTAINER_BASE/$name" |
|
local config_file="$CONTAINER_CONFIG/$name.nspawn" |
|
|
|
if [ ! -d "$container_path" ]; then |
|
log_error "Container '$name' does not exist" |
|
exit 1 |
|
fi |
|
|
|
log_info "Starting container '$name'..." |
|
|
|
local bind_args="" |
|
if [ -f "$config_file" ]; then |
|
# Extract Bind= lines (Read-Write) -> convert to --bind= |
|
local bind_rw=$(grep "^Bind=" "$config_file" | sed 's/^Bind=/--bind=/' | tr '\n' ' ') |
|
|
|
# Extract BindReadOnly= lines (Read-Only) -> convert to --bind-ro= |
|
local bind_ro=$(grep "^BindReadOnly=" "$config_file" | sed 's/^BindReadOnly=/--bind-ro=/' | tr '\n' ' ') |
|
|
|
bind_args="$bind_rw $bind_ro" |
|
fi |
|
|
|
# Start container |
|
sudo systemd-nspawn -D "$container_path" \ |
|
--machine="$name" \ |
|
--network-veth \ |
|
--private-users=pick \ |
|
--private-users-ownership=auto \ |
|
$bind_args \ |
|
--boot & |
|
|
|
sleep 2 |
|
log_info "Container '$name' started" |
|
} |
|
|
|
shell_container() { |
|
local name=$1 |
|
|
|
if ! machinectl list | grep -q "$name"; then |
|
log_warn "Container '$name' is not running. Starting..." |
|
start_container "$name" |
|
sleep 3 |
|
fi |
|
|
|
log_info "Opening shell in container '$name' as user 'agent'..." |
|
sudo machinectl shell agent@$name /bin/bash |
|
} |
|
|
|
stop_container() { |
|
local name=$1 |
|
|
|
log_info "Stopping container '$name'..." |
|
sudo machinectl poweroff "$name" 2>/dev/null || true |
|
sleep 2 |
|
log_info "Container '$name' stopped" |
|
} |
|
|
|
delete_container() { |
|
local name=$1 |
|
local container_path="$CONTAINER_BASE/$name" |
|
local config_file="$CONTAINER_CONFIG/$name.nspawn" |
|
|
|
if [ ! -d "$container_path" ]; then |
|
log_error "Container '$name' does not exist" |
|
exit 1 |
|
fi |
|
|
|
# Check if container is running |
|
if machinectl list 2>/dev/null | grep -q "$name"; then |
|
log_warn "Container '$name' is running. Stopping it first..." |
|
stop_container "$name" |
|
fi |
|
|
|
log_warn "About to delete container '$name'" |
|
log_warn "Path: $container_path" |
|
read -p "Are you sure? (yes/no): " confirm |
|
|
|
if [ "$confirm" != "yes" ]; then |
|
log_info "Deletion cancelled" |
|
exit 0 |
|
fi |
|
|
|
log_info "Deleting container '$name' (requires sudo for root-owned files)..." |
|
|
|
# Remove container directory (use sudo for root-owned files) |
|
sudo rm -rf "$container_path" |
|
|
|
# Remove config file (user-owned, no sudo needed) |
|
if [ -f "$config_file" ]; then |
|
rm -f "$config_file" |
|
fi |
|
|
|
log_info "Container '$name' deleted successfully" |
|
} |
|
|
|
list_containers() { |
|
log_info "Available containers:" |
|
if [ -d "$CONTAINER_BASE" ]; then |
|
ls -1 "$CONTAINER_BASE" 2>/dev/null || echo " (none)" |
|
else |
|
echo " (none)" |
|
fi |
|
|
|
echo "" |
|
log_info "Running containers:" |
|
machinectl list --no-pager 2>/dev/null || echo " (none)" |
|
} |
|
|
|
case "${1:-}" in |
|
create) |
|
check_dependencies |
|
setup_directories |
|
if [ -z "${2:-}" ]; then |
|
log_error "Usage: $0 create <container-name> [mount-source] [mount-dest]" |
|
log_error "Example: $0 create ai-agent ~/projects /mnt/projects" |
|
exit 1 |
|
fi |
|
create_container "$2" "${3:-}" "${4:-/mnt/shared}" |
|
;; |
|
start) |
|
if [ -z "${2:-}" ]; then |
|
log_error "Usage: $0 start <container-name>" |
|
exit 1 |
|
fi |
|
start_container "$2" |
|
;; |
|
shell) |
|
if [ -z "${2:-}" ]; then |
|
log_error "Usage: $0 shell <container-name>" |
|
exit 1 |
|
fi |
|
shell_container "$2" |
|
;; |
|
stop) |
|
if [ -z "${2:-}" ]; then |
|
log_error "Usage: $0 stop <container-name>" |
|
exit 1 |
|
fi |
|
stop_container "$2" |
|
;; |
|
delete) |
|
if [ -z "${2:-}" ]; then |
|
log_error "Usage: $0 delete <container-name>" |
|
exit 1 |
|
fi |
|
delete_container "$2" |
|
;; |
|
list) |
|
list_containers |
|
;; |
|
*) |
|
echo "Usage: $0 {create|start|shell|stop|list} [container-name]" |
|
echo "" |
|
echo "Commands:" |
|
echo " create <name> - Create a new container" |
|
echo " start <name> - Start a container" |
|
echo " shell <name> - Open shell in container" |
|
echo " stop <name> - Stop a container" |
|
echo " list - List all containers" |
|
exit 1 |
|
;; |
|
esac |
|
``` |