mirror of
https://github.com/pacnpal/thrillwiki_django_no_react.git
synced 2025-12-21 08:31:09 -05:00
Add Road Trip Planner template with interactive map and trip management features
- Implemented a new HTML template for the Road Trip Planner. - Integrated Leaflet.js for interactive mapping and routing. - Added functionality for searching and selecting parks to include in a trip. - Enabled drag-and-drop reordering of selected parks. - Included trip optimization and route calculation features. - Created a summary display for trip statistics. - Added functionality to save trips and manage saved trips. - Enhanced UI with responsive design and dark mode support.
This commit is contained in:
861
scripts/unraid/vm-manager.py
Executable file
861
scripts/unraid/vm-manager.py
Executable file
@@ -0,0 +1,861 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Unraid VM Manager for ThrillWiki
|
||||
This script automates VM creation, configuration, and management on Unraid.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
import time
|
||||
import logging
|
||||
import requests
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from typing import Dict, Optional, List
|
||||
|
||||
# Configuration
|
||||
UNRAID_HOST = os***REMOVED***iron.get('UNRAID_HOST', 'localhost')
|
||||
UNRAID_USER = os***REMOVED***iron.get('UNRAID_USER', 'root')
|
||||
UNRAID_PASSWORD = os***REMOVED***iron.get('UNRAID_PASSWORD', '')
|
||||
VM_NAME = os***REMOVED***iron.get('VM_NAME', 'thrillwiki-vm')
|
||||
VM_TEMPLATE = os***REMOVED***iron.get('VM_TEMPLATE', 'Ubuntu Server 22.04')
|
||||
VM_MEMORY = int(os***REMOVED***iron.get('VM_MEMORY', 4096)) # MB
|
||||
VM_VCPUS = int(os***REMOVED***iron.get('VM_VCPUS', 2))
|
||||
VM_DISK_SIZE = int(os***REMOVED***iron.get('VM_DISK_SIZE', 50)) # GB
|
||||
SSH_PUBLIC_KEY = os***REMOVED***iron.get('SSH_PUBLIC_KEY', '')
|
||||
|
||||
# Network Configuration
|
||||
VM_IP = os***REMOVED***iron.get('VM_IP', '192.168.20.20')
|
||||
VM_GATEWAY = os***REMOVED***iron.get('VM_GATEWAY', '192.168.20.1')
|
||||
VM_NETMASK = os***REMOVED***iron.get('VM_NETMASK', '255.255.255.0')
|
||||
VM_NETWORK = os***REMOVED***iron.get('VM_NETWORK', '192.168.20.0/24')
|
||||
|
||||
# GitHub Configuration
|
||||
REPO_URL = os***REMOVED***iron.get('REPO_URL', '')
|
||||
GITHUB_USERNAME = os***REMOVED***iron.get('GITHUB_USERNAME', '')
|
||||
GITHUB_TOKEN = os***REMOVED***iron.get('GITHUB_TOKEN', '')
|
||||
GITHUB_API_ENABLED = os***REMOVED***iron.get(
|
||||
'GITHUB_API_ENABLED', 'false').lower() == 'true'
|
||||
|
||||
# Setup logging
|
||||
os.makedirs('logs', exist_ok=True)
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format='%(asctime)s - %(levelname)s - %(message)s',
|
||||
handlers=[
|
||||
logging.FileHandler('logs/unraid-vm.log'),
|
||||
logging.StreamHandler()
|
||||
]
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class UnraidVMManager:
|
||||
"""Manages VMs on Unraid server."""
|
||||
|
||||
def __init__(self):
|
||||
self.session = requests.Session()
|
||||
self.base_url = f"http://{UNRAID_HOST}"
|
||||
self.vm_config_path = f"/mnt/user/domains/{VM_NAME}"
|
||||
|
||||
def authenticate(self) -> bool:
|
||||
"""Authenticate with Unraid server."""
|
||||
try:
|
||||
login_url = f"{self.base_url}/login"
|
||||
login_data = {
|
||||
'username': UNRAID_USER,
|
||||
'password': UNRAID_PASSWORD
|
||||
}
|
||||
|
||||
response = self.session.post(login_url, data=login_data)
|
||||
if response.status_code == 200:
|
||||
logger.info("Successfully authenticated with Unraid")
|
||||
return True
|
||||
else:
|
||||
logger.error(f"Authentication failed: {response.status_code}")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Authentication error: {e}")
|
||||
return False
|
||||
|
||||
def check_vm_exists(self) -> bool:
|
||||
"""Check if VM already exists."""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
f"ssh {UNRAID_USER}@{UNRAID_HOST} 'virsh list --all | grep {VM_NAME}'",
|
||||
shell=True,
|
||||
capture_output=True,
|
||||
text=True
|
||||
)
|
||||
return VM_NAME in result.stdout
|
||||
except Exception as e:
|
||||
logger.error(f"Error checking VM existence: {e}")
|
||||
return False
|
||||
|
||||
def create_vm_xml(self, existing_uuid: str = None) -> str:
|
||||
"""Generate VM XML configuration."""
|
||||
import uuid
|
||||
vm_uuid = existing_uuid if existing_uuid else str(uuid.uuid4())
|
||||
|
||||
xml_template = f"""<?xml version='1.0' encoding='UTF-8'?>
|
||||
<domain type='kvm'>
|
||||
<name>{VM_NAME}</name>
|
||||
<uuid>{vm_uuid}</uuid>
|
||||
<metadata>
|
||||
<vmtemplate xmlns="unraid" name="Windows 10" iconold="ubuntu.png" icon="ubuntu.png" os="linux" webui=""/>
|
||||
</metadata>
|
||||
<memory unit='KiB'>{VM_MEMORY * 1024}</memory>
|
||||
<currentMemory unit='KiB'>{VM_MEMORY * 1024}</currentMemory>
|
||||
<vcpu placement='static'>{VM_VCPUS}</vcpu>
|
||||
<os>
|
||||
<type arch='x86_64' machine='pc-q35-9.2'>hvm</type>
|
||||
<loader readonly='yes' type='pflash' format='raw'>/usr/share/qemu/ovmf-x64/OVMF_CODE-pure-efi.fd</loader>
|
||||
<nvram format='raw'>/etc/libvirt/qemu/nvram/{vm_uuid}_VARS-pure-efi.fd</nvram>
|
||||
</os>
|
||||
<features>
|
||||
<acpi/>
|
||||
<apic/>
|
||||
<vmport state='off'/>
|
||||
</features>
|
||||
<cpu mode='host-passthrough' check='none' migratable='on'>
|
||||
<topology sockets='1' dies='1' clusters='1' cores='{VM_VCPUS // 2 if VM_VCPUS > 1 else 1}' threads='{2 if VM_VCPUS > 1 else 1}'/>
|
||||
<cache mode='passthrough'/>
|
||||
<feature policy='require' name='topoext'/>
|
||||
</cpu>
|
||||
<clock offset='utc'>
|
||||
<timer name='hpet' present='no'/>
|
||||
<timer name='hypervclock' present='yes'/>
|
||||
<timer name='pit' tickpolicy='delay'/>
|
||||
<timer name='rtc' tickpolicy='catchup'/>
|
||||
</clock>
|
||||
<on_poweroff>destroy</on_poweroff>
|
||||
<on_reboot>restart</on_reboot>
|
||||
<on_crash>restart</on_crash>
|
||||
<pm>
|
||||
<suspend-to-mem enabled='no'/>
|
||||
<suspend-to-disk enabled='no'/>
|
||||
</pm>
|
||||
<devices>
|
||||
<emulator>/usr/local/sbin/qemu</emulator>
|
||||
<disk type='file' device='disk'>
|
||||
<driver name='qemu' type='qcow2' cache='writeback' discard='ignore'/>
|
||||
<source file='/mnt/user/domains/{VM_NAME}/vdisk1.qcow2'/>
|
||||
<target dev='hdc' bus='virtio'/>
|
||||
<boot order='2'/>
|
||||
<address type='pci' domain='0x0000' bus='0x02' slot='0x00' function='0x0'/>
|
||||
</disk>
|
||||
<disk type='file' device='cdrom'>
|
||||
<driver name='qemu' type='raw'/>
|
||||
<source file='/mnt/user/isos/ubuntu-24.04.3-live-server-amd64.iso'/>
|
||||
<target dev='hda' bus='sata'/>
|
||||
<readonly/>
|
||||
<boot order='1'/>
|
||||
<address type='drive' controller='0' bus='0' target='0' unit='0'/>
|
||||
</disk>
|
||||
<disk type='file' device='cdrom'>
|
||||
<driver name='qemu' type='raw'/>
|
||||
<source file='/mnt/user/isos/{VM_NAME}-cloud-init.iso'/>
|
||||
<target dev='hdb' bus='sata'/>
|
||||
<readonly/>
|
||||
<address type='drive' controller='0' bus='0' target='0' unit='1'/>
|
||||
</disk>
|
||||
<controller type='usb' index='0' model='qemu-xhci' ports='15'>
|
||||
<address type='pci' domain='0x0000' bus='0x00' slot='0x07' function='0x0'/>
|
||||
</controller>
|
||||
<controller type='pci' index='0' model='pcie-root'/>
|
||||
<controller type='pci' index='1' model='pcie-root-port'>
|
||||
<model name='pcie-root-port'/>
|
||||
<target chassis='1' port='0x10'/>
|
||||
<address type='pci' domain='0x0000' bus='0x00' slot='0x02' function='0x0' multifunction='on'/>
|
||||
</controller>
|
||||
<controller type='pci' index='2' model='pcie-root-port'>
|
||||
<model name='pcie-root-port'/>
|
||||
<target chassis='2' port='0x11'/>
|
||||
<address type='pci' domain='0x0000' bus='0x00' slot='0x02' function='0x1'/>
|
||||
</controller>
|
||||
<controller type='pci' index='3' model='pcie-root-port'>
|
||||
<model name='pcie-root-port'/>
|
||||
<target chassis='3' port='0x12'/>
|
||||
<address type='pci' domain='0x0000' bus='0x00' slot='0x02' function='0x2'/>
|
||||
</controller>
|
||||
<controller type='pci' index='4' model='pcie-root-port'>
|
||||
<model name='pcie-root-port'/>
|
||||
<target chassis='4' port='0x13'/>
|
||||
<address type='pci' domain='0x0000' bus='0x00' slot='0x02' function='0x3'/>
|
||||
</controller>
|
||||
<controller type='pci' index='5' model='pcie-root-port'>
|
||||
<model name='pcie-root-port'/>
|
||||
<target chassis='5' port='0x14'/>
|
||||
<address type='pci' domain='0x0000' bus='0x00' slot='0x02' function='0x4'/>
|
||||
</controller>
|
||||
<controller type='virtio-serial' index='0'>
|
||||
<address type='pci' domain='0x0000' bus='0x03' slot='0x00' function='0x0'/>
|
||||
</controller>
|
||||
<controller type='sata' index='0'>
|
||||
<address type='pci' domain='0x0000' bus='0x00' slot='0x1f' function='0x2'/>
|
||||
</controller>
|
||||
<interface type='bridge'>
|
||||
<mac address='52:54:00:{":".join([f"{int(VM_IP.split('.')[3]):02x}", "7d", "fd"])}'/>
|
||||
<source bridge='br0.20'/>
|
||||
<model type='virtio'/>
|
||||
<address type='pci' domain='0x0000' bus='0x01' slot='0x00' function='0x0'/>
|
||||
</interface>
|
||||
<serial type='pty'>
|
||||
<target type='isa-serial' port='0'>
|
||||
<model name='isa-serial'/>
|
||||
</target>
|
||||
</serial>
|
||||
<console type='pty'>
|
||||
<target type='serial' port='0'/>
|
||||
</console>
|
||||
<channel type='unix'>
|
||||
<target type='virtio' name='org.qemu.guest_agent.0'/>
|
||||
<address type='virtio-serial' controller='0' bus='0' port='1'/>
|
||||
</channel>
|
||||
<input type='tablet' bus='usb'>
|
||||
<address type='usb' bus='0' port='1'/>
|
||||
</input>
|
||||
<input type='mouse' bus='ps2'/>
|
||||
<input type='keyboard' bus='ps2'/>
|
||||
<graphics type='vnc' port='-1' autoport='yes' websocket='-1' listen='0.0.0.0' sharePolicy='ignore'>
|
||||
<listen type='address' address='0.0.0.0'/>
|
||||
</graphics>
|
||||
<audio id='1' type='none'/>
|
||||
<video>
|
||||
<model type='qxl' ram='65536' vram='65536' vram64='65535' vgamem='65536' heads='1' primary='yes'/>
|
||||
<address type='pci' domain='0x0000' bus='0x00' slot='0x1e' function='0x0'/>
|
||||
</video>
|
||||
<watchdog model='itco' action='reset'/>
|
||||
<memballoon model='virtio'>
|
||||
<address type='pci' domain='0x0000' bus='0x05' slot='0x00' function='0x0'/>
|
||||
</memballoon>
|
||||
</devices>
|
||||
</domain>"""
|
||||
return xml_template.strip()
|
||||
|
||||
def create_vm(self) -> bool:
|
||||
"""Create or update the VM on Unraid."""
|
||||
try:
|
||||
vm_exists = self.check_vm_exists()
|
||||
|
||||
if vm_exists:
|
||||
logger.info(
|
||||
f"VM {VM_NAME} already exists, updating configuration...")
|
||||
# Stop VM if running before updating
|
||||
if self.vm_status() == "running":
|
||||
logger.info(
|
||||
f"Stopping VM {VM_NAME} for configuration update...")
|
||||
self.stop_vm()
|
||||
# Wait for VM to stop
|
||||
import time
|
||||
time.sleep(5)
|
||||
else:
|
||||
logger.info(f"Creating VM {VM_NAME}...")
|
||||
|
||||
# Create VM directory
|
||||
subprocess.run(
|
||||
f"ssh {UNRAID_USER}@{UNRAID_HOST} 'mkdir -p {self.vm_config_path}'",
|
||||
shell=True,
|
||||
check=True
|
||||
)
|
||||
|
||||
# Create virtual disk only if VM doesn't exist
|
||||
disk_cmd = f"""
|
||||
ssh {UNRAID_USER}@{UNRAID_HOST} 'qemu-img create -f qcow2 {self.vm_config_path}/vdisk1.qcow2 {VM_DISK_SIZE}G'
|
||||
"""
|
||||
subprocess.run(disk_cmd, shell=True, check=True)
|
||||
|
||||
# Create cloud-init ISO for automated installation and ThrillWiki deployment
|
||||
logger.info(
|
||||
"Creating cloud-init ISO for automated Ubuntu and ThrillWiki setup...")
|
||||
if not self.create_cloud_init_iso(VM_IP):
|
||||
logger.error("Failed to create cloud-init ISO")
|
||||
return False
|
||||
|
||||
existing_uuid = None
|
||||
|
||||
if vm_exists:
|
||||
# Get existing VM UUID
|
||||
result = subprocess.run(
|
||||
f"ssh {UNRAID_USER}@{UNRAID_HOST} 'virsh dumpxml {VM_NAME} | grep \"<uuid>\" | sed \"s/<uuid>//g\" | sed \"s/<\\/uuid>//g\" | tr -d \" \"'",
|
||||
shell=True,
|
||||
capture_output=True,
|
||||
text=True
|
||||
)
|
||||
|
||||
if result.returncode == 0 and result.stdout.strip():
|
||||
existing_uuid = result.stdout.strip()
|
||||
logger.info(f"Found existing VM UUID: {existing_uuid}")
|
||||
|
||||
# Always undefine existing VM with NVRAM flag (since we create persistent VMs)
|
||||
logger.info(
|
||||
f"VM {VM_NAME} exists, undefining with NVRAM for reconfiguration...")
|
||||
subprocess.run(
|
||||
f"ssh {UNRAID_USER}@{UNRAID_HOST} 'virsh undefine {VM_NAME} --nvram'",
|
||||
shell=True,
|
||||
check=True
|
||||
)
|
||||
logger.info(
|
||||
f"VM {VM_NAME} undefined for reconfiguration (with NVRAM)")
|
||||
|
||||
# Generate VM XML with appropriate UUID
|
||||
vm_xml = self.create_vm_xml(existing_uuid)
|
||||
xml_file = f"/tmp/{VM_NAME}.xml"
|
||||
|
||||
with open(xml_file, 'w') as f:
|
||||
f.write(vm_xml)
|
||||
|
||||
# Copy XML to Unraid and define/redefine VM
|
||||
subprocess.run(
|
||||
f"scp {xml_file} {UNRAID_USER}@{UNRAID_HOST}:/tmp/",
|
||||
shell=True,
|
||||
check=True
|
||||
)
|
||||
|
||||
# Define VM as persistent domain
|
||||
subprocess.run(
|
||||
f"ssh {UNRAID_USER}@{UNRAID_HOST} 'virsh define /tmp/{VM_NAME}.xml'",
|
||||
shell=True,
|
||||
check=True
|
||||
)
|
||||
|
||||
# Ensure VM is set to autostart for persistent configuration
|
||||
subprocess.run(
|
||||
f"ssh {UNRAID_USER}@{UNRAID_HOST} 'virsh autostart {VM_NAME}'",
|
||||
shell=True,
|
||||
check=False # Don't fail if autostart is already enabled
|
||||
)
|
||||
|
||||
action = "updated" if vm_exists else "created"
|
||||
logger.info(f"VM {VM_NAME} {action} successfully")
|
||||
|
||||
# Cleanup
|
||||
os.remove(xml_file)
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to create VM: {e}")
|
||||
return False
|
||||
|
||||
def create_nvram_file(self, vm_uuid: str) -> bool:
|
||||
"""Create NVRAM file for UEFI VM."""
|
||||
try:
|
||||
nvram_path = f"/etc/libvirt/qemu/nvram/{vm_uuid}_VARS-pure-efi.fd"
|
||||
|
||||
# Check if NVRAM file already exists
|
||||
result = subprocess.run(
|
||||
f"ssh {UNRAID_USER}@{UNRAID_HOST} 'test -f {nvram_path}'",
|
||||
shell=True,
|
||||
capture_output=True
|
||||
)
|
||||
|
||||
if result.returncode == 0:
|
||||
logger.info(f"NVRAM file already exists: {nvram_path}")
|
||||
return True
|
||||
|
||||
# Copy template to create NVRAM file
|
||||
logger.info(f"Creating NVRAM file: {nvram_path}")
|
||||
result = subprocess.run(
|
||||
f"ssh {UNRAID_USER}@{UNRAID_HOST} 'cp /usr/share/qemu/ovmf-x64/OVMF_VARS-pure-efi.fd {nvram_path}'",
|
||||
shell=True,
|
||||
capture_output=True,
|
||||
text=True
|
||||
)
|
||||
|
||||
if result.returncode == 0:
|
||||
logger.info("NVRAM file created successfully")
|
||||
return True
|
||||
else:
|
||||
logger.error(f"Failed to create NVRAM file: {result.stderr}")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error creating NVRAM file: {e}")
|
||||
return False
|
||||
|
||||
def start_vm(self) -> bool:
|
||||
"""Start the VM if it's not already running."""
|
||||
try:
|
||||
# Check if VM is already running
|
||||
current_status = self.vm_status()
|
||||
if current_status == "running":
|
||||
logger.info(f"VM {VM_NAME} is already running")
|
||||
return True
|
||||
|
||||
logger.info(f"Starting VM {VM_NAME}...")
|
||||
|
||||
# For new VMs, we need to extract the UUID and create NVRAM file
|
||||
vm_exists = self.check_vm_exists()
|
||||
if not vm_exists:
|
||||
logger.error("Cannot start VM that doesn't exist")
|
||||
return False
|
||||
|
||||
# Get VM UUID from XML
|
||||
result = subprocess.run(
|
||||
f"ssh {UNRAID_USER}@{UNRAID_HOST} 'virsh dumpxml {VM_NAME} | grep \"<uuid>\" | sed \"s/<uuid>//g\" | sed \"s/<\\/uuid>//g\" | tr -d \" \"'",
|
||||
shell=True,
|
||||
capture_output=True,
|
||||
text=True
|
||||
)
|
||||
|
||||
if result.returncode == 0 and result.stdout.strip():
|
||||
vm_uuid = result.stdout.strip()
|
||||
logger.info(f"VM UUID: {vm_uuid}")
|
||||
|
||||
# Create NVRAM file if it doesn't exist
|
||||
if not self.create_nvram_file(vm_uuid):
|
||||
return False
|
||||
|
||||
result = subprocess.run(
|
||||
f"ssh {UNRAID_USER}@{UNRAID_HOST} 'virsh start {VM_NAME}'",
|
||||
shell=True,
|
||||
capture_output=True,
|
||||
text=True
|
||||
)
|
||||
|
||||
if result.returncode == 0:
|
||||
logger.info(f"VM {VM_NAME} started successfully")
|
||||
return True
|
||||
else:
|
||||
logger.error(f"Failed to start VM: {result.stderr}")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error starting VM: {e}")
|
||||
return False
|
||||
|
||||
def stop_vm(self) -> bool:
|
||||
"""Stop the VM."""
|
||||
try:
|
||||
logger.info(f"Stopping VM {VM_NAME}...")
|
||||
|
||||
result = subprocess.run(
|
||||
f"ssh {UNRAID_USER}@{UNRAID_HOST} 'virsh shutdown {VM_NAME}'",
|
||||
shell=True,
|
||||
capture_output=True,
|
||||
text=True
|
||||
)
|
||||
|
||||
if result.returncode == 0:
|
||||
logger.info(f"VM {VM_NAME} stopped successfully")
|
||||
return True
|
||||
else:
|
||||
logger.error(f"Failed to stop VM: {result.stderr}")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error stopping VM: {e}")
|
||||
return False
|
||||
|
||||
def get_vm_ip(self) -> Optional[str]:
|
||||
"""Get VM IP address."""
|
||||
try:
|
||||
# Wait for VM to get IP
|
||||
for attempt in range(30):
|
||||
result = subprocess.run(
|
||||
f"ssh {UNRAID_USER}@{UNRAID_HOST} 'virsh domifaddr {VM_NAME}'",
|
||||
shell=True,
|
||||
capture_output=True,
|
||||
text=True
|
||||
)
|
||||
|
||||
if result.returncode == 0 and 'ipv4' in result.stdout:
|
||||
lines = result.stdout.strip().split('\n')
|
||||
for line in lines:
|
||||
if 'ipv4' in line:
|
||||
# Extract IP from line like: vnet0 52:54:00:xx:xx:xx ipv4 192.168.1.100/24
|
||||
parts = line.split()
|
||||
if len(parts) >= 4:
|
||||
ip_with_mask = parts[3]
|
||||
ip = ip_with_mask.split('/')[0]
|
||||
logger.info(f"VM IP address: {ip}")
|
||||
return ip
|
||||
|
||||
logger.info(f"Waiting for VM IP... (attempt {attempt + 1}/30)")
|
||||
time.sleep(10)
|
||||
|
||||
logger.error("Failed to get VM IP address")
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting VM IP: {e}")
|
||||
return None
|
||||
|
||||
def create_cloud_init_iso(self, vm_ip: str) -> bool:
|
||||
"""Create cloud-init ISO for automated Ubuntu installation."""
|
||||
try:
|
||||
logger.info("Creating cloud-init ISO...")
|
||||
|
||||
# Get environment variables
|
||||
repo_url = os.getenv('REPO_URL', '')
|
||||
github_token = os.getenv('GITHUB_TOKEN', '')
|
||||
ssh_public_key = os.getenv('SSH_PUBLIC_KEY', '')
|
||||
|
||||
# Extract repository name from URL
|
||||
if repo_url:
|
||||
# Extract owner/repo from URL like https://github.com/owner/repo
|
||||
github_repo = repo_url.replace(
|
||||
'https://github.com/', '').replace('.git', '')
|
||||
else:
|
||||
logger.error("REPO_URL environment variable not set")
|
||||
return False
|
||||
|
||||
# Create cloud-init user-data with complete ThrillWiki deployment
|
||||
user_data = f"""#cloud-config
|
||||
runcmd:
|
||||
- [eval, 'echo $(cat /proc/cmdline) "autoinstall" > /root/cmdline']
|
||||
- [eval, 'mount -n --bind -o ro /root/cmdline /proc/cmdline']
|
||||
- [eval, 'snap restart subiquity.subiquity-server']
|
||||
- [eval, 'snap restart subiquity.subiquity-service']
|
||||
|
||||
autoinstall:
|
||||
version: 1
|
||||
locale: en_US
|
||||
keyboard:
|
||||
layout: us
|
||||
ssh:
|
||||
install-server: true
|
||||
authorized-keys:
|
||||
- {ssh_public_key}
|
||||
allow-pw: false
|
||||
storage:
|
||||
layout:
|
||||
name: direct
|
||||
identity:
|
||||
hostname: thrillwiki-vm
|
||||
username: ubuntu
|
||||
password: '$6$rounds=4096$saltsalt$hash' # disabled
|
||||
kernel:
|
||||
package: linux-generic
|
||||
early-commands:
|
||||
- systemctl stop ssh
|
||||
packages:
|
||||
- curl
|
||||
- git
|
||||
- build-essential
|
||||
- python3-pip
|
||||
- postgresql
|
||||
- postgresql-contrib
|
||||
- nginx
|
||||
- nodejs
|
||||
- npm
|
||||
- pipx
|
||||
late-commands:
|
||||
- apt install pipx -y
|
||||
- echo 'ubuntu ALL=(ALL) NOPASSWD:ALL' > /target/etc/sudoers.d/ubuntu
|
||||
- /target/usr/bin/pipx install uv
|
||||
# Setup ThrillWiki deployment script
|
||||
- |
|
||||
cat > /target/home/ubuntu/deploy-thrillwiki.sh << 'DEPLOY_EOF'
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
# Wait for system to be ready
|
||||
sleep 30
|
||||
|
||||
# Clone ThrillWiki repository with GitHub token
|
||||
export GITHUB_TOKEN=$(cat /home/ubuntu/.github-token 2>/dev/null || echo "")
|
||||
if [ -n "$GITHUB_TOKEN" ]; then
|
||||
git clone https://$GITHUB_TOKEN@github.com/{github_repo} /home/ubuntu/thrillwiki
|
||||
else
|
||||
git clone https://github.com/{github_repo} /home/ubuntu/thrillwiki
|
||||
fi
|
||||
|
||||
cd /home/ubuntu/thrillwiki
|
||||
|
||||
# Setup UV and Python environment
|
||||
export PATH="/home/ubuntu/.local/bin:$PATH"
|
||||
uv venv
|
||||
source .venv/bin/activate
|
||||
|
||||
# Install dependencies
|
||||
uv sync
|
||||
|
||||
# Setup PostgreSQL
|
||||
sudo -u postgres createuser ubuntu
|
||||
sudo -u postgres createdb thrillwiki_production
|
||||
sudo -u postgres psql -c "ALTER USER ubuntu WITH SUPERUSER;"
|
||||
|
||||
# Setup environment
|
||||
cp ***REMOVED***.example ***REMOVED***
|
||||
echo "DEBUG=False" >> ***REMOVED***
|
||||
echo "DATABASE_URL=postgresql://ubuntu@localhost/thrillwiki_production" >> ***REMOVED***
|
||||
echo "ALLOWED_HOSTS=*" >> ***REMOVED***
|
||||
|
||||
# Run migrations and collect static files
|
||||
uv run manage.py migrate
|
||||
uv run manage.py collectstatic --noinput
|
||||
uv run manage.py tailwind build
|
||||
|
||||
# Setup systemd services
|
||||
sudo cp [AWS-SECRET-REMOVED]thrillwiki.service /etc/systemd/system/
|
||||
sudo cp [AWS-SECRET-REMOVED]thrillwiki-webhook.service /etc/systemd/system/
|
||||
|
||||
# Update service files with correct paths
|
||||
sudo sed -i "s|/opt/thrillwiki|/home/ubuntu/thrillwiki|g" /etc/systemd/system/thrillwiki.service
|
||||
sudo sed -i "s|/opt/thrillwiki|/home/ubuntu/thrillwiki|g" /etc/systemd/system/thrillwiki-webhook.service
|
||||
|
||||
# Enable and start services
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable thrillwiki
|
||||
sudo systemctl enable thrillwiki-webhook
|
||||
sudo systemctl start thrillwiki
|
||||
sudo systemctl start thrillwiki-webhook
|
||||
|
||||
echo "ThrillWiki deployment completed successfully!"
|
||||
DEPLOY_EOF
|
||||
- chmod +x /target/home/ubuntu/deploy-thrillwiki.sh
|
||||
- chroot /target chown ubuntu:ubuntu /home/ubuntu/deploy-thrillwiki.sh
|
||||
# Create systemd service to run deployment after first boot
|
||||
- |
|
||||
cat > /target/etc/systemd/system/thrillwiki-deploy.service << 'SERVICE_EOF'
|
||||
[Unit]
|
||||
Description=Deploy ThrillWiki on first boot
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
User=ubuntu
|
||||
ExecStart=/home/ubuntu/deploy-thrillwiki.sh
|
||||
RemainAfterExit=yes
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
SERVICE_EOF
|
||||
- chroot /target systemctl enable thrillwiki-deploy
|
||||
user-data:
|
||||
disable_root: true
|
||||
ssh_pwauth: false
|
||||
power_state:
|
||||
mode: reboot
|
||||
"""
|
||||
|
||||
meta_data = f"""instance-id: thrillwiki-vm-001
|
||||
local-hostname: thrillwiki-vm
|
||||
network:
|
||||
version: 2
|
||||
ethernets:
|
||||
enp1s0:
|
||||
dhcp4: true
|
||||
"""
|
||||
|
||||
# Create temp directory for cloud-init files
|
||||
cloud_init_dir = "/tmp/cloud-init"
|
||||
os.makedirs(cloud_init_dir, exist_ok=True)
|
||||
|
||||
with open(f"{cloud_init_dir}/user-data", 'w') as f:
|
||||
f.write(user_data)
|
||||
|
||||
with open(f"{cloud_init_dir}/meta-data", 'w') as f:
|
||||
f.write(meta_data)
|
||||
|
||||
# Create ISO
|
||||
iso_path = f"/tmp/{VM_NAME}-cloud-init.iso"
|
||||
|
||||
# Try different ISO creation tools
|
||||
iso_created = False
|
||||
|
||||
# Try genisoimage first
|
||||
try:
|
||||
subprocess.run([
|
||||
'genisoimage',
|
||||
'-output', iso_path,
|
||||
'-volid', 'cidata',
|
||||
'-joliet',
|
||||
'-rock',
|
||||
cloud_init_dir
|
||||
], check=True)
|
||||
iso_created = True
|
||||
except FileNotFoundError:
|
||||
logger.warning("genisoimage not found, trying mkisofs...")
|
||||
|
||||
# Try mkisofs as fallback
|
||||
if not iso_created:
|
||||
try:
|
||||
subprocess.run([
|
||||
'mkisofs',
|
||||
'-output', iso_path,
|
||||
'-volid', 'cidata',
|
||||
'-joliet',
|
||||
'-rock',
|
||||
cloud_init_dir
|
||||
], check=True)
|
||||
iso_created = True
|
||||
except FileNotFoundError:
|
||||
logger.warning(
|
||||
"mkisofs not found, trying hdiutil (macOS)...")
|
||||
|
||||
# Try hdiutil for macOS
|
||||
if not iso_created:
|
||||
try:
|
||||
subprocess.run([
|
||||
'hdiutil', 'makehybrid',
|
||||
'-iso', '-joliet',
|
||||
'-o', iso_path,
|
||||
cloud_init_dir
|
||||
], check=True)
|
||||
iso_created = True
|
||||
except FileNotFoundError:
|
||||
logger.error(
|
||||
"No ISO creation tool found. Please install genisoimage, mkisofs, or use macOS hdiutil")
|
||||
return False
|
||||
|
||||
if not iso_created:
|
||||
logger.error("Failed to create ISO with any available tool")
|
||||
return False
|
||||
|
||||
# Copy ISO to Unraid
|
||||
subprocess.run(
|
||||
f"scp {iso_path} {UNRAID_USER}@{UNRAID_HOST}:/mnt/user/isos/",
|
||||
shell=True,
|
||||
check=True
|
||||
)
|
||||
|
||||
logger.info("Cloud-init ISO created successfully")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to create cloud-init ISO: {e}")
|
||||
return False
|
||||
|
||||
def vm_status(self) -> str:
|
||||
"""Get VM status."""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
f"ssh {UNRAID_USER}@{UNRAID_HOST} 'virsh domstate {VM_NAME}'",
|
||||
shell=True,
|
||||
capture_output=True,
|
||||
text=True
|
||||
)
|
||||
|
||||
if result.returncode == 0:
|
||||
return result.stdout.strip()
|
||||
else:
|
||||
return "unknown"
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting VM status: {e}")
|
||||
return "error"
|
||||
|
||||
def delete_vm(self) -> bool:
|
||||
"""Completely remove VM and all associated files."""
|
||||
try:
|
||||
logger.info(f"Deleting VM {VM_NAME} and all associated files...")
|
||||
|
||||
# Check if VM exists
|
||||
if not self.check_vm_exists():
|
||||
logger.info(f"VM {VM_NAME} does not exist")
|
||||
return True
|
||||
|
||||
# Stop VM if running
|
||||
if self.vm_status() == "running":
|
||||
logger.info(f"Stopping VM {VM_NAME}...")
|
||||
self.stop_vm()
|
||||
import time
|
||||
time.sleep(5)
|
||||
|
||||
# Undefine VM with NVRAM
|
||||
logger.info(f"Undefining VM {VM_NAME}...")
|
||||
subprocess.run(
|
||||
f"ssh {UNRAID_USER}@{UNRAID_HOST} 'virsh undefine {VM_NAME} --nvram'",
|
||||
shell=True,
|
||||
check=True
|
||||
)
|
||||
|
||||
# Remove VM directory and all files
|
||||
logger.info(f"Removing VM directory and files...")
|
||||
subprocess.run(
|
||||
f"ssh {UNRAID_USER}@{UNRAID_HOST} 'rm -rf {self.vm_config_path}'",
|
||||
shell=True,
|
||||
check=True
|
||||
)
|
||||
|
||||
# Remove cloud-init ISO
|
||||
subprocess.run(
|
||||
f"ssh {UNRAID_USER}@{UNRAID_HOST} 'rm -f /mnt/user/isos/{VM_NAME}-cloud-init.iso'",
|
||||
shell=True,
|
||||
check=False # Don't fail if file doesn't exist
|
||||
)
|
||||
|
||||
logger.info(f"VM {VM_NAME} completely removed")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to delete VM: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def main():
|
||||
"""Main function."""
|
||||
import argparse
|
||||
|
||||
parser = argparse.ArgumentParser(
|
||||
description='Unraid VM Manager for ThrillWiki')
|
||||
parser.add_argument('action', choices=['create', 'start', 'stop', 'status', 'ip', 'setup', 'delete'],
|
||||
help='Action to perform')
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
# Create logs directory
|
||||
os.makedirs('logs', exist_ok=True)
|
||||
|
||||
vm_manager = UnraidVMManager()
|
||||
|
||||
if args.action == 'create':
|
||||
success = vm_manager.create_vm()
|
||||
sys.exit(0 if success else 1)
|
||||
|
||||
elif args.action == 'start':
|
||||
success = vm_manager.start_vm()
|
||||
sys.exit(0 if success else 1)
|
||||
|
||||
elif args.action == 'stop':
|
||||
success = vm_manager.stop_vm()
|
||||
sys.exit(0 if success else 1)
|
||||
|
||||
elif args.action == 'status':
|
||||
status = vm_manager.vm_status()
|
||||
print(f"VM Status: {status}")
|
||||
sys.exit(0)
|
||||
|
||||
elif args.action == 'ip':
|
||||
ip = vm_manager.get_vm_ip()
|
||||
if ip:
|
||||
print(f"VM IP: {ip}")
|
||||
sys.exit(0)
|
||||
else:
|
||||
print("Failed to get VM IP")
|
||||
sys.exit(1)
|
||||
|
||||
elif args.action == 'setup':
|
||||
logger.info("Setting up complete VM environment...")
|
||||
|
||||
# Create VM
|
||||
if not vm_manager.create_vm():
|
||||
sys.exit(1)
|
||||
|
||||
# Start VM
|
||||
if not vm_manager.start_vm():
|
||||
sys.exit(1)
|
||||
|
||||
# Get IP
|
||||
vm_ip = vm_manager.get_vm_ip()
|
||||
if not vm_ip:
|
||||
sys.exit(1)
|
||||
|
||||
print(f"VM setup complete. IP: {vm_ip}")
|
||||
print("You can now connect via SSH and complete the ThrillWiki setup.")
|
||||
|
||||
sys.exit(0)
|
||||
|
||||
elif args.action == 'delete':
|
||||
success = vm_manager.delete_vm()
|
||||
sys.exit(0 if success else 1)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
Reference in New Issue
Block a user