Initial Commit

This commit is contained in:
Alphaeus Mote
2025-11-19 08:09:34 -05:00
parent 3da6627c2d
commit be6d358086
78 changed files with 13135 additions and 1 deletions
+341 -1
View File
@@ -1 +1,341 @@
# Depl0y # Depl0y
**Automated VM Deployment Panel for Proxmox VE**
Depl0y is a free, open-source web-based control panel that simplifies the deployment and management of virtual machines on Proxmox VE infrastructure. With an intuitive interface and powerful automation features, Depl0y makes VM provisioning accessible to everyone.
![License](https://img.shields.io/badge/license-MIT-blue.svg)
![Python](https://img.shields.io/badge/python-3.11%2B-blue.svg)
![Vue.js](https://img.shields.io/badge/vue.js-3.x-green.svg)
## Features
### Core Functionality
- **Automated VM Deployment** - Deploy Ubuntu, Debian, CentOS, Rocky Linux, Alma Linux, and Windows VMs with a few clicks
- **⚡ Cloud Images** - Ultra-fast 30-second deployments using pre-configured OS images (Ubuntu, Debian)
- **Cloud-Init Integration** - Automatic configuration of Linux VMs with cloud-init
- **Multi-Hypervisor Support** - Manage multiple Proxmox VE hosts and clusters
- **Resource Management** - Real-time monitoring of CPU, memory, and disk usage across your infrastructure
- **ISO Management** - Upload, store, and manage OS installation images
### Advanced Features
- **Update Management** - One-click system updates for deployed Linux VMs
- **QEMU Guest Agent** - Automatic installation and configuration
- **SSH Access** - Built-in SSH key management and password-based authentication
- **Network Configuration** - Static IP or DHCP configuration with cloud-init
- **Partition Management** - Single large partition or custom schemes for Linux deployments
### Security & Access Control
- **Multi-User Support** - Role-based access control (Admin, Operator, Viewer)
- **2FA Authentication** - TOTP-based two-factor authentication
- **Encrypted Credentials** - All sensitive data is encrypted at rest
- **Audit Logging** - Track all user actions and system changes
### User Experience
- **Modern UI** - Responsive, beautiful interface built with Vue.js
- **Real-time Status** - Live VM status updates and resource monitoring
- **Dashboard Analytics** - Overview of your entire virtualization infrastructure
- **RESTful API** - Complete API for automation and integration
## Screenshots
*Screenshots coming soon*
## Quick Start
### Prerequisites
- A server running Linux (Ubuntu 22.04+, Debian 11+, or similar)
- Docker and Docker Compose
- At least 2GB RAM and 20GB disk space
- Access to one or more Proxmox VE hosts
### Installation
1. **Clone the repository**
```bash
git clone https://github.com/yourusername/depl0y.git
cd depl0y
```
2. **Run the setup script**
```bash
sudo ./scripts/setup.sh
```
3. **Access the web interface**
- Open your browser and navigate to `http://your-server-ip`
- Log in with the credentials you created during setup
4. **Add your first Proxmox host**
- Go to "Proxmox Hosts" in the sidebar
- Click "Add Host" and enter your Proxmox credentials
- Test the connection
5. **Deploy your first VM**
- Go to "Virtual Machines"
- Click "Create VM"
- Fill in the required information and deploy!
## Manual Installation
If you prefer manual installation, see [docs/INSTALLATION.md](docs/INSTALLATION.md) for detailed instructions.
## Configuration
### Environment Variables
Copy `.env.example` to `.env` and configure the following:
```bash
# Database
MYSQL_ROOT_PASSWORD=your_secure_password
MYSQL_PASSWORD=your_depl0y_password
# Security
SECRET_KEY=your_jwt_secret_key_minimum_32_chars
ENCRYPTION_KEY=your_fernet_encryption_key
# Application
DEBUG=false
LOG_LEVEL=INFO
```
### Storage Locations
By default, Depl0y stores data in:
- ISOs: `/var/lib/depl0y/isos`
- Cloud images: `/var/lib/depl0y/cloud-images`
- Cloud-init configs: `/var/lib/depl0y/cloud-init`
- SSH keys: `/var/lib/depl0y/ssh_keys`
- Logs: `/var/log/depl0y/`
These can be customized in the `.env` file.
## Architecture
Depl0y consists of three main components:
1. **Backend API** (FastAPI + Python)
- RESTful API
- Proxmox integration via proxmoxer
- Database management with SQLAlchemy
- Authentication and authorization
2. **Frontend** (Vue.js 3)
- Single-page application
- Responsive design
- Real-time updates
3. **Database** (MariaDB)
- Stores users, VMs, hosts, and configuration
- Automated backups recommended
## API Documentation
Once installed, API documentation is available at:
- Swagger UI: `http://your-server-ip/api/v1/docs`
- ReDoc: `http://your-server-ip/api/v1/redoc`
## Usage
### ⚡ Deploying with Cloud Images (Fastest - Recommended)
Cloud images provide **30-second deployments** after initial setup:
**One-Time Setup (1 minute):**
1. Go to **Settings** > **Cloud Image Setup**
2. Copy the setup command shown
3. SSH to your Depl0y server and run: `sudo /tmp/enable_cloud_images.sh`
4. Enter your Proxmox root password when prompted
5. Done! All cloud images are now enabled
**Creating VMs:**
1. Navigate to "Virtual Machines" > "Create VM"
2. Select your Proxmox host and node
3. Choose **"Cloud Image (Fast)"** installation method
4. Select a cloud image (Ubuntu 24.04, Debian 12, etc.)
5. Configure resources (CPU, RAM, disk)
6. Set network configuration (DHCP or static IP)
7. Enter credentials for the VM
8. Click "Deploy"
**First deployment:** ~5-10 minutes (creates template)
**All subsequent deployments:** ~30 seconds ⚡
The VM will be automatically created with:
- OS fully installed and configured
- Your credentials already set up
- Network configured
- SSH server enabled
- Ready to use immediately
📖 **Full guide:** [CLOUD_IMAGES_GUIDE.md](CLOUD_IMAGES_GUIDE.md)
### Deploying a Linux VM (Traditional ISO Method)
1. Navigate to "Virtual Machines" > "Create VM"
2. Select your Proxmox host and node
3. Choose an OS type (Ubuntu, Debian, etc.)
4. Configure resources (CPU, RAM, disk)
5. Set network configuration (DHCP or static IP)
6. Enter credentials for the VM
7. Click "Deploy"
The VM will be automatically created with:
- Cloud-init configuration
- QEMU guest agent installed
- SSH server enabled
- User account configured
### Deploying a Windows VM
1. First upload a Windows ISO to "ISO Images"
2. Create a new VM and select Windows OS type
3. Configure resources
4. Deploy and complete Windows installation manually via VNC
### Managing Updates
1. Select a VM from the list
2. Click "Check Updates" to see available updates
3. Click "Install Updates" to apply them
4. View update history in the VM details
### Managing Proxmox Hosts
1. Go to "Proxmox Hosts"
2. Add your Proxmox VE hosts with credentials
3. Poll hosts to refresh resource information
4. View nodes and their current utilization
## Development
### Backend Development
```bash
cd backend
python3 -m venv venv
source venv/bin/activate
pip install -r requirements.txt
uvicorn app.main:app --reload
```
### Frontend Development
```bash
cd frontend
npm install
npm run dev
```
### Running Tests
```bash
# Backend tests
cd backend
pytest
# Frontend tests
cd frontend
npm run test
```
### Deploying Changes
After making changes to the code, deploy them to production:
```bash
# Quick deploy with the deploy script
./deploy.sh
# Or deploy manually - see DEPLOYMENT.md for details
```
📖 **Full deployment guide:** [DEPLOYMENT.md](DEPLOYMENT.md)
## Contributing
We welcome contributions! Please see [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines.
1. Fork the repository
2. Create a feature branch (`git checkout -b feature/amazing-feature`)
3. Commit your changes (`git commit -m 'Add amazing feature'`)
4. Push to the branch (`git push origin feature/amazing-feature`)
5. Open a Pull Request
## Security
### Reporting Vulnerabilities
Please report security vulnerabilities to security@example.com (not via public issues).
### Best Practices
- Change default passwords immediately after installation
- Use strong, unique passwords
- Enable 2FA for all admin accounts
- Keep Depl0y updated to the latest version
- Use HTTPS in production (configure SSL certificates)
- Regularly backup your database
- Restrict network access to trusted IPs if possible
## Roadmap
- [x] Cloud images for ultra-fast deployment
- [x] Template-based VM cloning
- [ ] Scheduled deployments
- [ ] VM snapshots management
- [ ] Backup automation
- [ ] Integration with monitoring tools (Prometheus, Grafana)
- [ ] Multi-language support
- [ ] Mobile app
- [ ] Ansible playbook execution
- [ ] Cost tracking and reporting
- [ ] API rate limiting
## Troubleshooting
### Common Issues
**Cannot connect to Proxmox host**
- Verify credentials are correct
- Check network connectivity
- Ensure Proxmox API is accessible
- Verify SSL certificate settings
**VMs not starting**
- Check Proxmox node has sufficient resources
- Verify ISO image is available
- Check VM logs in Proxmox
**Database connection errors**
- Ensure MariaDB container is running
- Verify database credentials in `.env`
- Check database container logs
For more help, see [docs/TROUBLESHOOTING.md](docs/TROUBLESHOOTING.md) or open an issue.
## License
This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
## Acknowledgments
- Built with [FastAPI](https://fastapi.tiangolo.com/)
- Frontend powered by [Vue.js](https://vuejs.org/)
- Proxmox integration via [proxmoxer](https://github.com/proxmoxer/proxmoxer)
- Icons from various open-source projects
## Support
- **Documentation**: [docs/](docs/)
- **Issues**: [GitHub Issues](https://github.com/yourusername/depl0y/issues)
- **Discussions**: [GitHub Discussions](https://github.com/yourusername/depl0y/discussions)
## Author
Created with ❤️ by the Depl0y team and contributors.
---
**Star this repo if you find it useful!**
+705
View File
@@ -0,0 +1,705 @@
# Cloud Images - Complete Guide
## Table of Contents
1. [What Are Cloud Images?](#what-are-cloud-images)
2. [Why Use Cloud Images?](#why-use-cloud-images)
3. [One-Time Setup](#one-time-setup)
4. [How to Use Cloud Images](#how-to-use-cloud-images)
5. [Available Cloud Images](#available-cloud-images)
6. [How It Works (Technical)](#how-it-works-technical)
7. [Troubleshooting](#troubleshooting)
8. [FAQ](#faq)
---
## What Are Cloud Images?
Cloud images are pre-configured, ready-to-deploy operating system disk images. Instead of manually installing an OS (which takes 15-20 minutes), cloud images allow you to:
- **Deploy VMs in 30 seconds** (after initial template creation)
- **Automatically configure credentials** (username/password)
- **Auto-configure networking** (DHCP or static IP)
- **Skip manual OS installation** completely
Think of it like cloning a pre-installed OS with your custom settings applied automatically.
---
## Why Use Cloud Images?
### Traditional ISO Installation:
- Upload ISO file (~1GB download)
- Create VM
- Boot from ISO
- Click through installation wizard
- Wait 15-20 minutes
- **Total time: 20-30 minutes per VM**
### Cloud Image Installation:
- Select cloud image
- Configure CPU, RAM, disk, credentials
- Click "Create VM"
- **First time: 5-10 minutes** (creates reusable template)
- **Every time after: 30 seconds** ⚡
**100x faster after the first deployment!**
---
## One-Time Setup
Cloud images require SSH access to your Proxmox server for initial template creation. This is a **ONE-TIME** setup that takes about 1 minute.
### Step 1: Check if Setup is Needed
Open Depl0y web UI and go to **Settings** page. Look for the "Cloud Image Setup" section:
- **Green box (✅)**: Setup already complete! You're good to go.
- **Yellow box (⚠️)**: Setup required. Continue to Step 2.
### Step 2: Run the Setup Script
The setup script is already on your Depl0y server at `/tmp/enable_cloud_images.sh`.
**⚠️ IMPORTANT: Run this script ON YOUR DEPL0Y SERVER (not on Proxmox!)**
The script is located on your Depl0y server at `/tmp/enable_cloud_images.sh`. It will automatically connect to Proxmox to set up SSH access.
**Method 1: Copy from Web UI (Recommended)**
1. In the Settings page, click the **"Copy"** button next to the setup command
2. SSH into your **Depl0y server** (the server where Depl0y is installed):
```bash
ssh administrator@your-depl0y-server
# Or: ssh administrator@deploy
```
3. Paste and run the command:
```bash
sudo /tmp/enable_cloud_images.sh
```
**Method 2: Manual Command**
If you're already logged into your Depl0y server, just run:
```bash
sudo /tmp/enable_cloud_images.sh
```
**What Happens:**
1. Script runs **on Depl0y server** (where you run the command)
2. Generates SSH keys **on Depl0y server**
3. Prompts you for **Proxmox root password**
4. Uses SSH to copy the key **from Depl0y to Proxmox**
5. Verifies connection works
### Step 3: Enter Proxmox Password
The script will prompt you:
```
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Please enter the ROOT PASSWORD for Proxmox server: pve.agit8or.net
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Password:
```
**Enter your Proxmox root password** (the password will not be displayed as you type for security).
### Step 4: Wait for Completion
The script will:
1. ✓ Check if `sshpass` is installed (installs if needed)
2. ✓ Generate SSH key pair (if not exists)
3. ✓ Copy SSH public key to Proxmox
4. ✓ Verify SSH connection works
You'll see:
```
╔════════════════════════════════════════════════════════════╗
║ ✅ SUCCESS! ║
╚════════════════════════════════════════════════════════════╝
Cloud images are now fully configured!
🎉 What happens now:
• Go to the web UI and create a VM
• Select any cloud image (Ubuntu, Debian, etc.)
• Click 'Create VM'
• The system will automatically:
- Download the cloud image to Proxmox (first time only)
- Create a bootable template (first time only)
- Clone and deploy your VM with your credentials
- Start the VM ready to use!
First deployment per cloud image: ~5-10 minutes
All subsequent deployments: ~30 seconds
✨ Everything is automatic from now on!
```
### Step 5: Verify in Web UI
1. Go back to **Settings** page in Depl0y web UI
2. Click **"Re-check Status"** button
3. You should see a **green success box**: "Cloud Images Enabled! ✅"
**Setup is complete!** You never have to do this again.
---
## How to Use Cloud Images
### Creating Your First Cloud Image VM
1. **Go to Create VM Page**
- Click "Create VM" in the navigation menu
2. **Basic Configuration**
- **Name**: Enter a name for your VM (e.g., "ubuntu-web-01")
- **Datacenter**: Select your Proxmox datacenter
- **Node**: Select which Proxmox node to deploy on
3. **Installation Method**
- Select **"Cloud Image (Fast)"**
- Choose a cloud image from the dropdown:
- Ubuntu 24.04 LTS
- Ubuntu 22.04 LTS
- Ubuntu 20.04 LTS
- Debian 12
- Debian 11
4. **Resources**
- **CPU Cores**: 2 (or more)
- **Memory (RAM)**: 2048 MB (or more)
- **Disk Size**: 20 GB (or more)
5. **Storage & Network**
- **Storage Pool**: Select where to store the VM disk
- **Network Bridge**: Select network (usually vmbr0)
6. **Cloud-Init Configuration**
- **Username**: Your desired username (e.g., "admin", "ubuntu")
- **Password**: Your desired password
- **SSH Public Key** (optional): Paste your SSH public key for key-based auth
- **IP Configuration**: Choose DHCP or Static IP
7. **Advanced Options** (optional)
- CPU Type
- BIOS (SeaBIOS or UEFI)
- VGA Type
- Boot Order
8. **Click "Create VM"**
### What Happens Next?
**First Time Using This Cloud Image:**
- Status: "Setting up cloud image (first time - takes ~5 min)..."
- The system automatically:
1. Downloads the cloud image to Depl0y server (~300-700 MB)
2. Uploads it to Proxmox via SSH
3. Creates a VM template on Proxmox
4. Clones the template to create your VM
5. Configures cloud-init with your credentials
6. Starts the VM
**Time: 5-10 minutes** ⏱️
**Every Time After (Same Cloud Image):**
- Status: "Cloning template..."
- The system automatically:
1. Clones existing template (instant!)
2. Configures cloud-init with your credentials
3. Starts the VM
**Time: 30 seconds** ⚡
### Accessing Your VM
Once the VM is created and started:
**SSH Access:**
```bash
ssh username@vm-ip-address
```
**Console Access:**
- Click on the VM in Depl0y
- Click "Console" button
- Login with your configured username/password
---
## Available Cloud Images
### Ubuntu
| Image | Version | Use Case | LTS Until |
|-------|---------|----------|-----------|
| Ubuntu 24.04 LTS | Noble Numbat | Latest features | April 2029 |
| Ubuntu 22.04 LTS | Jammy Jellyfish | Stable production | April 2027 |
| Ubuntu 20.04 LTS | Focal Fossa | Legacy apps | April 2025 |
### Debian
| Image | Version | Use Case |
|-------|---------|----------|
| Debian 12 | Bookworm | Latest stable |
| Debian 11 | Bullseye | Previous stable |
**More cloud images can be added!** Contact your administrator or submit a feature request.
---
## How It Works (Technical)
### Architecture Overview
```
┌─────────────────┐
│ Depl0y Server │
│ │
│ 1. Downloads │──────┐
│ cloud image │ │
└─────────────────┘ │
│ SCP Upload
┌─────────────────┐
│ Proxmox Server │
│ │
│ 2. Import disk │
│ 3. Convert to │
│ template │
│ │
│ Template ID: │
│ 9001, 9002... │
└─────────────────┘
│ Clone via API
┌─────────────────┐
│ New VM │
│ + Cloud-init │
│ configured │
└─────────────────┘
```
### Template ID System
Cloud images are converted to Proxmox templates with predictable IDs:
- Template ID = `9000 + cloud_image_id`
- Ubuntu 24.04 (id=1) → Template 9001
- Ubuntu 22.04 (id=2) → Template 9002
- Debian 12 (id=3) → Template 9003
- etc.
### Template Creation Process
**First VM deployment from a cloud image:**
1. **Download** (Depl0y server):
```bash
wget https://cloud-images.ubuntu.com/.../ubuntu-24.04-server-cloudimg-amd64.img
# Stored in: /var/lib/depl0y/cloud-images/
```
2. **Upload to Proxmox** (via SSH):
```bash
scp ubuntu-24.04-server-cloudimg-amd64.img root@pve.agit8or.net:/tmp/
```
3. **Create Template VM** (via SSH on Proxmox):
```bash
# Create empty VM
qm create 9001 --name Ubuntu-24.04-LTS --memory 2048 --cores 2 \
--net0 virtio,bridge=vmbr0 --ostype l26 --scsihw virtio-scsi-pci
# Import cloud image as disk
qm importdisk 9001 /tmp/ubuntu-24.04-server-cloudimg-amd64.img local-lvm --format qcow2
# Configure disk and boot
qm set 9001 --scsi0 local-lvm:vm-9001-disk-0
qm set 9001 --boot order=scsi0
# Add cloud-init drive
qm set 9001 --ide2 local-lvm:cloudinit
# Convert to template
qm template 9001
```
4. **Clone Template** (via Proxmox API):
```python
proxmox.nodes(node).qemu(9001).clone.post(
newid=vm_id,
name=vm_name,
full=1, # Full clone
storage=storage
)
```
5. **Configure Cloud-init** (via Proxmox API):
```python
proxmox.nodes(node).qemu(vm_id).config.set(
ciuser=username,
cipassword=password,
ipconfig0=network_config,
sshkeys=ssh_public_key
)
```
### Subsequent Deployments
**All VMs after the first:**
- **Skip steps 1-3** (template already exists!)
- **Only run steps 4-5** (clone + configure)
- **Result**: 30-second deployments
### Why SSH is Required
The `qm importdisk` command does not have a Proxmox API endpoint. It can only be executed via SSH on the Proxmox server. This is why SSH setup is required for the **first** deployment of each cloud image type.
**After template creation:**
- All operations use pure Proxmox API
- No SSH required for VM cloning
- Fast and efficient deployments
### Security
- **SSH Key Authentication**: Password-free after setup
- **Public Key Only**: Only public key stored on Proxmox
- **No Password Storage**: Proxmox password never stored in database
- **Template Isolation**: Templates separate from user VMs
- **Cloud-init Encryption**: Credentials encrypted in transit
---
## Troubleshooting
### Error: "SSH access not configured"
**Symptoms:**
```
Error: SSH access not configured. Please run this ONE-TIME setup command:
sudo /tmp/enable_cloud_images.sh
After that, cloud images will deploy automatically!
```
**Solution:**
1. SSH to your Depl0y server
2. Run: `sudo /tmp/enable_cloud_images.sh`
3. Enter your Proxmox root password when prompted
4. Wait for "✅ SUCCESS!" message
5. Try creating the VM again
### SSH Setup Script Fails
**Error: "Permission denied (publickey,password)"**
**Possible Causes:**
- Wrong Proxmox password
- SSH password authentication disabled on Proxmox
**Solution 1: Verify Password**
```bash
# Test SSH with password manually
ssh root@pve.agit8or.net
# If this fails with your password, reset Proxmox root password
```
**Solution 2: Enable Password Auth (if disabled)**
On your Proxmox server, edit SSH config:
```bash
sudo nano /etc/ssh/sshd_config
```
Find and change:
```
PasswordAuthentication yes
```
Restart SSH:
```bash
sudo systemctl restart sshd
```
Then run the setup script again.
**Solution 3: Manual SSH Key Setup**
If the automated script fails, set up SSH manually:
1. On Depl0y server:
```bash
sudo -u depl0y ssh-keygen -t rsa -b 4096 -f /opt/depl0y/.ssh/id_rsa -N ""
sudo -u depl0y cat /opt/depl0y/.ssh/id_rsa.pub
```
2. Copy the public key output
3. On Proxmox server:
```bash
mkdir -p ~/.ssh
chmod 700 ~/.ssh
nano ~/.ssh/authorized_keys
# Paste the public key, save and exit
chmod 600 ~/.ssh/authorized_keys
```
4. Test from Depl0y server:
```bash
sudo -u depl0y ssh root@pve.agit8or.net "echo test"
```
### VM Created But Won't Boot
**Symptoms:**
- VM starts but no OS loads
- Black screen or BIOS errors
- "No bootable device" error
**Cause:**
Template doesn't exist or is corrupt.
**Solution:**
Delete the template and let it recreate:
1. Check which template exists:
```bash
ssh root@pve.agit8or.net "qm list | grep 900"
```
2. Delete the problematic template:
```bash
# For Ubuntu 24.04 (template 9001)
ssh root@pve.agit8or.net "qm destroy 9001"
```
3. Try creating the VM again - template will be recreated automatically
### Cloud-init Not Working
**Symptoms:**
- Can't login with configured credentials
- Default username/password required
- Network not configured
**Cause:**
Cloud-init configuration failed or not applied.
**Solution:**
1. Check cloud-init logs on the VM:
```bash
# Login via console with default credentials
sudo cat /var/log/cloud-init.log
sudo cat /var/log/cloud-init-output.log
```
2. Verify cloud-init config in Proxmox:
```bash
ssh root@pve.agit8or.net "qm cloudinit dump <vmid> user"
```
3. Manually set cloud-init (if needed):
```bash
# Via Proxmox UI:
# VM → Cloud-Init → Edit settings
```
### Template Creation Stuck
**Symptoms:**
- Status shows "Setting up cloud image..." for 20+ minutes
- No progress
**Cause:**
- Network issue downloading cloud image
- Proxmox storage full
- SSH connection lost
**Check Logs:**
```bash
sudo journalctl -u depl0y-backend -f --no-pager
```
**Solutions:**
1. **Check download progress:**
```bash
ls -lh /var/lib/depl0y/cloud-images/
# If file is growing, download is in progress
```
2. **Check Proxmox storage:**
```bash
ssh root@pve.agit8or.net "df -h"
# Ensure storage has 5+ GB free
```
3. **Check SSH connection:**
```bash
sudo -u depl0y ssh root@pve.agit8or.net "echo test"
```
4. **Restart the deployment:**
- Delete the partially created template
- Try creating the VM again
### Check Templates on Proxmox
List all templates:
```bash
ssh root@pve.agit8or.net "qm list | grep 900"
```
Example output:
```
9001 Ubuntu-24.04-LTS 0 2048 0.00 0
9002 Ubuntu-22.04-LTS 0 2048 0.00 0
```
Delete a template:
```bash
ssh root@pve.agit8or.net "qm destroy 9001"
```
### Verify SSH Status from Command Line
**Test SSH access:**
```bash
sudo -u depl0y ssh -o BatchMode=yes -o ConnectTimeout=5 \
-o StrictHostKeyChecking=no root@pve.agit8or.net "echo test"
```
**If successful, you'll see:**
```
test
```
**If not configured:**
```
Permission denied (publickey,password).
```
---
## FAQ
### Q: Do I need to run the setup script for every VM?
**A:** No! The setup script is run **once** per Depl0y installation. After that, all cloud image deployments are automatic.
### Q: Do I need to run the setup script for each cloud image?
**A:** No! One setup enables **all** cloud images. The first deployment of each cloud image type takes 5-10 minutes to create the template, then all subsequent deployments are 30 seconds.
### Q: What if I add a new Proxmox node?
**A:** The SSH key is configured per Proxmox host, not per node. If you add a node to an existing cluster, no new setup is needed. If you add a completely new Proxmox host/cluster, you'll need to run the setup script once for that new host.
### Q: Can I use cloud images and ISO images?
**A:** Yes! Both installation methods work side-by-side. Use cloud images for quick deployments, ISOs for custom installations or distros without cloud images.
### Q: Where are cloud images stored?
**A:**
- **Depl0y server**: `/var/lib/depl0y/cloud-images/` (original downloads)
- **Proxmox server**: Imported as disks in configured storage (e.g., local-lvm)
### Q: How much disk space do cloud images use?
**A:**
- **Download**: 300-700 MB per cloud image
- **Template on Proxmox**: 2-3 GB per template
- **Cloned VMs**: Your configured disk size (20+ GB recommended)
### Q: Can I customize the cloud images?
**A:** Cloud images are standard upstream images from Ubuntu/Debian. You can:
- Customize credentials (via cloud-init)
- Customize network (via cloud-init)
- Add SSH keys (via cloud-init)
- Install software after deployment (via Ansible, scripts, etc.)
If you need pre-customized images, consider using Packer to build custom images.
### Q: What happens if template creation fails?
**A:** The deployment will fail with an error message. Check the logs, fix the issue (usually SSH or storage), and try again. The system will automatically retry template creation.
### Q: Can I delete templates?
**A:** Yes, but if you delete a template, the next VM deployment for that cloud image will take 5-10 minutes to recreate the template. Templates are safe to delete if you need to free space.
### Q: How do I add more cloud images?
**A:** Cloud images are defined in the database. You can:
1. Contact your Depl0y administrator
2. Submit a feature request
3. Manually add via database (advanced users)
### Q: Does cloud-init work on all images?
**A:** Yes! All official Ubuntu and Debian cloud images have cloud-init pre-installed and configured. That's what makes them "cloud images."
### Q: Can I use Windows cloud images?
**A:** Windows doesn't have official cloud images like Linux. For Windows VMs, use the traditional ISO installation method. You can create custom Windows templates manually in Proxmox.
### Q: What if I don't want to give Proxmox root SSH access?
**A:** SSH root access is required for the `qm importdisk` command during template creation. This is a Proxmox limitation. For security:
- SSH uses key-based auth (no password storage)
- SSH only used during template creation
- All subsequent operations use Proxmox API
- You can disable SSH after all templates are created (though you'll need to re-enable for new cloud image types)
### Q: How do I know which template ID corresponds to which cloud image?
**A:** Template ID = 9000 + cloud_image_id. You can check cloud image IDs in the database or via the API. Common mappings:
- 9001: Ubuntu 24.04 LTS
- 9002: Ubuntu 22.04 LTS
- 9003: Ubuntu 20.04 LTS
- 9004: Debian 12
- 9005: Debian 11
---
## Additional Resources
### Check System Logs
```bash
# Backend logs
sudo journalctl -u depl0y-backend -f --no-pager
# Last 50 lines
sudo journalctl -u depl0y-backend -n 50 --no-pager
```
### Test SSH Manually
```bash
# As depl0y user
sudo -u depl0y ssh root@pve.agit8or.net "qm list"
# Check SSH key
sudo -u depl0y cat /opt/depl0y/.ssh/id_rsa.pub
```
### View Cloud Images in Database
```bash
sudo -u depl0y sqlite3 /var/lib/depl0y/db/depl0y.db "SELECT id, name, filename, is_downloaded FROM cloud_images WHERE is_available=1;"
```
### File Locations
- **Setup script**: `/tmp/enable_cloud_images.sh`
- **Cloud image downloads**: `/var/lib/depl0y/cloud-images/`
- **SSH keys**: `/opt/depl0y/.ssh/`
- **Backend code**: `/opt/depl0y/backend/app/services/deployment.py`
- **Database**: `/var/lib/depl0y/db/depl0y.db`
---
## Support
If you encounter issues not covered in this guide:
1. **Check logs**: `sudo journalctl -u depl0y-backend -f`
2. **Verify SSH**: `sudo -u depl0y ssh root@pve.agit8or.net "echo test"`
3. **Check templates**: `ssh root@pve.agit8or.net "qm list | grep 900"`
4. **Contact support** or submit a bug report via the web UI
---
**Happy deploying!** 🚀
+139
View File
@@ -0,0 +1,139 @@
# Cloud Images - Quick Start Guide
## What Are Cloud Images?
Cloud images let you deploy VMs in **30 seconds** instead of 20 minutes. No manual OS installation needed!
---
## One-Time Setup (Takes 1 Minute)
### Step 1: Check Status in Web UI
Go to **Settings** → Look for **"Cloud Image Setup"** section
- **Green box (✅)**: Already configured! Skip to "Using Cloud Images" below.
- **Yellow box (⚠️)**: Setup needed. Continue to Step 2.
### Step 2: Run Setup Script
**IMPORTANT:** Run this script **ON YOUR DEPL0Y SERVER** (not on Proxmox!)
**Option A: Copy from Web UI**
1. Click the **"Copy"** button in Settings page
2. SSH to your **Depl0y server** (hostname: `deploy`)
3. Paste and run the command
**Option B: Manual Command**
SSH to your **Depl0y server** and run:
```bash
sudo /tmp/enable_cloud_images.sh
```
**If you're already logged into the Depl0y server:**
Just run the command directly - no need to SSH anywhere!
### Step 3: Enter Password
When prompted, enter your **Proxmox root password**:
```
Password: [your-proxmox-root-password]
```
(Password won't be displayed as you type - this is normal)
### Step 4: Done!
You'll see:
```
✅ SUCCESS!
Cloud images are now fully configured!
```
Go back to **Settings** in web UI and click **"Re-check Status"** to verify.
---
## Using Cloud Images
### Create a VM:
1. Go to **"Create VM"** in web UI
2. Select **"Cloud Image (Fast)"** installation method
3. Choose a cloud image:
- Ubuntu 24.04 LTS
- Ubuntu 22.04 LTS
- Ubuntu 20.04 LTS
- Debian 12
- Debian 11
4. Configure CPU, RAM, disk size
5. Enter your desired **username** and **password**
6. Click **"Create VM"**
### Deployment Times:
- **First time using a cloud image**: 5-10 minutes (creates template)
- **Every time after that**: 30 seconds ⚡
---
## Troubleshooting
### Error: "SSH access not configured"
**Fix:** Run the setup script again:
```bash
sudo /tmp/enable_cloud_images.sh
```
### Setup script fails with "Permission denied"
**Possible causes:**
- Wrong password
- SSH password auth disabled on Proxmox
**Fix:** Try password again, or manually enable SSH password authentication on Proxmox.
### VM boots but no OS
**Cause:** Template is missing or corrupt
**Fix:** Delete and recreate template:
```bash
ssh root@pve.agit8or.net "qm destroy 9001"
```
Then create a new VM - template will be recreated.
### Can't login to VM
**Cause:** Cloud-init didn't configure credentials
**Fix:** Check cloud-init logs via Proxmox console:
```bash
sudo cat /var/log/cloud-init.log
```
---
## Get Help
- **Full documentation**: `CLOUD_IMAGES_GUIDE.md`
- **Check logs**: `sudo journalctl -u depl0y-backend -f`
- **Verify SSH**: `sudo -u depl0y ssh root@pve.agit8or.net "echo test"`
- **Check templates**: `ssh root@pve.agit8or.net "qm list | grep 900"`
---
## Summary
**One-time setup**: 1 minute
**Maintenance**: Zero
**Deployment time**: 30 seconds
**Manual installation**: Never again
**Questions?** See full guide: `CLOUD_IMAGES_GUIDE.md`
+132
View File
@@ -0,0 +1,132 @@
# Contributing to Depl0y
Thank you for your interest in contributing to Depl0y! We welcome contributions from everyone.
## How Can I Contribute?
### Reporting Bugs
Before submitting a bug report:
- Check the [existing issues](https://github.com/yourusername/depl0y/issues) to avoid duplicates
- Gather information about the bug
- Try to reproduce it with the latest version
When submitting a bug report, include:
- Clear title and description
- Steps to reproduce
- Expected vs actual behavior
- Screenshots if applicable
- Environment details (OS, Docker version, etc.)
- Relevant logs
### Suggesting Enhancements
Enhancement suggestions are tracked as GitHub issues. When creating an enhancement suggestion:
- Use a clear, descriptive title
- Provide detailed description of the proposed feature
- Explain why this enhancement would be useful
- List any alternative solutions you've considered
### Pull Requests
1. **Fork the repository**
2. **Create a branch** from `main`:
```bash
git checkout -b feature/my-feature
```
3. **Make your changes**
4. **Test thoroughly**
5. **Commit with clear messages**:
```bash
git commit -m "Add feature: description"
```
6. **Push to your fork**:
```bash
git push origin feature/my-feature
```
7. **Open a Pull Request**
## Development Setup
### Backend
```bash
cd backend
python3 -m venv venv
source venv/bin/activate
pip install -r requirements.txt
uvicorn app.main:app --reload
```
### Frontend
```bash
cd frontend
npm install
npm run dev
```
### Database
```bash
docker-compose up -d db
```
## Code Style
### Python
- Follow PEP 8
- Use type hints
- Write docstrings for functions and classes
- Maximum line length: 100 characters
### JavaScript/Vue
- Use ES6+ features
- Follow Vue.js style guide
- Use meaningful variable names
- Add comments for complex logic
### Commits
- Use present tense ("Add feature" not "Added feature")
- Reference issues and PRs when applicable
- Keep commits focused and atomic
## Testing
### Backend Tests
```bash
cd backend
pytest
```
### Frontend Tests
```bash
cd frontend
npm run test
```
## Documentation
- Update README.md if needed
- Add docstrings to new functions
- Update user guide for new features
- Include inline comments for complex code
## Community
- Be respectful and inclusive
- Help others learn and grow
- Follow the [Code of Conduct](CODE_OF_CONDUCT.md)
## Questions?
Feel free to open an issue or discussion if you have questions!
## License
By contributing, you agree that your contributions will be licensed under the MIT License.
+571
View File
@@ -0,0 +1,571 @@
# Depl0y Deployment Guide
This guide covers how to deploy Depl0y updates, make code changes, and manage the production environment.
## Table of Contents
- [Development Workflow](#development-workflow)
- [Making Code Changes](#making-code-changes)
- [Deploying Changes](#deploying-changes)
- [Creating a Release](#creating-a-release)
- [Update Distribution](#update-distribution)
- [Rollback Procedures](#rollback-procedures)
---
## Development Workflow
### Project Structure
```
/home/administrator/depl0y/ # Development directory
├── backend/ # Python FastAPI backend
│ ├── app/
│ │ ├── api/ # API endpoints
│ │ ├── core/ # Core functionality
│ │ ├── models/ # Database models
│ │ └── services/ # Business logic
│ ├── requirements.txt
│ └── main.py
├── frontend/ # Vue.js frontend
│ ├── src/
│ │ ├── views/ # Page components
│ │ ├── services/ # API services
│ │ ├── store/ # State management
│ │ └── router/ # Routes
│ ├── package.json
│ └── vite.config.js
├── scripts/ # Deployment scripts
├── install.sh # One-line installer
└── *.md # Documentation
/opt/depl0y/ # Production directory
├── backend/ # Production backend
├── frontend/dist/ # Built frontend assets
└── ...
/var/lib/depl0y/ # Data directory
├── db/ # SQLite database
├── isos/ # ISO images
├── cloud-images/ # Cloud image cache
└── ssh_keys/ # SSH keys
```
---
## Making Code Changes
### Backend Changes
1. **Edit Backend Code**
```bash
cd /home/administrator/depl0y/backend
# Edit files in app/ directory
```
2. **Test Changes Locally (Optional)**
```bash
cd /home/administrator/depl0y/backend
source venv/bin/activate
uvicorn app.main:app --reload --host 127.0.0.1 --port 8001
```
3. **Check Logs**
```bash
sudo journalctl -u depl0y-backend -f
```
### Frontend Changes
1. **Edit Frontend Code**
```bash
cd /home/administrator/depl0y/frontend
# Edit files in src/ directory
```
2. **Test Changes Locally (Optional)**
```bash
cd /home/administrator/depl0y/frontend
npm run dev
# Access at http://localhost:5173
```
3. **Check Console**
- Open browser Developer Tools (F12)
- Check Console for errors
- Check Network tab for API calls
---
## Deploying Changes
### Quick Deploy Script
Create a deployment script for convenience:
```bash
cat > /home/administrator/depl0y/deploy.sh << 'EOF'
#!/bin/bash
set -e
echo "🚀 Deploying Depl0y..."
# Build frontend
echo "📦 Building frontend..."
cd /home/administrator/depl0y/frontend
npm run build
# Deploy frontend
echo "📤 Deploying frontend..."
sudo rm -rf /opt/depl0y/frontend/dist/*
sudo cp -r dist/* /opt/depl0y/frontend/dist/
sudo chown -R www-data:www-data /opt/depl0y/frontend/dist
sudo chmod -R 755 /opt/depl0y/frontend/dist
# Deploy backend
echo "📤 Deploying backend..."
sudo cp -r /home/administrator/depl0y/backend/* /opt/depl0y/backend/
sudo chown -R depl0y:depl0y /opt/depl0y/backend
sudo chmod -R 755 /opt/depl0y/backend
# Restart services
echo "🔄 Restarting services..."
sudo systemctl restart depl0y-backend
sudo systemctl reload nginx
# Check status
echo "✅ Checking status..."
sleep 2
sudo systemctl status depl0y-backend --no-pager | head -15
echo ""
echo "✅ Deployment complete!"
echo "Check logs: sudo journalctl -u depl0y-backend -f"
EOF
chmod +x /home/administrator/depl0y/deploy.sh
```
### Deploy with Script
```bash
/home/administrator/depl0y/deploy.sh
```
### Manual Deployment Steps
#### Deploy Backend Only
```bash
# Copy backend files to production
sudo cp -r /home/administrator/depl0y/backend/* /opt/depl0y/backend/
# Fix permissions
sudo chown -R depl0y:depl0y /opt/depl0y/backend
sudo chmod -R 755 /opt/depl0y/backend
# Restart backend service
sudo systemctl restart depl0y-backend
# Check status
sudo systemctl status depl0y-backend
sudo journalctl -u depl0y-backend -n 50
```
#### Deploy Frontend Only
```bash
# Build frontend
cd /home/administrator/depl0y/frontend
npm run build
# Deploy to production
sudo rm -rf /opt/depl0y/frontend/dist/*
sudo cp -r dist/* /opt/depl0y/frontend/dist/
# Fix permissions
sudo chown -R www-data:www-data /opt/depl0y/frontend/dist
sudo chmod -R 755 /opt/depl0y/frontend/dist
# Reload nginx (optional, usually not needed)
sudo systemctl reload nginx
```
#### Deploy Both (Full Deploy)
```bash
# Build frontend
cd /home/administrator/depl0y/frontend
npm run build
# Deploy frontend
sudo rm -rf /opt/depl0y/frontend/dist/*
sudo cp -r dist/* /opt/depl0y/frontend/dist/
sudo chown -R www-data:www-data /opt/depl0y/frontend/dist
sudo chmod -R 755 /opt/depl0y/frontend/dist
# Deploy backend
sudo cp -r /home/administrator/depl0y/backend/* /opt/depl0y/backend/
sudo chown -R depl0y:depl0y /opt/depl0y/backend
sudo chmod -R 755 /opt/depl0y/backend
# Restart services
sudo systemctl restart depl0y-backend
sudo systemctl reload nginx
# Verify
sudo systemctl status depl0y-backend
```
---
## Creating a Release
### 1. Update Version Number
Edit the version in the backend config:
```bash
nano /home/administrator/depl0y/backend/app/core/config.py
```
Change:
```python
APP_VERSION: str = "1.1.0" # Update this
```
### 2. Update Release Notes
Edit the system updates endpoint:
```bash
nano /home/administrator/depl0y/backend/app/api/system_updates.py
```
Update the `release_notes` in the `/version` endpoint:
```python
"release_notes": f"""
Depl0y {settings.APP_VERSION} Release Notes:
✨ New Features:
- Feature 1
- Feature 2
🔧 Improvements:
- Improvement 1
- Improvement 2
🐛 Bug Fixes:
- Fix 1
- Fix 2
"""
```
### 3. Deploy to Production
```bash
/home/administrator/depl0y/deploy.sh
```
### 4. Test Update Endpoint
```bash
curl http://localhost/api/v1/system-updates/version
```
Verify the version and release notes are correct.
---
## Update Distribution
Depl0y uses a **pull-based update system** where client instances pull updates from the main server (`deploy.agit8or.net`).
### How Updates Work
1. **Main Server** (deploy.agit8or.net)
- Serves version information via `/api/v1/system-updates/version`
- Provides update packages via `/api/v1/system-updates/download`
- Hosts the installer via `/install.sh`
2. **Client Instances**
- Check for updates by querying main server
- Compare local version with latest version
- Download and apply updates if available
### Making Main Server the Update Source
If this is your main update server (`deploy.agit8or.net`):
1. **Ensure the installer is accessible**
```bash
# The installer should be served by nginx
curl http://deploy.agit8or.net/install.sh
```
2. **Update the backend config if needed**
```bash
nano /home/administrator/depl0y/backend/app/api/system_updates.py
```
Verify:
```python
UPDATE_SERVER = "http://deploy.agit8or.net"
```
3. **Test the update endpoints**
```bash
# Version info
curl http://deploy.agit8or.net/api/v1/system-updates/version
# Download package (requires auth)
curl -H "Authorization: Bearer YOUR_TOKEN" \
http://deploy.agit8or.net/api/v1/system-updates/download \
-o test-package.tar.gz
```
### Client Update Process
When a client checks for updates (Settings → System Updates):
1. Client calls `/api/v1/system-updates/check`
2. Backend queries main server at `http://deploy.agit8or.net/api/v1/system-updates/version`
3. Compares versions
4. If update available, shows "Install Update" button
5. When clicked, downloads from `http://deploy.agit8or.net/api/v1/system-updates/download`
6. Extracts, builds, deploys, and restarts
---
## Rollback Procedures
### Automatic Backups
The update system creates automatic backups at:
```
/opt/depl0y-backups/backup-YYYYMMDD-HHMMSS/
```
### Manual Rollback
1. **Stop the service**
```bash
sudo systemctl stop depl0y-backend
```
2. **Restore from backup**
```bash
# Find latest backup
ls -la /opt/depl0y-backups/
# Restore backend
sudo cp -r /opt/depl0y-backups/backup-YYYYMMDD-HHMMSS/backend/* /opt/depl0y/backend/
# Restore frontend (if backed up)
sudo cp -r /opt/depl0y-backups/backup-YYYYMMDD-HHMMSS/frontend/* /opt/depl0y/frontend/
```
3. **Fix permissions**
```bash
sudo chown -R depl0y:depl0y /opt/depl0y/backend
sudo chown -R www-data:www-data /opt/depl0y/frontend/dist
```
4. **Restart service**
```bash
sudo systemctl start depl0y-backend
sudo systemctl status depl0y-backend
```
### Manual Backup Before Changes
```bash
# Create backup directory
BACKUP_DIR="/opt/depl0y-backups/manual-$(date +%Y%m%d-%H%M%S)"
sudo mkdir -p "$BACKUP_DIR"
# Backup backend
sudo cp -r /opt/depl0y/backend "$BACKUP_DIR/"
# Backup frontend
sudo cp -r /opt/depl0y/frontend "$BACKUP_DIR/"
# Backup database
sudo cp -r /var/lib/depl0y/db "$BACKUP_DIR/"
echo "Backup created at $BACKUP_DIR"
```
---
## Common Deployment Issues
### Issue: Backend Service Won't Start
**Check logs:**
```bash
sudo journalctl -u depl0y-backend -n 100
```
**Common causes:**
- Python syntax errors
- Missing dependencies
- Database connection issues
- Port already in use
**Fix:**
```bash
# Check if port 8000 is in use
sudo lsof -i :8000
# Reinstall dependencies if needed
cd /opt/depl0y/backend
sudo -u depl0y venv/bin/pip install -r requirements.txt
```
### Issue: Frontend Shows Blank Page
**Check:**
1. Browser console (F12) for JavaScript errors
2. Nginx error logs: `sudo tail -f /var/log/nginx/depl0y_error.log`
3. Verify build was successful
4. Check file permissions
**Fix:**
```bash
# Rebuild and redeploy
cd /home/administrator/depl0y/frontend
npm run build
sudo rm -rf /opt/depl0y/frontend/dist/*
sudo cp -r dist/* /opt/depl0y/frontend/dist/
sudo chown -R www-data:www-data /opt/depl0y/frontend/dist
sudo chmod -R 755 /opt/depl0y/frontend/dist
```
### Issue: API Calls Failing (500 Errors)
**Check backend logs:**
```bash
sudo journalctl -u depl0y-backend -f
```
**Common causes:**
- Backend crashed
- Database errors
- API endpoint errors
**Quick restart:**
```bash
sudo systemctl restart depl0y-backend
```
### Issue: Changes Not Appearing
**For backend:**
```bash
# Make sure you restarted the service
sudo systemctl restart depl0y-backend
# Verify files were copied
ls -la /opt/depl0y/backend/app/api/
```
**For frontend:**
```bash
# Make sure you rebuilt
cd /home/administrator/depl0y/frontend
npm run build
# Verify files were copied
ls -la /opt/depl0y/frontend/dist/
# Hard refresh browser (Ctrl+Shift+R or Cmd+Shift+R)
```
---
## Monitoring and Maintenance
### Check Service Status
```bash
sudo systemctl status depl0y-backend
sudo systemctl status nginx
```
### View Logs
```bash
# Live backend logs
sudo journalctl -u depl0y-backend -f
# Last 100 lines
sudo journalctl -u depl0y-backend -n 100
# Nginx access logs
sudo tail -f /var/log/nginx/depl0y_access.log
# Nginx error logs
sudo tail -f /var/log/nginx/depl0y_error.log
```
### Restart Services
```bash
# Backend only
sudo systemctl restart depl0y-backend
# Nginx only
sudo systemctl reload nginx
# Both
sudo systemctl restart depl0y-backend
sudo systemctl reload nginx
```
### Check Disk Space
```bash
df -h /opt/depl0y
df -h /var/lib/depl0y
```
### Clean Up Old Backups
```bash
# List backups
ls -lah /opt/depl0y-backups/
# Remove old backups (keep last 5)
cd /opt/depl0y-backups/
ls -t | tail -n +6 | xargs sudo rm -rf
```
---
## Quick Reference
### Deploy Everything
```bash
cd /home/administrator/depl0y/frontend && npm run build && \
sudo rm -rf /opt/depl0y/frontend/dist/* && \
sudo cp -r dist/* /opt/depl0y/frontend/dist/ && \
sudo cp -r /home/administrator/depl0y/backend/* /opt/depl0y/backend/ && \
sudo chown -R www-data:www-data /opt/depl0y/frontend/dist && \
sudo chown -R depl0y:depl0y /opt/depl0y/backend && \
sudo systemctl restart depl0y-backend
```
### Check Everything
```bash
sudo systemctl status depl0y-backend nginx && \
curl -s http://localhost/api/v1/system-updates/version | head -5 && \
sudo journalctl -u depl0y-backend -n 10
```
### Emergency Restart
```bash
sudo systemctl restart depl0y-backend nginx && \
sleep 2 && \
sudo systemctl status depl0y-backend
```
---
**For more information, see:**
- [INSTALL.md](INSTALL.md) - Installation guide
- [README.md](README.md) - Project overview
- [CLOUD_IMAGES_GUIDE.md](CLOUD_IMAGES_GUIDE.md) - Cloud images setup
+163
View File
@@ -0,0 +1,163 @@
# Depl0y Installation Guide
## One-Line Installation
Install Depl0y with a single command:
```bash
curl -fsSL http://deploy.agit8or.net/install.sh | sudo bash
```
That's it! The installer will:
- Install all dependencies (Python, Node.js, nginx, etc.)
- Create the depl0y system user
- Download and install the latest version from deploy.agit8or.net
- Configure the backend service
- Configure nginx as reverse proxy
- Set up proper permissions
## After Installation
1. **Access the Web Interface**
```
http://YOUR_SERVER_IP
```
2. **Default Credentials**
```
Username: admin
Password: admin
```
⚠️ **IMPORTANT:** Change the default password immediately after first login!
3. **Add Your Proxmox Host**
- Go to Settings → Proxmox Hosts
- Add your Proxmox datacenter details
- Generate API token in Proxmox and add it
4. **Enable Cloud Images** (Optional but Recommended)
- Go to Settings → Cloud Images
- Click "🚀 Enable Cloud Images Now"
- Enter your Proxmox root password
- Wait ~30 seconds for automated setup
5. **Configure Inter-Node SSH** (For Multi-Node Clusters Only)
- Go to Settings → Proxmox Cluster Inter-Node SSH
- Click "🔐 Enable Inter-Node SSH"
- Enter your Proxmox root password
- Wait ~30 seconds for automated setup
## Manual Installation
If you prefer to install manually, see [README.md](README.md) for detailed instructions.
## Deployment Guide
For developers and operations teams who need to deploy code changes or manage updates:
📖 **See [DEPLOYMENT.md](DEPLOYMENT.md)** for complete deployment procedures including:
- Making and deploying code changes
- Creating releases and updates
- Rollback procedures
- Troubleshooting deployment issues
## Requirements
- Ubuntu 20.04+ or Debian 11+ (recommended)
- 2GB RAM minimum
- 20GB disk space minimum
- Root access
- Network access to your Proxmox server
## Update Mechanism
Depl0y includes an automatic update system:
1. **Check for Updates**
- Go to Settings → System Updates
- Click "🔍 Check for Updates"
2. **Install Updates**
- If an update is available, click "⬇️ Install Update"
- The system will download from deploy.agit8or.net
- Service restarts automatically
- Page reloads after update completes
## Source Server
The main Depl0y server is hosted at:
- **URL:** http://deploy.agit8or.net
- **Purpose:** Source for installations and updates
- **Updates:** All instances pull updates from this server
## Troubleshooting
### Installation Failed
```bash
# Check logs
sudo journalctl -u depl0y-backend -n 50
# Restart service
sudo systemctl restart depl0y-backend
# Check nginx
sudo nginx -t
sudo systemctl status nginx
```
### Cannot Access Web Interface
```bash
# Check if service is running
sudo systemctl status depl0y-backend
# Check nginx
sudo systemctl status nginx
# Check firewall
sudo ufw status
sudo ufw allow 80/tcp
```
### Database Issues
```bash
# Reset database (WARNING: Deletes all data!)
sudo systemctl stop depl0y-backend
sudo rm -rf /var/lib/depl0y/db/*
sudo systemctl start depl0y-backend
```
## Uninstallation
To completely remove Depl0y:
```bash
# Stop services
sudo systemctl stop depl0y-backend
sudo systemctl disable depl0y-backend
# Remove files
sudo rm -rf /opt/depl0y
sudo rm -rf /var/lib/depl0y
sudo rm /etc/systemd/system/depl0y-backend.service
sudo rm /etc/nginx/sites-enabled/depl0y
sudo rm /etc/nginx/sites-available/depl0y
sudo rm /etc/sudoers.d/depl0y
# Remove user
sudo userdel -r depl0y
# Reload services
sudo systemctl daemon-reload
sudo systemctl restart nginx
```
## Support
- **Documentation:** http://deploy.agit8or.net/docs
- **Issues:** Report bugs and request features on GitHub
- **Updates:** Automatic updates from deploy.agit8or.net
---
**Depl0y** - Automated VM Deployment Panel for Proxmox VE
+128
View File
@@ -0,0 +1,128 @@
# Proxmox API Tokens for 2FA Authentication
When your Proxmox VE server has Two-Factor Authentication (2FA) enabled, you need to use **API Tokens** instead of password authentication for programmatic access.
## Why API Tokens?
- **Bypass 2FA**: API tokens don't require 2FA codes, making them perfect for automated systems
- **More Secure**: Can be revoked without changing your main password
- **Fine-grained Permissions**: Can be restricted to specific operations
- **Audit Trail**: API token usage is logged separately
## Creating an API Token in Proxmox
### Step 1: Log into Proxmox Web Interface
1. Navigate to your Proxmox web interface: `https://your-proxmox-host:8006`
2. Log in with your credentials (including 2FA if enabled)
### Step 2: Navigate to API Tokens
1. Click on **Datacenter** in the left sidebar
2. Expand **Permissions**
3. Click on **API Tokens**
### Step 3: Create a New Token
1. Click the **Add** button at the top
2. Fill in the token details:
- **User**: Select the user (e.g., `root@pam`)
- **Token ID**: Give it a meaningful name (e.g., `depl0y`)
- **Privilege Separation**: **UNCHECK** this box (important!)
- Unchecking this gives the token the same permissions as the user
- Checking it would create a token with limited permissions
- **Expiration**: Leave empty for no expiration, or set an expiration date
3. Click **Add**
### Step 4: Save the Token Secret
**IMPORTANT**: After clicking Add, you'll see a screen showing the **Token Secret**.
```
Token ID: root@pam!depl0y
Token Secret: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
```
**Copy and save this secret immediately!** You won't be able to see it again.
## Using API Tokens in Depl0y
When adding a Proxmox host in Depl0y:
1. Go to **Proxmox Hosts****+ Add Host**
2. Fill in basic details:
- Name: Any name for your reference
- Hostname/IP: Your Proxmox server address
- Port: 8006 (default)
- Username: `root@pam` (the user who owns the token)
3. **Check** the box: "Use API Token (recommended for 2FA-enabled Proxmox)"
4. Enter token details:
- **API Token ID**: You can use either format:
- **Full format**: `root@pam!depl0y` (the complete Token ID from Proxmox)
- **Short format**: `depl0y` (just the token name)
- **API Token Secret**: The UUID-like secret you copied earlier
5. Optionally check "Verify SSL Certificate" if you have a valid SSL cert
6. Click **Add Host**
## Example
If Proxmox shows your token as:
```
Token ID: root@pam!mytoken
Token Secret: 12345678-1234-1234-1234-123456789abc
```
In Depl0y, you can enter **either**:
**Option 1 (Full format - Recommended):**
- Username: `root@pam` (can be anything, will be extracted from token)
- API Token ID: `root@pam!mytoken`
- API Token Secret: `12345678-1234-1234-1234-123456789abc`
**Option 2 (Short format):**
- Username: `root@pam`
- API Token ID: `mytoken`
- API Token Secret: `12345678-1234-1234-1234-123456789abc`
## Troubleshooting
### Connection Failed
- Make sure "Privilege Separation" was UNCHECKED when creating the token
- Confirm the token hasn't expired
- Check that the Proxmox host is reachable from your Depl0y server
- Verify the token secret is correct (it's case-sensitive)
- Try using the full token format: `root@pam!tokenname`
### Permission Denied
- Ensure "Privilege Separation" was unchecked when creating the token
- Verify the user account has sufficient permissions
- Check Proxmox logs: `/var/log/pve/tasks/`
### Token Not Working After Creation
- API tokens may need a few seconds to become active
- Try refreshing the Proxmox web interface
- Log out and back in to Proxmox
## Security Best Practices
1. **Use Separate Tokens**: Create different tokens for different applications
2. **Set Expiration**: Consider setting expiration dates for tokens
3. **Revoke Unused Tokens**: Regularly audit and remove tokens you no longer use
4. **Store Securely**: Keep token secrets secure, like passwords
5. **Monitor Usage**: Check Proxmox logs for API token usage
## Revoking a Token
To revoke an API token:
1. Go to **Datacenter****Permissions****API Tokens**
2. Select the token you want to revoke
3. Click **Remove**
4. Confirm the removal
The token will be immediately revoked and can no longer be used.
+344
View File
@@ -0,0 +1,344 @@
#!/bin/bash
# Depl0y Native Installation Script (No Docker)
# This script sets up Depl0y directly on the host system
set -e
echo "========================================="
echo " Depl0y Native Installation"
echo "========================================="
echo ""
# Check if running as root
if [ "$EUID" -ne 0 ]; then
echo "Please run as root (use sudo)"
exit 1
fi
# Get the script directory
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
echo "Installing to: $PROJECT_DIR"
echo ""
# Detect OS
if [ -f /etc/os-release ]; then
. /etc/os-release
OS=$ID
else
echo "Cannot detect OS. Exiting."
exit 1
fi
echo "Detected OS: $OS"
echo ""
# Install system dependencies
echo "Installing system dependencies..."
if [ "$OS" = "ubuntu" ] || [ "$OS" = "debian" ]; then
apt-get update
apt-get install -y \
python3 \
python3-venv \
python3-pip \
mariadb-server \
nginx \
nodejs \
npm \
gcc \
g++ \
libmariadb-dev \
pkg-config \
git
elif [ "$OS" = "centos" ] || [ "$OS" = "rhel" ] || [ "$OS" = "rocky" ] || [ "$OS" = "almalinux" ]; then
yum install -y epel-release
yum install -y \
python311 \
python3-pip \
mariadb-server \
nginx \
nodejs \
npm \
gcc \
gcc-c++ \
mariadb-devel \
git
else
echo "Unsupported OS: $OS"
exit 1
fi
echo "System dependencies installed"
echo ""
# Start and enable MariaDB
echo "Configuring MariaDB..."
systemctl enable mariadb
systemctl start mariadb
# Secure MariaDB installation
DB_ROOT_PASSWORD=$(openssl rand -hex 16)
mysql -e "ALTER USER 'root'@'localhost' IDENTIFIED BY '${DB_ROOT_PASSWORD}';" || true
mysql -u root -p"${DB_ROOT_PASSWORD}" -e "DELETE FROM mysql.user WHERE User='';" || true
mysql -u root -p"${DB_ROOT_PASSWORD}" -e "DELETE FROM mysql.user WHERE User='root' AND Host NOT IN ('localhost', '127.0.0.1', '::1');" || true
mysql -u root -p"${DB_ROOT_PASSWORD}" -e "DROP DATABASE IF EXISTS test;" || true
mysql -u root -p"${DB_ROOT_PASSWORD}" -e "DELETE FROM mysql.db WHERE Db='test' OR Db='test\\_%';" || true
mysql -u root -p"${DB_ROOT_PASSWORD}" -e "FLUSH PRIVILEGES;" || true
# Create database and user
DB_PASSWORD=$(openssl rand -hex 16)
mysql -u root -p"${DB_ROOT_PASSWORD}" << EOF
CREATE DATABASE IF NOT EXISTS depl0y;
CREATE USER IF NOT EXISTS 'depl0y'@'localhost' IDENTIFIED BY '${DB_PASSWORD}';
GRANT ALL PRIVILEGES ON depl0y.* TO 'depl0y'@'localhost';
FLUSH PRIVILEGES;
EOF
echo "MariaDB configured"
echo ""
# Create application user
echo "Creating depl0y user..."
useradd -r -s /bin/bash -d /opt/depl0y -m depl0y || true
# Create directories
echo "Creating directories..."
mkdir -p /opt/depl0y/{backend,frontend}
mkdir -p /var/lib/depl0y/{isos,cloud-init,ssh_keys}
mkdir -p /var/log/depl0y
mkdir -p /etc/depl0y
# Copy application files
echo "Copying application files..."
cp -r "$PROJECT_DIR/backend"/* /opt/depl0y/backend/
cp -r "$PROJECT_DIR/frontend"/* /opt/depl0y/frontend/
# Set permissions
chown -R depl0y:depl0y /opt/depl0y
chown -R depl0y:depl0y /var/lib/depl0y
chown -R depl0y:depl0y /var/log/depl0y
# Generate secrets
echo "Generating secrets..."
SECRET_KEY=$(openssl rand -hex 32)
ENCRYPTION_KEY=$(python3 -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())")
# Create environment file
cat > /etc/depl0y/config.env << EOF
# Database Configuration
DATABASE_URL=mysql+pymysql://depl0y:${DB_PASSWORD}@localhost:3306/depl0y
# Security
SECRET_KEY=${SECRET_KEY}
ENCRYPTION_KEY=${ENCRYPTION_KEY}
DEBUG=false
# Application Settings
LOG_LEVEL=INFO
LOG_FILE=/var/log/depl0y/app.log
# Storage Paths
ISO_STORAGE_PATH=/var/lib/depl0y/isos
CLOUDINIT_TEMPLATE_PATH=/var/lib/depl0y/cloud-init
SSH_KEY_PATH=/var/lib/depl0y/ssh_keys
# API
API_V1_PREFIX=/api/v1
EOF
chmod 600 /etc/depl0y/config.env
chown depl0y:depl0y /etc/depl0y/config.env
# Install Python dependencies
echo "Installing Python dependencies..."
cd /opt/depl0y/backend
sudo -u depl0y python3 -m venv venv
sudo -u depl0y /opt/depl0y/backend/venv/bin/pip install --upgrade pip
sudo -u depl0y /opt/depl0y/backend/venv/bin/pip install -r requirements.txt
# Initialize database
echo "Initializing database..."
cd /opt/depl0y/backend
sudo -u depl0y bash -c "source venv/bin/activate && source /etc/depl0y/config.env && python3 -c 'from app.core.database import init_db; init_db()'"
# Build frontend
echo "Building frontend..."
cd /opt/depl0y/frontend
sudo -u depl0y npm install
sudo -u depl0y npm run build
# Create systemd service for backend
echo "Creating systemd service..."
cat > /etc/systemd/system/depl0y-backend.service << 'EOF'
[Unit]
Description=Depl0y Backend API
After=network.target mariadb.service
Wants=mariadb.service
[Service]
Type=simple
User=depl0y
Group=depl0y
WorkingDirectory=/opt/depl0y/backend
EnvironmentFile=/etc/depl0y/config.env
ExecStart=/opt/depl0y/backend/venv/bin/uvicorn app.main:app --host 127.0.0.1 --port 8000
Restart=always
RestartSec=10
[Install]
WantedBy=multi-user.target
EOF
# Configure Nginx
echo "Configuring Nginx..."
cat > /etc/nginx/sites-available/depl0y << 'EOF'
server {
listen 80;
server_name _;
client_max_body_size 10G;
# Frontend
location / {
root /opt/depl0y/frontend/dist;
try_files $uri $uri/ /index.html;
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
}
# Backend API
location /api {
proxy_pass http://127.0.0.1:8000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_connect_timeout 300s;
proxy_send_timeout 300s;
proxy_read_timeout 300s;
}
# Health check
location /health {
proxy_pass http://127.0.0.1:8000/health;
access_log off;
}
access_log /var/log/nginx/depl0y_access.log;
error_log /var/log/nginx/depl0y_error.log;
}
EOF
# Enable nginx site
if [ "$OS" = "ubuntu" ] || [ "$OS" = "debian" ]; then
ln -sf /etc/nginx/sites-available/depl0y /etc/nginx/sites-enabled/
rm -f /etc/nginx/sites-enabled/default
else
cp /etc/nginx/sites-available/depl0y /etc/nginx/conf.d/depl0y.conf
fi
# Test nginx configuration
nginx -t
# Create admin user
echo ""
echo "========================================="
echo "Creating admin user..."
echo "========================================="
read -p "Enter admin username (default: admin): " ADMIN_USER
ADMIN_USER=${ADMIN_USER:-admin}
read -p "Enter admin email: " ADMIN_EMAIL
while [ -z "$ADMIN_EMAIL" ]; do
echo "Email cannot be empty"
read -p "Enter admin email: " ADMIN_EMAIL
done
read -sp "Enter admin password: " ADMIN_PASSWORD
echo ""
while [ ${#ADMIN_PASSWORD} -lt 8 ]; do
echo "Password must be at least 8 characters"
read -sp "Enter admin password: " ADMIN_PASSWORD
echo ""
done
cd /opt/depl0y/backend
sudo -u depl0y bash -c "source venv/bin/activate && source /etc/depl0y/config.env && python3" << PYEOF
from app.core.database import SessionLocal
from app.models import User, UserRole
from app.core.security import get_password_hash
db = SessionLocal()
try:
existing_user = db.query(User).filter(User.username == "$ADMIN_USER").first()
if existing_user:
print("User '$ADMIN_USER' already exists")
else:
admin = User(
username="$ADMIN_USER",
email="$ADMIN_EMAIL",
hashed_password=get_password_hash("$ADMIN_PASSWORD"),
role=UserRole.ADMIN,
is_active=True
)
db.add(admin)
db.commit()
print("Admin user created successfully")
except Exception as e:
print(f"Error creating admin user: {e}")
import traceback
traceback.print_exc()
finally:
db.close()
PYEOF
# Enable and start services
echo ""
echo "Starting services..."
systemctl daemon-reload
systemctl enable depl0y-backend
systemctl start depl0y-backend
systemctl restart nginx
# Wait for backend to start
echo "Waiting for backend to start..."
sleep 5
# Check service status
echo ""
echo "Checking service status..."
systemctl status depl0y-backend --no-pager || true
echo ""
echo "========================================="
echo " Installation Complete!"
echo "========================================="
echo ""
echo "Depl0y is now running!"
echo ""
SERVER_IP=$(hostname -I | awk '{print $1}')
echo "Access the web interface at: http://${SERVER_IP}"
echo ""
echo "Login credentials:"
echo " Username: $ADMIN_USER"
echo " Password: (the one you just entered)"
echo ""
echo "Service management:"
echo " Status: sudo systemctl status depl0y-backend"
echo " Stop: sudo systemctl stop depl0y-backend"
echo " Start: sudo systemctl start depl0y-backend"
echo " Restart: sudo systemctl restart depl0y-backend"
echo " Logs: sudo journalctl -u depl0y-backend -f"
echo ""
echo "Database credentials saved to: /etc/depl0y/config.env"
echo "MariaDB root password: ${DB_ROOT_PASSWORD}"
echo ""
echo "========================================="
+652
View File
@@ -0,0 +1,652 @@
#!/bin/bash
#
# Depl0y - Automated VM Deployment Panel Installer
# Complete automated installation - no manual steps required!
# One-line install: curl -fsSL http://deploy.agit8or.net/downloads/install.sh | sudo bash
#
set -e
# Installer version for tracking
INSTALLER_VERSION="1.1.0"
INSTALLER_BUILD="$(date +%Y%m%d%H%M%S)"
echo "╔══════════════════════════════════════════════════════════╗"
echo "║ ║"
echo "║ ██████╗ ███████╗██████╗ ██╗ ██████╗ ██╗ ██╗ ║"
echo "║ ██╔══██╗██╔════╝██╔══██╗██║ ██╔═████╗╚██╗ ██╔╝ ║"
echo "║ ██║ ██║█████╗ ██████╔╝██║ ██║██╔██║ ╚████╔╝ ║"
echo "║ ██║ ██║██╔══╝ ██╔═══╝ ██║ ████╔╝██║ ╚██╔╝ ║"
echo "║ ██████╔╝███████╗██║ ███████╗╚██████╔╝ ██║ ║"
echo "║ ╚═════╝ ╚══════╝╚═╝ ╚══════╝ ╚═════╝ ╚═╝ ║"
echo "║ ║"
echo "║ Automated VM Deployment Panel for Proxmox VE ║"
echo "║ https://deploy.agit8or.net ║"
echo "║ Version 1.1.5 ║"
echo "║ ║"
echo "╚══════════════════════════════════════════════════════════╝"
echo ""
echo "This installer will:"
echo " ✓ Install all dependencies"
echo " ✓ Set up Depl0y application"
echo " ✓ Configure cloud images (optional)"
echo " ✓ Configure inter-node SSH (optional)"
echo " ✓ No manual steps required!"
echo ""
# Check if running as root
if [ "$EUID" -ne 0 ]; then
echo "ERROR: Please run as root (use sudo)"
exit 1
fi
echo "🚀 Starting Depl0y installation..."
echo ""
# Check if Depl0y is already installed
UPGRADE_MODE=false
if [ -d "/opt/depl0y" ] && [ -f "/etc/systemd/system/depl0y-backend.service" ]; then
UPGRADE_MODE=true
echo "📦 Existing Depl0y installation detected"
echo " This will upgrade your installation while preserving your database"
echo ""
fi
# Detect OS
if [ -f /etc/os-release ]; then
. /etc/os-release
OS=$ID
VER=$VERSION_ID
else
echo "ERROR: Cannot detect OS"
exit 1
fi
echo "✓ Detected OS: $OS $VER"
# Check if Ubuntu/Debian
if [[ "$OS" != "ubuntu" && "$OS" != "debian" ]]; then
echo "ERROR: This installer currently only supports Ubuntu and Debian"
exit 1
fi
# Update system
echo ""
echo "📦 Updating system packages..."
apt-get update -qq
# Install ALL dependencies
echo "📦 Installing dependencies..."
echo " This includes: Python, Node.js, nginx, SQLite, PDF libraries, and more"
apt-get install -y -qq \
python3 \
python3-pip \
python3-venv \
python3-dev \
build-essential \
nginx \
nodejs \
npm \
sqlite3 \
curl \
wget \
git \
sshpass \
openssh-client \
at \
ca-certificates \
gnupg \
lsb-release \
libpango-1.0-0 \
libpangocairo-1.0-0 \
libgdk-pixbuf2.0-0 \
libffi-dev \
shared-mime-info
echo "✓ Dependencies installed"
# Create depl0y user
echo ""
echo "👤 Creating depl0y system user..."
if ! id -u depl0y > /dev/null 2>&1; then
useradd -r -m -d /opt/depl0y -s /bin/bash depl0y
echo "✓ User 'depl0y' created"
else
echo "✓ User 'depl0y' already exists"
fi
# If upgrading, stop the backend service and backup encryption keys
if [ "$UPGRADE_MODE" = true ]; then
echo "🔄 Preparing for upgrade..."
# Clear any stale Python cache first
echo "🗑️ Clearing old Python cache..."
find /opt/depl0y/backend -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null || true
find /opt/depl0y/backend -type f -name "*.pyc" -delete 2>/dev/null || true
echo "✓ Cache cleared"
if systemctl is-active --quiet depl0y-backend; then
systemctl stop depl0y-backend
echo "✓ Stopped backend service"
fi
# Backup encryption keys from existing service
KEYS_VALID=false
if [ -f /etc/systemd/system/depl0y-backend.service ]; then
echo "🔍 Extracting existing encryption keys..."
EXISTING_SECRET=$(grep "SECRET_KEY=" /etc/systemd/system/depl0y-backend.service | sed 's/.*SECRET_KEY=\([^"]*\).*/\1/')
EXISTING_ENCRYPTION=$(grep "ENCRYPTION_KEY=" /etc/systemd/system/depl0y-backend.service | sed 's/.*ENCRYPTION_KEY=\([^"]*\).*/\1/')
if [ -n "$EXISTING_SECRET" ] && [ -n "$EXISTING_ENCRYPTION" ]; then
echo " Found existing keys, validating..."
# Validate the encryption key
if python3 -c "from cryptography.fernet import Fernet; Fernet('${EXISTING_ENCRYPTION}'.encode())" 2>/dev/null; then
echo "✓ Existing encryption keys are valid and will be preserved"
KEYS_VALID=true
else
echo "⚠️ Existing encryption keys are INVALID (wrong format)"
echo " Will generate new keys - you'll need to re-enter Proxmox credentials"
fi
else
echo "⚠️ Could not extract existing keys from service file"
echo " Will generate new keys"
fi
fi
fi
# Download latest Depl0y
echo ""
echo "⬇️ Downloading Depl0y from deploy.agit8or.net..."
cd /tmp
rm -f depl0y-latest.tar.gz
# Add cache-busting timestamp to ensure fresh download
CACHE_BUST=$(date +%s)
curl -fsSL "http://deploy.agit8or.net/api/v1/system-updates/download?v=${CACHE_BUST}" -o depl0y-latest.tar.gz
echo "📦 Extracting Depl0y..."
rm -rf /tmp/depl0y-install
mkdir -p /tmp/depl0y-install
cd /tmp/depl0y-install
tar -xzf /tmp/depl0y-latest.tar.gz
# Install backend
echo ""
echo "🔧 Installing backend..."
mkdir -p /opt/depl0y
chmod 755 /opt/depl0y # Allow nginx to traverse
cp -r backend /opt/depl0y/
chown -R depl0y:depl0y /opt/depl0y/backend
chmod -R 755 /opt/depl0y/backend
# Create Python virtual environment
echo "🐍 Setting up Python environment..."
cd /opt/depl0y/backend
sudo -u depl0y python3 -m venv venv
sudo -u depl0y venv/bin/pip install --upgrade pip -q
sudo -u depl0y venv/bin/pip install -r requirements.txt -q
# Install additional Python packages for features
echo " Installing additional Python packages..."
sudo -u depl0y venv/bin/pip install -q weasyprint markdown requests
echo "✓ Backend installed"
# Install scripts
echo ""
echo "📜 Installing scripts..."
if [ -d "/tmp/depl0y-install/scripts" ]; then
mkdir -p /opt/depl0y/scripts
cp -r /tmp/depl0y-install/scripts/* /opt/depl0y/scripts/
chmod -R 755 /opt/depl0y/scripts
echo "✓ Scripts installed"
else
echo "⚠️ No scripts directory found in package"
fi
# Install frontend
echo ""
echo "🎨 Installing frontend..."
if [ "$UPGRADE_MODE" = true ]; then
# During upgrade, use pre-built frontend from package (much faster!)
echo " Using pre-built frontend from package..."
if [ -d "/tmp/depl0y-install/frontend/dist" ]; then
mkdir -p /opt/depl0y/frontend/dist
chmod 755 /opt/depl0y/frontend
cp -r /tmp/depl0y-install/frontend/dist/* /opt/depl0y/frontend/dist/
chown -R www-data:www-data /opt/depl0y/frontend/dist
chmod -R 755 /opt/depl0y/frontend/dist
echo "✓ Frontend installed (pre-built)"
else
echo "⚠️ Pre-built frontend not found, building from source..."
cd /tmp/depl0y-install/frontend
npm install --silent
npm run build
mkdir -p /opt/depl0y/frontend/dist
chmod 755 /opt/depl0y/frontend
cp -r dist/* /opt/depl0y/frontend/dist/
chown -R www-data:www-data /opt/depl0y/frontend/dist
chmod -R 755 /opt/depl0y/frontend/dist
echo "✓ Frontend installed (built from source)"
fi
else
# Fresh install - build frontend from source
cd /tmp/depl0y-install/frontend
npm install --silent
npm run build
mkdir -p /opt/depl0y/frontend/dist
chmod 755 /opt/depl0y/frontend
cp -r dist/* /opt/depl0y/frontend/dist/
chown -R www-data:www-data /opt/depl0y/frontend/dist
chmod -R 755 /opt/depl0y/frontend/dist
echo "✓ Frontend installed"
fi
# Create database directory
echo ""
echo "💾 Setting up database..."
mkdir -p /var/lib/depl0y/db
mkdir -p /var/lib/depl0y/cloud-images
mkdir -p /var/lib/depl0y/isos
mkdir -p /var/lib/depl0y/ssh_keys
mkdir -p /var/log/depl0y
chown -R depl0y:depl0y /var/lib/depl0y
chown -R depl0y:depl0y /var/log/depl0y
chmod -R 755 /var/lib/depl0y
echo "✓ Database directories created"
# Copy documentation
echo ""
echo "📚 Installing documentation..."
if [ -d "/tmp/depl0y-install/docs" ]; then
mkdir -p /opt/depl0y/docs
cp -r /tmp/depl0y-install/docs/* /opt/depl0y/docs/
chown -R depl0y:depl0y /opt/depl0y/docs
echo "✓ Documentation installed"
fi
# Generate or reuse encryption keys
echo ""
if [ "$UPGRADE_MODE" = true ] && [ "$KEYS_VALID" = true ]; then
echo "🔐 Reusing existing encryption keys..."
SECRET_KEY="$EXISTING_SECRET"
ENCRYPTION_KEY="$EXISTING_ENCRYPTION"
echo "✓ Existing encryption keys preserved"
else
echo "🔐 Generating new encryption keys..."
SECRET_KEY=$(python3 -c 'import secrets; print(secrets.token_urlsafe(32))')
ENCRYPTION_KEY=$(python3 -c 'from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())')
echo "✓ New encryption keys generated"
if [ "$UPGRADE_MODE" = true ]; then
echo ""
echo "⚠️ IMPORTANT: New encryption keys have been generated"
echo " This means you will need to:"
echo " • Log in again (previous sessions are now invalid)"
echo " • Re-enter all Proxmox host credentials"
echo " • Your database and settings are preserved"
fi
fi
# Create systemd service
echo ""
echo "⚙️ Creating systemd service..."
cat > /etc/systemd/system/depl0y-backend.service << SVCEOF
[Unit]
Description=Depl0y Backend API
After=network.target
[Service]
Type=simple
User=depl0y
Group=depl0y
WorkingDirectory=/opt/depl0y/backend
Environment="PATH=/opt/depl0y/backend/venv/bin"
Environment="SECRET_KEY=${SECRET_KEY}"
Environment="ENCRYPTION_KEY=${ENCRYPTION_KEY}"
ExecStart=/opt/depl0y/backend/venv/bin/uvicorn app.main:app --host 127.0.0.1 --port 8000
Restart=always
RestartSec=10
[Install]
WantedBy=multi-user.target
SVCEOF
# Clear Python cache to ensure new code is loaded
if [ "$UPGRADE_MODE" = true ]; then
echo "🗑️ Clearing Python cache again (ensuring fresh code)..."
else
echo "🗑️ Clearing Python cache..."
fi
find /opt/depl0y/backend -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null || true
find /opt/depl0y/backend -type f -name "*.pyc" -delete 2>/dev/null || true
systemctl daemon-reload
systemctl enable depl0y-backend
# Use restart to ensure service loads new code (important for upgrades)
if [ "$UPGRADE_MODE" = true ]; then
echo "🔄 Restarting backend service with new code..."
systemctl restart depl0y-backend
else
echo "🚀 Starting backend service..."
systemctl start depl0y-backend
fi
echo "✓ Backend service started"
# Configure nginx
echo ""
echo "🌐 Configuring nginx..."
cat > /etc/nginx/sites-available/depl0y << 'NGEOF'
server {
listen 80;
server_name _;
client_max_body_size 10G;
# Frontend
location / {
root /opt/depl0y/frontend/dist;
try_files $uri $uri/ /index.html;
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
}
# Backend API
location /api {
proxy_pass http://127.0.0.1:8000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_cache_bypass $http_upgrade;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_connect_timeout 300s;
proxy_send_timeout 300s;
proxy_read_timeout 300s;
}
access_log /var/log/nginx/depl0y_access.log;
error_log /var/log/nginx/depl0y_error.log;
}
NGEOF
ln -sf /etc/nginx/sites-available/depl0y /etc/nginx/sites-enabled/depl0y
rm -f /etc/nginx/sites-enabled/default
nginx -t
systemctl restart nginx
echo "✓ Nginx configured"
# Setup sudoers for depl0y user
echo ""
echo "🔐 Configuring permissions..."
cat > /etc/sudoers.d/depl0y << 'SUDOEOF'
# Depl0y user sudo permissions
depl0y ALL=(ALL) NOPASSWD: /usr/bin/apt-get install -y -qq sshpass
depl0y ALL=(ALL) NOPASSWD: /usr/bin/which sshpass
depl0y ALL=(depl0y) NOPASSWD: /usr/bin/mkdir -p /opt/depl0y/.ssh
depl0y ALL=(depl0y) NOPASSWD: /usr/bin/ssh-keygen *
depl0y ALL=(depl0y) NOPASSWD: /usr/bin/sshpass *
depl0y ALL=(depl0y) NOPASSWD: /usr/bin/ssh *
depl0y ALL=(depl0y) NOPASSWD: /usr/bin/ssh-copy-id *
depl0y ALL=(ALL) NOPASSWD: /usr/bin/systemctl restart depl0y-backend
depl0y ALL=(ALL) NOPASSWD: /bin/systemctl restart depl0y-backend
depl0y ALL=(ALL) NOPASSWD: /usr/bin/systemctl status depl0y-backend
depl0y ALL=(ALL) NOPASSWD: /bin/bash /tmp/depl0y-update-install.sh
depl0y ALL=(ALL) NOPASSWD: /opt/depl0y/scripts/update-wrapper.sh *
depl0y ALL=(ALL) NOPASSWD: /usr/bin/journalctl -u depl0y-backend *
SUDOEOF
chmod 440 /etc/sudoers.d/depl0y
visudo -c
echo "✓ Permissions configured"
# Wait for backend to start
echo ""
echo "⏳ Waiting for backend to start..."
sleep 5
# Check if backend is running
if systemctl is-active --quiet depl0y-backend; then
echo "✓ Backend is running"
else
echo ""
echo "❌ ERROR: Backend failed to start!"
echo ""
echo "Last 30 lines of backend logs:"
journalctl -u depl0y-backend -n 30 --no-pager
echo ""
echo "Installation failed. Please check the logs above for errors."
exit 1
fi
# Create default admin user
echo ""
echo "👤 Creating default admin user..."
cd /opt/depl0y/backend
if sudo -u depl0y /opt/depl0y/backend/venv/bin/python3 create_admin.py; then
echo "✓ Default credentials: admin / admin"
else
echo "❌ ERROR: Failed to create admin user!"
echo "Check permissions and database connection."
exit 1
fi
# Initialize system settings in database
echo ""
echo "⚙️ Initializing system settings..."
sudo -u depl0y sqlite3 /var/lib/depl0y/db/depl0y.db "CREATE TABLE IF NOT EXISTS system_settings (
id INTEGER PRIMARY KEY AUTOINCREMENT,
key VARCHAR(100) UNIQUE NOT NULL,
value TEXT NOT NULL,
description TEXT,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);" 2>/dev/null || true
sudo -u depl0y sqlite3 /var/lib/depl0y/db/depl0y.db "INSERT OR REPLACE INTO system_settings (key, value, description) VALUES
('app_version', '1.1.9', 'Current application version'),
('app_name', 'Depl0y', 'Application name');" 2>/dev/null || true
echo "✓ System settings initialized"
# Optional Proxmox setup
echo ""
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo " Optional: Proxmox Integration Setup"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo ""
echo "Would you like to configure Proxmox integration now?"
echo "This will:"
echo " • Set up cloud images for fast VM deployment"
echo " • Configure inter-node SSH for multi-node clusters"
echo ""
read -p "Configure Proxmox now? (y/N): " -n 1 -r SETUP_PROXMOX < /dev/tty || SETUP_PROXMOX="N"
echo ""
if [[ $SETUP_PROXMOX =~ ^[Yy]$ ]]; then
echo ""
echo "📋 Proxmox Configuration"
echo ""
# Get Proxmox details
read -p "Enter Proxmox hostname or IP: " PROXMOX_HOST < /dev/tty
read -p "Enter Proxmox root username [root]: " PROXMOX_USER < /dev/tty
PROXMOX_USER=${PROXMOX_USER:-root}
echo ""
echo "⚠️ Note: Password will be used to set up SSH keys only"
read -s -p "Enter Proxmox root password: " PROXMOX_PASSWORD < /dev/tty
echo ""
echo ""
# Setup SSH keys for depl0y user
echo "🔑 Setting up SSH keys..."
if [ ! -f "/opt/depl0y/.ssh/id_rsa" ]; then
sudo -u depl0y mkdir -p /opt/depl0y/.ssh
sudo -u depl0y ssh-keygen -t rsa -b 4096 -f /opt/depl0y/.ssh/id_rsa -N "" -q
echo "✓ SSH keys generated"
else
echo "✓ SSH keys already exist"
fi
# Copy SSH key to Proxmox
echo "📤 Copying SSH key to Proxmox..."
sudo -u depl0y sshpass -p "$PROXMOX_PASSWORD" ssh-copy-id -o StrictHostKeyChecking=no "${PROXMOX_USER}@${PROXMOX_HOST}" 2>/dev/null || {
echo "⚠️ Failed to copy SSH key. You may need to do this manually later."
}
# Test SSH connection
if sudo -u depl0y ssh -o BatchMode=yes -o ConnectTimeout=5 -o StrictHostKeyChecking=no "${PROXMOX_USER}@${PROXMOX_HOST}" "echo SSH_OK" 2>/dev/null | grep -q "SSH_OK"; then
echo "✓ SSH connection verified"
# Setup cloud images
echo ""
echo "☁️ Setting up cloud images..."
sudo -u depl0y ssh -o StrictHostKeyChecking=no "${PROXMOX_USER}@${PROXMOX_HOST}" 'bash -s' << 'CLOUDEOF'
# Check if cloud-init is available
if ! which cloud-init >/dev/null 2>&1; then
apt-get update -qq
apt-get install -y -qq cloud-init
fi
# Create cloud-init snippet storage if needed
mkdir -p /var/lib/vz/snippets
chmod 755 /var/lib/vz/snippets
echo "✓ Cloud images configured"
CLOUDEOF
# Setup inter-node SSH for cluster
echo ""
echo "🔗 Setting up inter-node SSH for cluster..."
sudo -u depl0y ssh -o StrictHostKeyChecking=no "${PROXMOX_USER}@${PROXMOX_HOST}" 'bash -s' << 'SSHEOF'
# Get cluster nodes
NODES=$(pvesh get /cluster/status --output-format json 2>/dev/null | grep -o '"name":"[^"]*"' | cut -d'"' -f4 | grep -v "^$" || echo "")
if [ -n "$NODES" ]; then
echo " Found cluster nodes: $NODES"
# Generate SSH key on this node if needed
if [ ! -f ~/.ssh/id_rsa ]; then
ssh-keygen -t rsa -b 4096 -f ~/.ssh/id_rsa -N "" -q
fi
# Copy key to other nodes
for node in $NODES; do
if [ "$node" != "$(hostname)" ]; then
echo " Setting up SSH to $node..."
ssh-copy-id -o StrictHostKeyChecking=no "root@$node" 2>/dev/null || true
fi
done
echo "✓ Inter-node SSH configured"
else
echo " Single node or cluster not configured, skipping"
fi
SSHEOF
echo ""
echo "✓ Proxmox integration complete!"
else
echo "⚠️ SSH connection failed. You can set this up later via Settings in the web interface."
fi
else
echo ""
echo "⏭️ Skipping Proxmox setup. You can configure this later via:"
echo " Settings → Cloud Images"
echo " Settings → Proxmox Cluster SSH"
fi
# Cleanup
echo ""
echo "🧹 Cleaning up..."
cd /
rm -rf /tmp/depl0y-install
rm -f /tmp/depl0y-latest.tar.gz
# Final restart for upgrades to ensure new code is loaded
if [ "$UPGRADE_MODE" = true ]; then
echo ""
echo "🔄 Final restart to load new code..."
find /opt/depl0y/backend -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null || true
find /opt/depl0y/backend -type f -name "*.pyc" -delete 2>/dev/null || true
systemctl restart depl0y-backend
sleep 3
if systemctl is-active --quiet depl0y-backend; then
echo "✓ Backend restarted successfully"
else
echo "⚠️ Backend restart may have issues, check logs"
fi
fi
# Get IP address
IP=$(hostname -I | awk '{print $1}')
echo ""
if [ "$UPGRADE_MODE" = true ]; then
echo "╔══════════════════════════════════════════════════════════╗"
echo "║ ║"
echo "║ ✅ UPGRADE COMPLETE ║"
echo "║ ║"
echo "╚══════════════════════════════════════════════════════════╝"
echo ""
echo "🎉 Depl0y v1.1.9 has been successfully upgraded!"
echo ""
echo "📍 Access Depl0y at:"
echo " http://$IP"
echo ""
echo "✨ What's new in v1.1.9:"
echo " • Automatic backend restart after upgrades"
echo " • No manual restart needed - updates apply immediately"
echo " • Fixed JavaScript error (version not defined)"
echo " • One-click automatic updates from Settings"
echo ""
echo "📚 Note:"
echo " • Your database has been preserved"
echo " • Your encryption keys have been preserved"
echo " • All users and settings retained"
else
echo "╔══════════════════════════════════════════════════════════╗"
echo "║ ║"
echo "║ ✅ INSTALLATION COMPLETE ║"
echo "║ ║"
echo "╚══════════════════════════════════════════════════════════╝"
echo ""
echo "🎉 Depl0y v1.1.9 has been successfully installed!"
echo ""
echo "📍 Access Depl0y at:"
echo " http://$IP"
echo ""
echo "👤 Default Credentials:"
echo " Username: admin"
echo " Password: admin"
echo " 🔐 2FA: Disabled (enable in Settings after login)"
echo " ⚠️ CHANGE PASSWORD IMMEDIATELY AFTER FIRST LOGIN!"
echo ""
echo "✨ Features:"
echo " • High Availability wizard"
echo " • Cloud image management"
echo " • Automated VM deployment"
echo ""
echo "📚 Next Steps:"
echo " 1. Access the web interface"
echo " 2. Change the default password"
echo " 3. Add your Proxmox host in Settings (if not done)"
echo " 4. Try the HA Setup Wizard!"
fi
echo ""
echo "📖 Documentation: http://$IP → Documentation"
echo "🆘 Support: Check /opt/depl0y/docs/"
echo ""
echo "Thank you for using Depl0y! 🚀"
echo ""
+187
View File
@@ -0,0 +1,187 @@
#!/bin/bash
# Depl0y Quick Setup with SQLite (simplest installation)
set -e
echo "========================================="
echo " Depl0y Quick Setup (SQLite)"
echo "========================================="
echo ""
if [ "$EUID" -ne 0 ]; then
echo "Please run as root"
exit 1
fi
cd /home/administrator/depl0y
# Create directories
echo "Creating directories..."
mkdir -p /opt/depl0y/{backend,frontend}
mkdir -p /var/lib/depl0y/{isos,cloud-init,ssh_keys,db}
mkdir -p /var/log/depl0y
mkdir -p /etc/depl0y
# Copy files
echo "Copying files..."
cp -r backend/* /opt/depl0y/backend/
cp -r frontend/* /opt/depl0y/frontend/
# Create user
useradd -r -s /bin/bash -d /opt/depl0y -m depl0y 2>/dev/null || true
# Set permissions
chown -R depl0y:depl0y /opt/depl0y /var/lib/depl0y /var/log/depl0y
# Generate config
echo "Generating configuration..."
SECRET_KEY=$(openssl rand -hex 32)
ENCRYPTION_KEY=$(python3 -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())")
cat > /etc/depl0y/config.env << EOF
DATABASE_URL=sqlite:////var/lib/depl0y/db/depl0y.db
SECRET_KEY=${SECRET_KEY}
ENCRYPTION_KEY=${ENCRYPTION_KEY}
DEBUG=false
LOG_LEVEL=INFO
LOG_FILE=/var/log/depl0y/app.log
ISO_STORAGE_PATH=/var/lib/depl0y/isos
CLOUDINIT_TEMPLATE_PATH=/var/lib/depl0y/cloud-init
SSH_KEY_PATH=/var/lib/depl0y/ssh_keys
API_V1_PREFIX=/api/v1
EOF
chmod 600 /etc/depl0y/config.env
chown depl0y:depl0y /etc/depl0y/config.env
# Install Python deps
echo "Installing Python dependencies..."
cd /opt/depl0y/backend
sudo -u depl0y python3 -m venv venv
sudo -u depl0y venv/bin/pip install --upgrade pip -q
sudo -u depl0y venv/bin/pip install -r requirements.txt -q
echo "✓ Python dependencies installed"
# Init database
echo "Initializing database..."
sudo -u depl0y bash -c "source venv/bin/activate && source /etc/depl0y/config.env && python3 -c 'from app.core.database import init_db; init_db()'"
echo "✓ Database initialized"
# Create admin
echo "Creating admin user..."
sudo -u depl0y bash -c "source venv/bin/activate && source /etc/depl0y/config.env && python3" << 'PYEOF'
from app.core.database import SessionLocal
from app.models import User, UserRole
from app.core.security import get_password_hash
db = SessionLocal()
try:
admin = User(
username="admin",
email="admin@depl0y.local",
hashed_password=get_password_hash("Admin123!"),
role=UserRole.ADMIN,
is_active=True
)
db.add(admin)
db.commit()
print("✓ Admin created")
except Exception as e:
print(f"Note: {e}")
finally:
db.close()
PYEOF
# Build frontend
echo "Building frontend..."
cd /opt/depl0y/frontend
sudo -u depl0y npm install -q
sudo -u depl0y npm run build
echo "✓ Frontend built"
# Create service
cat > /etc/systemd/system/depl0y-backend.service << 'EOF'
[Unit]
Description=Depl0y Backend
After=network.target
[Service]
Type=simple
User=depl0y
Group=depl0y
WorkingDirectory=/opt/depl0y/backend
EnvironmentFile=/etc/depl0y/config.env
ExecStart=/opt/depl0y/backend/venv/bin/uvicorn app.main:app --host 127.0.0.1 --port 8000
Restart=always
[Install]
WantedBy=multi-user.target
EOF
# Configure nginx
cat > /etc/nginx/sites-available/depl0y << 'EOF'
server {
listen 80 default_server;
server_name _;
client_max_body_size 10G;
location / {
root /opt/depl0y/frontend/dist;
try_files $uri $uri/ /index.html;
}
location /api {
proxy_pass http://127.0.0.1:8000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_http_version 1.1;
proxy_connect_timeout 300s;
proxy_send_timeout 300s;
proxy_read_timeout 300s;
}
location /health {
proxy_pass http://127.0.0.1:8000/health;
access_log off;
}
}
EOF
ln -sf /etc/nginx/sites-available/depl0y /etc/nginx/sites-enabled/
rm -f /etc/nginx/sites-enabled/default
nginx -t
# Start everything
echo "Starting services..."
systemctl daemon-reload
systemctl enable depl0y-backend
systemctl restart depl0y-backend
systemctl reload nginx
sleep 3
SERVER_IP=$(hostname -I | awk '{print $1}')
echo ""
echo "========================================="
echo " ✓ Depl0y is Running!"
echo "========================================="
echo ""
echo "URL: http://${SERVER_IP}"
echo ""
echo "Login:"
echo " Username: admin"
echo " Password: Admin123!"
echo ""
echo "Commands:"
echo " sudo systemctl status depl0y-backend"
echo " sudo systemctl restart depl0y-backend"
echo " sudo journalctl -u depl0y-backend -f"
echo ""
echo "========================================="
+236
View File
@@ -0,0 +1,236 @@
#!/bin/bash
# Depl0y Quick Start (assumes MariaDB already installed)
set -e
echo "========================================="
echo " Depl0y Quick Start"
echo "========================================="
echo ""
# Check if running as root
if [ "$EUID" -ne 0 ]; then
echo "Please run as root (use sudo)"
exit 1
fi
PROJECT_DIR="/home/administrator/depl0y"
cd "$PROJECT_DIR"
# Setup MariaDB database
echo "Setting up database..."
mysql -u root << 'EOF'
CREATE DATABASE IF NOT EXISTS depl0y;
CREATE USER IF NOT EXISTS 'depl0y'@'localhost' IDENTIFIED BY 'depl0y_password_123';
GRANT ALL PRIVILEGES ON depl0y.* TO 'depl0y'@'localhost';
FLUSH PRIVILEGES;
EOF
echo "Database created"
# Create application user and directories
echo "Creating directories..."
useradd -r -s /bin/bash -d /opt/depl0y -m depl0y 2>/dev/null || true
mkdir -p /opt/depl0y/{backend,frontend}
mkdir -p /var/lib/depl0y/{isos,cloud-init,ssh_keys}
mkdir -p /var/log/depl0y
mkdir -p /etc/depl0y
# Copy files
echo "Copying application files..."
cp -r "$PROJECT_DIR/backend"/* /opt/depl0y/backend/
cp -r "$PROJECT_DIR/frontend"/* /opt/depl0y/frontend/
# Set permissions
chown -R depl0y:depl0y /opt/depl0y
chown -R depl0y:depl0y /var/lib/depl0y
chown -R depl0y:depl0y /var/log/depl0y
# Generate secrets
echo "Generating configuration..."
SECRET_KEY=$(openssl rand -hex 32)
ENCRYPTION_KEY=$(python3 -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())")
# Create config file
cat > /etc/depl0y/config.env << EOF
DATABASE_URL=mysql+pymysql://depl0y:depl0y_password_123@localhost:3306/depl0y
SECRET_KEY=${SECRET_KEY}
ENCRYPTION_KEY=${ENCRYPTION_KEY}
DEBUG=false
LOG_LEVEL=INFO
LOG_FILE=/var/log/depl0y/app.log
ISO_STORAGE_PATH=/var/lib/depl0y/isos
CLOUDINIT_TEMPLATE_PATH=/var/lib/depl0y/cloud-init
SSH_KEY_PATH=/var/lib/depl0y/ssh_keys
API_V1_PREFIX=/api/v1
EOF
chmod 600 /etc/depl0y/config.env
chown depl0y:depl0y /etc/depl0y/config.env
# Install Python dependencies
echo "Installing Python dependencies (this may take a few minutes)..."
cd /opt/depl0y/backend
sudo -u depl0y python3 -m venv venv
sudo -u depl0y /opt/depl0y/backend/venv/bin/pip install --upgrade pip --quiet
sudo -u depl0y /opt/depl0y/backend/venv/bin/pip install -r requirements.txt --quiet
echo "Python dependencies installed"
# Initialize database
echo "Initializing database..."
cd /opt/depl0y/backend
sudo -u depl0y bash -c "source venv/bin/activate && source /etc/depl0y/config.env && python3 -c 'from app.core.database import init_db; init_db()'"
echo "Database initialized"
# Build frontend
echo "Building frontend (this may take a few minutes)..."
cd /opt/depl0y/frontend
sudo -u depl0y npm install --quiet
sudo -u depl0y npm run build
echo "Frontend built"
# Create systemd service
echo "Creating systemd service..."
cat > /etc/systemd/system/depl0y-backend.service << 'SVCEOF'
[Unit]
Description=Depl0y Backend API
After=network.target mariadb.service
Wants=mariadb.service
[Service]
Type=simple
User=depl0y
Group=depl0y
WorkingDirectory=/opt/depl0y/backend
EnvironmentFile=/etc/depl0y/config.env
ExecStart=/opt/depl0y/backend/venv/bin/uvicorn app.main:app --host 127.0.0.1 --port 8000
Restart=always
RestartSec=10
[Install]
WantedBy=multi-user.target
SVCEOF
# Configure Nginx
echo "Configuring Nginx..."
cat > /etc/nginx/sites-available/depl0y << 'NGEOF'
server {
listen 80;
server_name _;
client_max_body_size 10G;
location / {
root /opt/depl0y/frontend/dist;
try_files $uri $uri/ /index.html;
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
}
location /api {
proxy_pass http://127.0.0.1:8000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_connect_timeout 300s;
proxy_send_timeout 300s;
proxy_read_timeout 300s;
}
location /health {
proxy_pass http://127.0.0.1:8000/health;
access_log off;
}
access_log /var/log/nginx/depl0y_access.log;
error_log /var/log/nginx/depl0y_error.log;
}
NGEOF
ln -sf /etc/nginx/sites-available/depl0y /etc/nginx/sites-enabled/
rm -f /etc/nginx/sites-enabled/default
# Test nginx
nginx -t
# Create admin user
echo ""
echo "Creating admin user..."
cd /opt/depl0y/backend
sudo -u depl0y bash -c "source venv/bin/activate && source /etc/depl0y/config.env && python3" << 'PYEOF'
from app.core.database import SessionLocal
from app.models import User, UserRole
from app.core.security import get_password_hash
db = SessionLocal()
try:
# Delete existing admin if exists
existing = db.query(User).filter(User.username == "admin").first()
if existing:
db.delete(existing)
db.commit()
# Create new admin
admin = User(
username="admin",
email="admin@depl0y.local",
hashed_password=get_password_hash("Admin123!"),
role=UserRole.ADMIN,
is_active=True
)
db.add(admin)
db.commit()
print("✓ Admin user created")
except Exception as e:
print(f"Error: {e}")
import traceback
traceback.print_exc()
finally:
db.close()
PYEOF
# Start services
echo ""
echo "Starting services..."
systemctl daemon-reload
systemctl enable depl0y-backend
systemctl restart depl0y-backend
systemctl reload nginx
# Wait for startup
echo "Waiting for services to start..."
sleep 5
# Get server IP
SERVER_IP=$(hostname -I | awk '{print $1}')
echo ""
echo "========================================="
echo " Installation Complete!"
echo "========================================="
echo ""
echo "✓ Depl0y is now running!"
echo ""
echo "Access: http://${SERVER_IP}"
echo ""
echo "Login:"
echo " Username: admin"
echo " Password: Admin123!"
echo ""
echo "Commands:"
echo " Status: sudo systemctl status depl0y-backend"
echo " Restart: sudo systemctl restart depl0y-backend"
echo " Logs: sudo journalctl -u depl0y-backend -f"
echo ""
echo "========================================="
+175
View File
@@ -0,0 +1,175 @@
#!/bin/bash
# Depl0y Setup Script
# This script sets up Depl0y on a fresh server
set -e
echo "========================================="
echo " Depl0y Installation Script"
echo "========================================="
echo ""
# Check if running as root
if [ "$EUID" -ne 0 ]; then
echo "Please run as root (use sudo)"
exit 1
fi
# Detect OS
if [ -f /etc/os-release ]; then
. /etc/os-release
OS=$ID
VERSION=$VERSION_ID
else
echo "Cannot detect OS. Exiting."
exit 1
fi
echo "Detected OS: $OS $VERSION"
echo ""
# Install Docker and Docker Compose
echo "Installing Docker and Docker Compose..."
if ! command -v docker &> /dev/null; then
curl -fsSL https://get.docker.com | sh
systemctl enable docker
systemctl start docker
echo "Docker installed successfully"
else
echo "Docker is already installed"
fi
if ! command -v docker-compose &> /dev/null; then
curl -L "https://github.com/docker/compose/releases/latest/download/docker-compose-$(uname -s)-$(uname -m)" -o /usr/local/bin/docker-compose
chmod +x /usr/local/bin/docker-compose
echo "Docker Compose installed successfully"
else
echo "Docker Compose is already installed"
fi
echo ""
# Generate secrets
echo "Generating secure secrets..."
if [ ! -f .env ]; then
cp .env.example .env
# Generate SECRET_KEY (64 character random string)
SECRET_KEY=$(openssl rand -hex 32)
sed -i "s|SECRET_KEY=.*|SECRET_KEY=$SECRET_KEY|" .env
# Generate ENCRYPTION_KEY (Fernet key)
ENCRYPTION_KEY=$(python3 -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())" 2>/dev/null || openssl rand -base64 32)
sed -i "s|ENCRYPTION_KEY=.*|ENCRYPTION_KEY=$ENCRYPTION_KEY|" .env
# Generate database passwords
MYSQL_ROOT_PASSWORD=$(openssl rand -hex 16)
MYSQL_PASSWORD=$(openssl rand -hex 16)
sed -i "s|MYSQL_ROOT_PASSWORD=.*|MYSQL_ROOT_PASSWORD=$MYSQL_ROOT_PASSWORD|" .env
sed -i "s|MYSQL_PASSWORD=.*|MYSQL_PASSWORD=$MYSQL_PASSWORD|" .env
echo ".env file created with secure random secrets"
else
echo ".env file already exists, skipping secret generation"
fi
echo ""
# Create necessary directories
echo "Creating directories..."
mkdir -p /var/lib/depl0y/{isos,cloud-init,ssh_keys}
mkdir -p /var/log/depl0y
chmod 755 /var/lib/depl0y
chmod 755 /var/log/depl0y
echo ""
# Build and start containers
echo "Building and starting Docker containers..."
docker-compose up -d --build
echo ""
echo "Waiting for services to start..."
sleep 10
# Run database migrations
echo "Running database migrations..."
docker-compose exec -T backend alembic upgrade head || echo "Note: Alembic migrations may need to be configured"
# Create default admin user
echo ""
echo "Creating default admin user..."
read -p "Enter admin username (default: admin): " ADMIN_USER
ADMIN_USER=${ADMIN_USER:-admin}
read -p "Enter admin email: " ADMIN_EMAIL
while [ -z "$ADMIN_EMAIL" ]; do
echo "Email cannot be empty"
read -p "Enter admin email: " ADMIN_EMAIL
done
read -sp "Enter admin password: " ADMIN_PASSWORD
echo ""
while [ ${#ADMIN_PASSWORD} -lt 8 ]; do
echo "Password must be at least 8 characters"
read -sp "Enter admin password: " ADMIN_PASSWORD
echo ""
done
# Create admin user via Python script
docker-compose exec -T backend python3 << EOF
from app.core.database import SessionLocal
from app.models import User, UserRole
from app.core.security import get_password_hash
db = SessionLocal()
try:
# Check if user exists
existing_user = db.query(User).filter(User.username == "$ADMIN_USER").first()
if existing_user:
print("User '$ADMIN_USER' already exists")
else:
# Create admin user
admin = User(
username="$ADMIN_USER",
email="$ADMIN_EMAIL",
hashed_password=get_password_hash("$ADMIN_PASSWORD"),
role=UserRole.ADMIN,
is_active=True
)
db.add(admin)
db.commit()
print("Admin user created successfully")
except Exception as e:
print(f"Error creating admin user: {e}")
finally:
db.close()
EOF
echo ""
echo "========================================="
echo " Installation Complete!"
echo "========================================="
echo ""
echo "Depl0y is now running!"
echo ""
echo "Access the web interface at: http://$(hostname -I | awk '{print $1}')"
echo ""
echo "Default credentials:"
echo " Username: $ADMIN_USER"
echo " Password: (the one you just entered)"
echo ""
echo "Important: Change the default admin password after first login!"
echo ""
echo "To view logs:"
echo " docker-compose logs -f"
echo ""
echo "To stop Depl0y:"
echo " docker-compose down"
echo ""
echo "To start Depl0y:"
echo " docker-compose up -d"
echo ""
echo "========================================="
+203
View File
@@ -0,0 +1,203 @@
#!/bin/bash
#
# Depl0y Uninstaller
# Completely removes Depl0y from your system
#
set -e
echo "╔══════════════════════════════════════════════════════════╗"
echo "║ ║"
echo "║ ██████╗ ███████╗██████╗ ██╗ ██████╗ ██╗ ██╗ ║"
echo "║ ██╔══██╗██╔════╝██╔══██╗██║ ██╔═████╗╚██╗ ██╔╝ ║"
echo "║ ██║ ██║█████╗ ██████╔╝██║ ██║██╔██║ ╚████╔╝ ║"
echo "║ ██║ ██║██╔══╝ ██╔═══╝ ██║ ████╔╝██║ ╚██╔╝ ║"
echo "║ ██████╔╝███████╗██║ ███████╗╚██████╔╝ ██║ ║"
echo "║ ╚═════╝ ╚══════╝╚═╝ ╚══════╝ ╚═════╝ ╚═╝ ║"
echo "║ ║"
echo "║ UNINSTALLER ║"
echo "║ ║"
echo "╚══════════════════════════════════════════════════════════╝"
echo ""
# Check if running as root
if [ "$EUID" -ne 0 ]; then
echo "ERROR: Please run as root (use sudo)"
exit 1
fi
# Check for -y flag or if stdin is not a terminal (piped input)
SKIP_CONFIRM=false
if [[ "$1" == "-y" || "$1" == "--yes" ]]; then
SKIP_CONFIRM=true
elif [ ! -t 0 ]; then
# Not running in terminal (piped through curl)
echo "⚠️ WARNING: Running in non-interactive mode"
echo ""
echo "To uninstall Depl0y, run this command:"
echo " curl -fsSL http://deploy.agit8or.net/downloads/uninstall.sh | sudo bash -s -- -y"
echo ""
echo "Or download and run locally:"
echo " curl -fsSL http://deploy.agit8or.net/downloads/uninstall.sh -o uninstall.sh"
echo " sudo bash uninstall.sh"
echo ""
exit 1
fi
if [ "$SKIP_CONFIRM" = false ]; then
echo "⚠️ WARNING: This will completely remove Depl0y from your system"
echo ""
echo "This will:"
echo " • Stop and remove the depl0y-backend service"
echo " • Remove all Depl0y files from /opt/depl0y"
echo " • Remove database and logs from /var/lib/depl0y and /var/log/depl0y"
echo " • Remove nginx configuration"
echo " • Remove the depl0y system user"
echo " • Remove sudo permissions"
echo " • Remove installed dependencies (Python packages, Node.js packages)"
echo ""
echo "⚠️ DATABASE WILL BE DELETED - All VM configurations will be lost!"
echo ""
read -p "Are you sure you want to uninstall Depl0y? (yes/NO): " -r CONFIRM < /dev/tty || CONFIRM="NO"
echo ""
if [ "$CONFIRM" != "yes" ]; then
echo "Uninstall cancelled."
exit 0
fi
else
echo "⚠️ Uninstalling Depl0y (confirmation skipped with -y flag)..."
echo ""
fi
echo "Starting uninstallation..."
echo ""
# Stop and disable backend service
echo "🛑 Stopping depl0y-backend service..."
if systemctl is-active --quiet depl0y-backend; then
systemctl stop depl0y-backend
echo "✓ Service stopped"
else
echo "✓ Service was not running"
fi
if systemctl is-enabled --quiet depl0y-backend 2>/dev/null; then
systemctl disable depl0y-backend
echo "✓ Service disabled"
fi
# Remove systemd service file
echo ""
echo "🗑️ Removing systemd service..."
if [ -f /etc/systemd/system/depl0y-backend.service ]; then
rm -f /etc/systemd/system/depl0y-backend.service
systemctl daemon-reload
echo "✓ Service file removed"
fi
# Remove application files
echo ""
echo "🗑️ Removing application files..."
if [ -d /opt/depl0y ]; then
rm -rf /opt/depl0y
echo "✓ Removed /opt/depl0y"
fi
# Remove data and logs
echo ""
echo "🗑️ Removing database and logs..."
if [ -d /var/lib/depl0y ]; then
rm -rf /var/lib/depl0y
echo "✓ Removed /var/lib/depl0y (database, cloud images, ISOs)"
fi
if [ -d /var/log/depl0y ]; then
rm -rf /var/log/depl0y
echo "✓ Removed /var/log/depl0y"
fi
# Remove nginx configuration
echo ""
echo "🗑️ Removing nginx configuration..."
if [ -f /etc/nginx/sites-enabled/depl0y ]; then
rm -f /etc/nginx/sites-enabled/depl0y
echo "✓ Removed nginx sites-enabled/depl0y"
fi
if [ -f /etc/nginx/sites-available/depl0y ]; then
rm -f /etc/nginx/sites-available/depl0y
echo "✓ Removed nginx sites-available/depl0y"
fi
# Restore default site if it existed
if [ -f /etc/nginx/sites-available/default ] && [ ! -f /etc/nginx/sites-enabled/default ]; then
ln -s /etc/nginx/sites-available/default /etc/nginx/sites-enabled/default
echo "✓ Restored nginx default site"
fi
# Test and reload nginx
if nginx -t >/dev/null 2>&1; then
systemctl reload nginx
echo "✓ Nginx reloaded"
else
echo "⚠️ Nginx configuration test failed, skipping reload"
fi
# Remove sudoers file
echo ""
echo "🗑️ Removing sudo permissions..."
if [ -f /etc/sudoers.d/depl0y ]; then
rm -f /etc/sudoers.d/depl0y
echo "✓ Removed /etc/sudoers.d/depl0y"
fi
# Remove depl0y user
echo ""
echo "👤 Removing depl0y system user..."
if id -u depl0y >/dev/null 2>&1; then
userdel -r depl0y 2>/dev/null || userdel depl0y 2>/dev/null || true
echo "✓ User 'depl0y' removed"
else
echo "✓ User 'depl0y' does not exist"
fi
# Remove temporary files and caches
echo ""
echo "🧹 Cleaning up temporary files and caches..."
rm -f /tmp/depl0y-*.tar.gz 2>/dev/null || true
rm -rf /tmp/depl0y-install 2>/dev/null || true
rm -f /tmp/enable_cloud_images.sh 2>/dev/null || true
rm -rf /tmp/depl0y* 2>/dev/null || true
echo "✓ Temporary files cleaned"
# Optional: Remove installed packages (commented out by default for safety)
echo ""
echo "📦 Package cleanup..."
echo " Note: System packages (Python, Node.js, nginx, etc.) were NOT removed"
echo " as they may be used by other applications."
echo ""
echo " To remove them manually if desired:"
echo " sudo apt-get remove --purge python3-venv python3-dev nodejs npm nginx"
echo " sudo apt-get autoremove"
echo ""
echo "╔══════════════════════════════════════════════════════════╗"
echo "║ ║"
echo "║ ✅ UNINSTALL COMPLETE ║"
echo "║ ║"
echo "╚══════════════════════════════════════════════════════════╝"
echo ""
echo "Depl0y has been completely removed from your system."
echo ""
echo "The following were NOT automatically removed (remove manually if needed):"
echo " • System packages: python3, nodejs, npm, nginx, sqlite3"
echo " • (These may be used by other applications)"
echo ""
echo "To remove system packages manually:"
echo " sudo apt-get remove --purge python3-venv python3-dev nodejs npm nginx sqlite3"
echo " sudo apt-get autoremove"
echo ""
echo "To reinstall Depl0y:"
echo " curl -fsSL http://deploy.agit8or.net/downloads/install.sh | sudo bash"
echo ""
+29
View File
@@ -0,0 +1,29 @@
#!/bin/bash
# Depl0y Update Wrapper - Completely detaches installer from backend service
# This ensures the installer survives when it stops the backend
INSTALLER_PATH="$1"
if [ -z "$INSTALLER_PATH" ]; then
echo "ERROR: No installer path provided"
exit 1
fi
if [ ! -f "$INSTALLER_PATH" ]; then
echo "ERROR: Installer not found at $INSTALLER_PATH"
exit 1
fi
# Use 'at' to schedule the installer to run immediately but completely detached
# The 'at' daemon will run the job in its own process tree, independent of the backend service
# IMPORTANT: Must run with sudo since installer needs root permissions
echo "/usr/bin/sudo /bin/bash $INSTALLER_PATH > /tmp/depl0y-update.log 2>&1" | /usr/bin/at now 2>&1
if [ $? -eq 0 ]; then
echo "Update scheduled successfully via 'at' daemon"
echo "The installer will run independently and survive backend restarts"
exit 0
else
echo "ERROR: Failed to schedule update"
exit 1
fi
+33
View File
@@ -0,0 +1,33 @@
FROM python:3.11-slim
# Set working directory
WORKDIR /app
# Install system dependencies
RUN apt-get update && apt-get install -y \
gcc \
g++ \
libmariadb-dev \
pkg-config \
&& rm -rf /var/lib/apt/lists/*
# Copy requirements file
COPY requirements.txt .
# Install Python dependencies
RUN pip install --no-cache-dir -r requirements.txt
# Copy application code
COPY . .
# Create necessary directories
RUN mkdir -p /app/storage/isos \
/app/storage/cloud-init \
/app/storage/ssh_keys \
/app/logs
# Expose port
EXPOSE 8000
# Run the application
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
+1
View File
@@ -0,0 +1 @@
"""Main application package"""
+1
View File
@@ -0,0 +1 @@
"""API routes package"""
+256
View File
@@ -0,0 +1,256 @@
"""Authentication API routes"""
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
from sqlalchemy.orm import Session
from pydantic import BaseModel, EmailStr
from typing import Optional
from datetime import datetime
import qrcode
import io
import base64
from app.core.database import get_db
from app.core.security import (
verify_password,
get_password_hash,
create_access_token,
create_refresh_token,
decode_token,
generate_totp_secret,
generate_totp_uri,
verify_totp_code,
)
from app.models import User, UserRole
router = APIRouter()
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="api/v1/auth/login")
# Pydantic models
class Token(BaseModel):
access_token: str
refresh_token: str
token_type: str
class LoginRequest(BaseModel):
username: str
password: str
totp_code: Optional[str] = None
class TOTPSetupResponse(BaseModel):
secret: str
qr_code: str
uri: str
class TOTPVerifyRequest(BaseModel):
code: str
class UserResponse(BaseModel):
id: int
username: str
email: str
role: UserRole
is_active: bool
totp_enabled: bool
created_at: datetime
class Config:
from_attributes = True
# Dependency to get current user
async def get_current_user(
token: str = Depends(oauth2_scheme), db: Session = Depends(get_db)
) -> User:
credentials_exception = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"},
)
payload = decode_token(token)
if payload is None:
raise credentials_exception
username: str = payload.get("sub")
if username is None:
raise credentials_exception
user = db.query(User).filter(User.username == username).first()
if user is None:
raise credentials_exception
if not user.is_active:
raise HTTPException(status_code=400, detail="Inactive user")
return user
# Dependency for admin-only access
async def require_admin(current_user: User = Depends(get_current_user)) -> User:
if current_user.role != UserRole.ADMIN:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Admin privileges required",
)
return current_user
# Dependency for operator or admin access
async def require_operator(current_user: User = Depends(get_current_user)) -> User:
if current_user.role not in [UserRole.ADMIN, UserRole.OPERATOR]:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Operator privileges required",
)
return current_user
@router.post("/login", response_model=Token)
async def login(credentials: LoginRequest, db: Session = Depends(get_db)):
"""Login endpoint"""
user = db.query(User).filter(User.username == credentials.username).first()
if not user or not verify_password(credentials.password, user.hashed_password):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect username or password",
)
if not user.is_active:
raise HTTPException(status_code=400, detail="Inactive user")
# Check 2FA if enabled
if user.totp_enabled:
if not credentials.totp_code:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="2FA code required",
)
if not verify_totp_code(user.totp_secret, credentials.totp_code):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid 2FA code",
)
# Update last login
user.last_login = datetime.utcnow()
db.commit()
# Create tokens
access_token = create_access_token(data={"sub": user.username})
refresh_token = create_refresh_token(data={"sub": user.username})
return {
"access_token": access_token,
"refresh_token": refresh_token,
"token_type": "bearer",
}
@router.post("/refresh", response_model=Token)
async def refresh_token(refresh_token: str, db: Session = Depends(get_db)):
"""Refresh access token"""
payload = decode_token(refresh_token)
if payload is None or payload.get("type") != "refresh":
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid refresh token",
)
username = payload.get("sub")
user = db.query(User).filter(User.username == username).first()
if not user or not user.is_active:
raise HTTPException(status_code=401, detail="User not found or inactive")
# Create new tokens
access_token = create_access_token(data={"sub": user.username})
new_refresh_token = create_refresh_token(data={"sub": user.username})
return {
"access_token": access_token,
"refresh_token": new_refresh_token,
"token_type": "bearer",
}
@router.get("/me", response_model=UserResponse)
async def get_current_user_info(current_user: User = Depends(get_current_user)):
"""Get current user information"""
return current_user
@router.post("/totp/setup", response_model=TOTPSetupResponse)
async def setup_totp(current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
"""Setup TOTP 2FA for current user"""
# Generate new secret
secret = generate_totp_secret()
uri = generate_totp_uri(secret, current_user.username)
# Generate QR code
qr = qrcode.QRCode(version=1, box_size=10, border=5)
qr.add_data(uri)
qr.make(fit=True)
img = qr.make_image(fill_color="black", back_color="white")
buffer = io.BytesIO()
img.save(buffer, format="PNG")
qr_code_base64 = base64.b64encode(buffer.getvalue()).decode()
# Store secret temporarily (not enabled yet)
current_user.totp_secret = secret
db.commit()
return {
"secret": secret,
"qr_code": f"data:image/png;base64,{qr_code_base64}",
"uri": uri,
}
@router.post("/totp/verify")
async def verify_totp(
request: TOTPVerifyRequest,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
"""Verify and enable TOTP 2FA"""
if not current_user.totp_secret:
raise HTTPException(status_code=400, detail="TOTP not set up")
if not verify_totp_code(current_user.totp_secret, request.code):
raise HTTPException(status_code=400, detail="Invalid TOTP code")
# Enable TOTP
current_user.totp_enabled = True
db.commit()
return {"message": "2FA enabled successfully"}
@router.post("/totp/disable")
async def disable_totp(
request: TOTPVerifyRequest,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
"""Disable TOTP 2FA"""
if not current_user.totp_enabled:
raise HTTPException(status_code=400, detail="2FA not enabled")
if not verify_totp_code(current_user.totp_secret, request.code):
raise HTTPException(status_code=400, detail="Invalid TOTP code")
# Disable TOTP
current_user.totp_enabled = False
current_user.totp_secret = None
db.commit()
return {"message": "2FA disabled successfully"}
+109
View File
@@ -0,0 +1,109 @@
"""Bug Report API routes"""
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, EmailStr
from typing import Optional
import smtplib
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
import logging
from datetime import datetime
from app.api.auth import get_current_user
from app.models import User
router = APIRouter()
logger = logging.getLogger(__name__)
class BugReport(BaseModel):
subject: str
description: str
page_url: Optional[str] = None
error_details: Optional[str] = None
browser_info: Optional[str] = None
@router.post("/")
async def submit_bug_report(
bug_report: BugReport,
current_user: User = Depends(get_current_user),
):
"""Submit a bug report via email"""
try:
# Create email message
msg = MIMEMultipart('alternative')
msg['Subject'] = f"[Depl0y Bug Report] {bug_report.subject}"
msg['From'] = "agit8or@agit8or.net"
msg['To'] = "agit8or@agit8or.net"
# Create email body
text_body = f"""
Bug Report from Depl0y
Reported by: {current_user.username} ({current_user.email})
Time: {datetime.utcnow().strftime('%Y-%m-%d %H:%M:%S UTC')}
Page URL: {bug_report.page_url or 'N/A'}
Description:
{bug_report.description}
Error Details:
{bug_report.error_details or 'N/A'}
Browser Info:
{bug_report.browser_info or 'N/A'}
"""
html_body = f"""
<html>
<body style="font-family: Arial, sans-serif;">
<h2 style="color: #ef4444;">Bug Report from Depl0y</h2>
<table style="border-collapse: collapse; width: 100%; margin-bottom: 20px;">
<tr>
<td style="padding: 8px; border: 1px solid #ddd; background-color: #f9f9f9; font-weight: bold;">Reported by:</td>
<td style="padding: 8px; border: 1px solid #ddd;">{current_user.username} ({current_user.email})</td>
</tr>
<tr>
<td style="padding: 8px; border: 1px solid #ddd; background-color: #f9f9f9; font-weight: bold;">Time:</td>
<td style="padding: 8px; border: 1px solid #ddd;">{datetime.utcnow().strftime('%Y-%m-%d %H:%M:%S UTC')}</td>
</tr>
<tr>
<td style="padding: 8px; border: 1px solid #ddd; background-color: #f9f9f9; font-weight: bold;">Page URL:</td>
<td style="padding: 8px; border: 1px solid #ddd;">{bug_report.page_url or 'N/A'}</td>
</tr>
</table>
<div style="margin-bottom: 20px;">
<h3 style="color: #333;">Description:</h3>
<p style="white-space: pre-wrap; padding: 10px; background-color: #f5f5f5; border-left: 4px solid #ef4444;">{bug_report.description}</p>
</div>
<div style="margin-bottom: 20px;">
<h3 style="color: #333;">Error Details:</h3>
<pre style="padding: 10px; background-color: #f5f5f5; border-left: 4px solid #f59e0b; overflow-x: auto;">{bug_report.error_details or 'N/A'}</pre>
</div>
<div style="margin-bottom: 20px;">
<h3 style="color: #333;">Browser Info:</h3>
<p style="padding: 10px; background-color: #f5f5f5; border-left: 4px solid #3b82f6;">{bug_report.browser_info or 'N/A'}</p>
</div>
</body>
</html>
"""
# Attach both text and HTML versions
part1 = MIMEText(text_body, 'plain')
part2 = MIMEText(html_body, 'html')
msg.attach(part1)
msg.attach(part2)
# Send email using local sendmail
with smtplib.SMTP('localhost') as server:
server.send_message(msg)
logger.info(f"Bug report sent from {current_user.username}: {bug_report.subject}")
return {"status": "success", "message": "Bug report submitted successfully"}
except Exception as e:
logger.error(f"Failed to send bug report: {e}")
raise HTTPException(status_code=500, detail="Failed to send bug report. Please try again later.")
+565
View File
@@ -0,0 +1,565 @@
"""Cloud Images API endpoints"""
from fastapi import APIRouter, Depends, HTTPException, BackgroundTasks
from sqlalchemy.orm import Session
from typing import List
from pydantic import BaseModel
from app.core.database import get_db
from app.models import CloudImage
from app.api.auth import get_current_user
import logging
import os
logger = logging.getLogger(__name__)
router = APIRouter()
class CloudImageResponse(BaseModel):
id: int
name: str
filename: str
os_type: str
version: str | None
architecture: str
file_size: int | None
download_url: str
is_downloaded: bool
download_progress: int
download_status: str
is_available: bool
class Config:
from_attributes = True
@router.get("/", response_model=List[CloudImageResponse])
def list_cloud_images(
db: Session = Depends(get_db),
current_user=Depends(get_current_user)
):
"""List all available cloud images"""
try:
images = db.query(CloudImage).filter(CloudImage.is_available == True).all()
return images
except Exception as e:
logger.error(f"Failed to list cloud images: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.post("/fetch-latest")
def fetch_latest_cloud_images(
db: Session = Depends(get_db),
current_user=Depends(get_current_user)
):
"""Fetch the latest cloud image information from upstream sources and update existing images"""
try:
import requests
import re
from datetime import datetime
updated_images = []
errors = []
# Get all existing cloud images
images = db.query(CloudImage).filter(CloudImage.is_available == True).all()
for image in images:
try:
updated = False
old_version = image.version
old_url = image.download_url
# Ubuntu cloud images
if image.os_type == 'ubuntu' and 'ubuntu' in image.download_url.lower():
# Extract version from existing URL or use stored version
version_match = re.search(r'(\d+\.\d+)', image.download_url)
if version_match:
version = version_match.group(1)
# Check for new URL format
base_url = f"https://cloud-images.ubuntu.com/releases/{version}/release/"
try:
response = requests.head(base_url + image.filename, timeout=10)
if response.status_code == 200:
new_url = base_url + image.filename
if new_url != image.download_url:
image.download_url = new_url
updated = True
except:
pass
# Debian cloud images
elif image.os_type == 'debian' and 'debian' in image.download_url.lower():
# Try to get latest release URL
try:
# Debian uses a "latest" symlink for current release
if 'latest' in image.download_url:
# URL is already using latest, just verify it's accessible
response = requests.head(image.download_url, timeout=10, allow_redirects=True)
if response.status_code == 200:
# Update version if we can determine it from redirect
if 'debian-' in response.url:
version_match = re.search(r'debian-(\d+)', response.url)
if version_match and version_match.group(1) != image.version:
image.version = version_match.group(1)
updated = True
except:
pass
# Rocky Linux cloud images
elif image.os_type == 'rocky' and 'rocky' in image.download_url.lower():
# Rocky Linux has versioned releases
version_match = re.search(r'rocky[/-](\d+)', image.download_url, re.IGNORECASE)
if version_match:
version = version_match.group(1)
# Check if URL is still valid
try:
response = requests.head(image.download_url, timeout=10)
if response.status_code != 200:
# Try to construct new URL
base_url = f"https://download.rockylinux.org/pub/rocky/{version}/images/x86_64/"
response = requests.head(base_url + image.filename, timeout=10)
if response.status_code == 200:
image.download_url = base_url + image.filename
updated = True
except:
pass
if updated:
db.commit()
updated_images.append({
"name": image.name,
"old_version": old_version,
"new_version": image.version,
"old_url": old_url,
"new_url": image.download_url
})
logger.info(f"Updated cloud image: {image.name}")
except Exception as e:
error_msg = f"Failed to update {image.name}: {str(e)}"
logger.error(error_msg)
errors.append(error_msg)
continue
return {
"message": f"Checked {len(images)} cloud images",
"updated_count": len(updated_images),
"updated_images": updated_images,
"errors": errors if errors else None
}
except Exception as e:
logger.error(f"Failed to fetch latest cloud images: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.post("/{image_id}/download")
def download_cloud_image(
image_id: int,
background_tasks: BackgroundTasks,
db: Session = Depends(get_db),
current_user=Depends(get_current_user)
):
"""Trigger download of a cloud image"""
try:
image = db.query(CloudImage).filter(CloudImage.id == image_id).first()
if not image:
raise HTTPException(status_code=404, detail="Cloud image not found")
if image.is_downloaded:
return {"message": "Cloud image already downloaded", "image": image}
# Import here to avoid circular dependency
from app.services.cloud_images import download_cloud_image_task
# Start download in background
background_tasks.add_task(download_cloud_image_task, image_id, db)
return {"message": "Download started", "image_id": image_id}
except HTTPException:
raise
except Exception as e:
logger.error(f"Failed to start download: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.get("/{image_id}/progress")
def get_download_progress(
image_id: int,
db: Session = Depends(get_db),
current_user=Depends(get_current_user)
):
"""Get download progress for a cloud image"""
try:
image = db.query(CloudImage).filter(CloudImage.id == image_id).first()
if not image:
raise HTTPException(status_code=404, detail="Cloud image not found")
return {
"id": image.id,
"name": image.name,
"download_progress": image.download_progress,
"download_status": image.download_status,
"is_downloaded": image.is_downloaded
}
except HTTPException:
raise
except Exception as e:
logger.error(f"Failed to get download progress: {e}")
raise HTTPException(status_code=500, detail=str(e))
class CloudImageCreate(BaseModel):
name: str
filename: str
os_type: str
version: str | None = None
architecture: str = "amd64"
checksum: str | None = None
download_url: str
class CloudImageUpdate(BaseModel):
name: str | None = None
filename: str | None = None
os_type: str | None = None
version: str | None = None
architecture: str | None = None
checksum: str | None = None
download_url: str | None = None
is_available: bool | None = None
@router.post("/", response_model=CloudImageResponse)
def create_cloud_image(
image_data: CloudImageCreate,
db: Session = Depends(get_db),
current_user=Depends(get_current_user)
):
"""Create a new cloud image entry"""
try:
# Check if filename already exists
existing = db.query(CloudImage).filter(CloudImage.filename == image_data.filename).first()
if existing:
raise HTTPException(status_code=400, detail="Cloud image with this filename already exists")
new_image = CloudImage(
name=image_data.name,
filename=image_data.filename,
os_type=image_data.os_type,
version=image_data.version,
architecture=image_data.architecture,
checksum=image_data.checksum,
download_url=image_data.download_url,
download_status="pending",
is_downloaded=False
)
db.add(new_image)
db.commit()
db.refresh(new_image)
logger.info(f"Created new cloud image: {new_image.name}")
return new_image
except HTTPException:
raise
except Exception as e:
logger.error(f"Failed to create cloud image: {e}")
db.rollback()
raise HTTPException(status_code=500, detail=str(e))
@router.put("/{image_id}", response_model=CloudImageResponse)
def update_cloud_image(
image_id: int,
image_data: CloudImageUpdate,
db: Session = Depends(get_db),
current_user=Depends(get_current_user)
):
"""Update a cloud image entry"""
try:
image = db.query(CloudImage).filter(CloudImage.id == image_id).first()
if not image:
raise HTTPException(status_code=404, detail="Cloud image not found")
# Update fields if provided
if image_data.name is not None:
image.name = image_data.name
if image_data.filename is not None:
image.filename = image_data.filename
if image_data.os_type is not None:
image.os_type = image_data.os_type
if image_data.version is not None:
image.version = image_data.version
if image_data.architecture is not None:
image.architecture = image_data.architecture
if image_data.checksum is not None:
image.checksum = image_data.checksum
if image_data.download_url is not None:
image.download_url = image_data.download_url
if image_data.is_available is not None:
image.is_available = image_data.is_available
db.commit()
db.refresh(image)
logger.info(f"Updated cloud image: {image.name}")
return image
except HTTPException:
raise
except Exception as e:
logger.error(f"Failed to update cloud image: {e}")
db.rollback()
raise HTTPException(status_code=500, detail=str(e))
@router.delete("/{image_id}")
def delete_cloud_image(
image_id: int,
db: Session = Depends(get_db),
current_user=Depends(get_current_user)
):
"""Delete a cloud image"""
try:
image = db.query(CloudImage).filter(CloudImage.id == image_id).first()
if not image:
raise HTTPException(status_code=404, detail="Cloud image not found")
# Delete the file if it exists
if image.storage_path and os.path.exists(image.storage_path):
try:
os.remove(image.storage_path)
logger.info(f"Deleted cloud image file: {image.storage_path}")
except Exception as e:
logger.warning(f"Failed to delete cloud image file: {e}")
db.delete(image)
db.commit()
logger.info(f"Deleted cloud image: {image.name}")
return {"message": "Cloud image deleted successfully"}
except HTTPException:
raise
except Exception as e:
logger.error(f"Failed to delete cloud image: {e}")
db.rollback()
raise HTTPException(status_code=500, detail=str(e))
@router.get("/setup-script")
def get_template_setup_script(
db: Session = Depends(get_db),
current_user=Depends(get_current_user)
):
"""Get the shell script to setup cloud image templates on Proxmox"""
# Get first 3 cloud images for the script
images = db.query(CloudImage).filter(CloudImage.is_available == True).limit(3).all()
script_lines = [
"#!/bin/bash",
"# Cloud Image Template Setup Script for Proxmox",
"# Run this script on your Proxmox node as root",
"",
"set -e",
"",
"echo '============================================'",
"echo 'Setting up cloud image templates...'",
"echo '============================================'",
"",
]
for idx, image in enumerate(images):
template_id = 9000 + idx
script_lines.extend([
f"# {image.name}",
f"if ! qm status {template_id} &>/dev/null; then",
f" echo 'Creating template {template_id}: {image.name}'",
f" wget -q -O /var/lib/vz/template/iso/{image.filename} '{image.download_url}'",
f" qm create {template_id} --name '{image.name.lower().replace(' ', '-')}' --memory 2048 --cores 2 --net0 virtio,bridge=vmbr0",
f" qm importdisk {template_id} /var/lib/vz/template/iso/{image.filename} local-lvm",
f" qm set {template_id} --scsihw virtio-scsi-pci --scsi0 local-lvm:vm-{template_id}-disk-0",
f" qm set {template_id} --ide2 local-lvm:cloudinit",
f" qm set {template_id} --boot order=scsi0",
f" qm set {template_id} --serial0 socket --vga serial0",
f" qm set {template_id} --agent enabled=1",
f" qm template {template_id}",
f" echo '✓ Template {template_id} created'",
"else",
f" echo 'Template {template_id} already exists'",
"fi",
"",
])
script_lines.extend([
"echo '============================================'",
"echo 'Template setup complete!'",
"echo 'You can now use cloud images in Depl0y'",
"echo '============================================'",
])
return {
"script": "\n".join(script_lines)
}
@router.get("/templates/status/{node_id}")
def check_templates_on_node(
node_id: int,
db: Session = Depends(get_db),
current_user=Depends(get_current_user)
):
"""Check which cloud image templates exist on a Proxmox node"""
try:
from app.models import ProxmoxNode, ProxmoxHost
from app.services.proxmox import ProxmoxService
node = db.query(ProxmoxNode).filter(ProxmoxNode.id == node_id).first()
if not node:
raise HTTPException(status_code=404, detail="Node not found")
host = db.query(ProxmoxHost).filter(ProxmoxHost.id == node.host_id).first()
if not host:
raise HTTPException(status_code=404, detail="Proxmox host not found")
proxmox = ProxmoxService(host)
# Check templates 9000-9010
template_status = {}
for i in range(9000, 9011):
try:
proxmox.proxmox.nodes(node.node_name).qemu(i).status.current.get()
template_status[i] = True
except:
template_status[i] = False
return {
"node_id": node_id,
"node_name": node.node_name,
"templates": template_status
}
except HTTPException:
raise
except Exception as e:
logger.error(f"Failed to check templates: {e}")
raise HTTPException(status_code=500, detail=str(e))
class SetupTemplatesRequest(BaseModel):
node_id: int
cloud_image_ids: List[int]
@router.post("/setup-templates")
def setup_templates_automated(
request: SetupTemplatesRequest,
background_tasks: BackgroundTasks,
db: Session = Depends(get_db),
current_user=Depends(get_current_user)
):
"""Automatically setup cloud image templates on a Proxmox node"""
try:
from app.models import ProxmoxNode, ProxmoxHost
from app.services.cloud_images import create_template_on_node
node = db.query(ProxmoxNode).filter(ProxmoxNode.id == request.node_id).first()
if not node:
raise HTTPException(status_code=404, detail="Node not found")
host = db.query(ProxmoxHost).filter(ProxmoxHost.id == node.host_id).first()
if not host:
raise HTTPException(status_code=404, detail="Proxmox host not found")
# Validate all cloud images exist
for image_id in request.cloud_image_ids:
image = db.query(CloudImage).filter(CloudImage.id == image_id).first()
if not image:
raise HTTPException(status_code=404, detail=f"Cloud image {image_id} not found")
# Start template creation in background
for image_id in request.cloud_image_ids:
template_vmid = 9000 + (image_id - 1)
background_tasks.add_task(
create_template_on_node,
image_id,
request.node_id,
template_vmid,
db
)
return {
"message": f"Template setup started for {len(request.cloud_image_ids)} cloud images on node {node.node_name}",
"node_id": request.node_id,
"node_name": node.node_name,
"cloud_image_ids": request.cloud_image_ids
}
except HTTPException:
raise
except Exception as e:
logger.error(f"Failed to start template setup: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.get("/ssh-status")
def check_ssh_status(
db: Session = Depends(get_db),
current_user=Depends(get_current_user)
):
"""Check if SSH access is configured for Proxmox hosts"""
try:
import subprocess
from app.models import ProxmoxHost
# Get the first Proxmox host
host = db.query(ProxmoxHost).first()
if not host:
return {
"configured": False,
"message": "No Proxmox host configured"
}
# Test SSH connection
try:
# Try to run a simple command via SSH
result = subprocess.run(
[
'ssh',
'-o', 'BatchMode=yes',
'-o', 'ConnectTimeout=5',
'-o', 'StrictHostKeyChecking=no',
f'root@{host.hostname}',
'echo SSH_KEY_CONFIGURED'
],
capture_output=True,
text=True,
timeout=10
)
if result.returncode == 0 and 'SSH_KEY_CONFIGURED' in result.stdout:
return {
"configured": True,
"message": "SSH access is configured",
"host": host.hostname
}
else:
return {
"configured": False,
"message": "SSH access not configured. Please run the setup script.",
"host": host.hostname
}
except subprocess.TimeoutExpired:
return {
"configured": False,
"message": "SSH connection timed out",
"host": host.hostname
}
except Exception as ssh_error:
logger.warning(f"SSH check failed: {ssh_error}")
return {
"configured": False,
"message": "SSH access not configured",
"host": host.hostname
}
except Exception as e:
logger.error(f"Failed to check SSH status: {e}")
raise HTTPException(status_code=500, detail=str(e))
+457
View File
@@ -0,0 +1,457 @@
"""Cloud Images API endpoints"""
from fastapi import APIRouter, Depends, HTTPException, BackgroundTasks
from sqlalchemy.orm import Session
from typing import List
from pydantic import BaseModel
from app.core.database import get_db
from app.models import CloudImage
from app.api.auth import get_current_user
import logging
import os
logger = logging.getLogger(__name__)
router = APIRouter()
class CloudImageResponse(BaseModel):
id: int
name: str
filename: str
os_type: str
version: str | None
architecture: str
file_size: int | None
download_url: str
is_downloaded: bool
download_progress: int
download_status: str
is_available: bool
class Config:
from_attributes = True
@router.get("/", response_model=List[CloudImageResponse])
def list_cloud_images(
db: Session = Depends(get_db),
current_user=Depends(get_current_user)
):
"""List all available cloud images"""
try:
images = db.query(CloudImage).filter(CloudImage.is_available == True).all()
return images
except Exception as e:
logger.error(f"Failed to list cloud images: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.post("/{image_id}/download")
def download_cloud_image(
image_id: int,
background_tasks: BackgroundTasks,
db: Session = Depends(get_db),
current_user=Depends(get_current_user)
):
"""Trigger download of a cloud image"""
try:
image = db.query(CloudImage).filter(CloudImage.id == image_id).first()
if not image:
raise HTTPException(status_code=404, detail="Cloud image not found")
if image.is_downloaded:
return {"message": "Cloud image already downloaded", "image": image}
# Import here to avoid circular dependency
from app.services.cloud_images import download_cloud_image_task
# Start download in background
background_tasks.add_task(download_cloud_image_task, image_id, db)
return {"message": "Download started", "image_id": image_id}
except HTTPException:
raise
except Exception as e:
logger.error(f"Failed to start download: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.get("/{image_id}/progress")
def get_download_progress(
image_id: int,
db: Session = Depends(get_db),
current_user=Depends(get_current_user)
):
"""Get download progress for a cloud image"""
try:
image = db.query(CloudImage).filter(CloudImage.id == image_id).first()
if not image:
raise HTTPException(status_code=404, detail="Cloud image not found")
return {
"id": image.id,
"name": image.name,
"download_progress": image.download_progress,
"download_status": image.download_status,
"is_downloaded": image.is_downloaded
}
except HTTPException:
raise
except Exception as e:
logger.error(f"Failed to get download progress: {e}")
raise HTTPException(status_code=500, detail=str(e))
class CloudImageCreate(BaseModel):
name: str
filename: str
os_type: str
version: str | None = None
architecture: str = "amd64"
checksum: str | None = None
download_url: str
class CloudImageUpdate(BaseModel):
name: str | None = None
filename: str | None = None
os_type: str | None = None
version: str | None = None
architecture: str | None = None
checksum: str | None = None
download_url: str | None = None
is_available: bool | None = None
@router.post("/", response_model=CloudImageResponse)
def create_cloud_image(
image_data: CloudImageCreate,
db: Session = Depends(get_db),
current_user=Depends(get_current_user)
):
"""Create a new cloud image entry"""
try:
# Check if filename already exists
existing = db.query(CloudImage).filter(CloudImage.filename == image_data.filename).first()
if existing:
raise HTTPException(status_code=400, detail="Cloud image with this filename already exists")
new_image = CloudImage(
name=image_data.name,
filename=image_data.filename,
os_type=image_data.os_type,
version=image_data.version,
architecture=image_data.architecture,
checksum=image_data.checksum,
download_url=image_data.download_url,
download_status="pending",
is_downloaded=False
)
db.add(new_image)
db.commit()
db.refresh(new_image)
logger.info(f"Created new cloud image: {new_image.name}")
return new_image
except HTTPException:
raise
except Exception as e:
logger.error(f"Failed to create cloud image: {e}")
db.rollback()
raise HTTPException(status_code=500, detail=str(e))
@router.put("/{image_id}", response_model=CloudImageResponse)
def update_cloud_image(
image_id: int,
image_data: CloudImageUpdate,
db: Session = Depends(get_db),
current_user=Depends(get_current_user)
):
"""Update a cloud image entry"""
try:
image = db.query(CloudImage).filter(CloudImage.id == image_id).first()
if not image:
raise HTTPException(status_code=404, detail="Cloud image not found")
# Update fields if provided
if image_data.name is not None:
image.name = image_data.name
if image_data.filename is not None:
image.filename = image_data.filename
if image_data.os_type is not None:
image.os_type = image_data.os_type
if image_data.version is not None:
image.version = image_data.version
if image_data.architecture is not None:
image.architecture = image_data.architecture
if image_data.checksum is not None:
image.checksum = image_data.checksum
if image_data.download_url is not None:
image.download_url = image_data.download_url
if image_data.is_available is not None:
image.is_available = image_data.is_available
db.commit()
db.refresh(image)
logger.info(f"Updated cloud image: {image.name}")
return image
except HTTPException:
raise
except Exception as e:
logger.error(f"Failed to update cloud image: {e}")
db.rollback()
raise HTTPException(status_code=500, detail=str(e))
@router.delete("/{image_id}")
def delete_cloud_image(
image_id: int,
db: Session = Depends(get_db),
current_user=Depends(get_current_user)
):
"""Delete a cloud image"""
try:
image = db.query(CloudImage).filter(CloudImage.id == image_id).first()
if not image:
raise HTTPException(status_code=404, detail="Cloud image not found")
# Delete the file if it exists
if image.storage_path and os.path.exists(image.storage_path):
try:
os.remove(image.storage_path)
logger.info(f"Deleted cloud image file: {image.storage_path}")
except Exception as e:
logger.warning(f"Failed to delete cloud image file: {e}")
db.delete(image)
db.commit()
logger.info(f"Deleted cloud image: {image.name}")
return {"message": "Cloud image deleted successfully"}
except HTTPException:
raise
except Exception as e:
logger.error(f"Failed to delete cloud image: {e}")
db.rollback()
raise HTTPException(status_code=500, detail=str(e))
@router.get("/setup-script")
def get_template_setup_script(
db: Session = Depends(get_db),
current_user=Depends(get_current_user)
):
"""Get the shell script to setup cloud image templates on Proxmox"""
# Get first 3 cloud images for the script
images = db.query(CloudImage).filter(CloudImage.is_available == True).limit(3).all()
script_lines = [
"#!/bin/bash",
"# Cloud Image Template Setup Script for Proxmox",
"# Run this script on your Proxmox node as root",
"",
"set -e",
"",
"echo '============================================'",
"echo 'Setting up cloud image templates...'",
"echo '============================================'",
"",
]
for idx, image in enumerate(images):
template_id = 9000 + idx
script_lines.extend([
f"# {image.name}",
f"if ! qm status {template_id} &>/dev/null; then",
f" echo 'Creating template {template_id}: {image.name}'",
f" wget -q -O /var/lib/vz/template/iso/{image.filename} '{image.download_url}'",
f" qm create {template_id} --name '{image.name.lower().replace(' ', '-')}' --memory 2048 --cores 2 --net0 virtio,bridge=vmbr0",
f" qm importdisk {template_id} /var/lib/vz/template/iso/{image.filename} local-lvm",
f" qm set {template_id} --scsihw virtio-scsi-pci --scsi0 local-lvm:vm-{template_id}-disk-0",
f" qm set {template_id} --ide2 local-lvm:cloudinit",
f" qm set {template_id} --boot order=scsi0",
f" qm set {template_id} --serial0 socket --vga serial0",
f" qm set {template_id} --agent enabled=1",
f" qm template {template_id}",
f" echo '✓ Template {template_id} created'",
"else",
f" echo 'Template {template_id} already exists'",
"fi",
"",
])
script_lines.extend([
"echo '============================================'",
"echo 'Template setup complete!'",
"echo 'You can now use cloud images in Depl0y'",
"echo '============================================'",
])
return {
"script": "\n".join(script_lines)
}
@router.get("/templates/status/{node_id}")
def check_templates_on_node(
node_id: int,
db: Session = Depends(get_db),
current_user=Depends(get_current_user)
):
"""Check which cloud image templates exist on a Proxmox node"""
try:
from app.models import ProxmoxNode, ProxmoxHost
from app.services.proxmox import ProxmoxService
node = db.query(ProxmoxNode).filter(ProxmoxNode.id == node_id).first()
if not node:
raise HTTPException(status_code=404, detail="Node not found")
host = db.query(ProxmoxHost).filter(ProxmoxHost.id == node.host_id).first()
if not host:
raise HTTPException(status_code=404, detail="Proxmox host not found")
proxmox = ProxmoxService(host)
# Check templates 9000-9010
template_status = {}
for i in range(9000, 9011):
try:
proxmox.proxmox.nodes(node.node_name).qemu(i).status.current.get()
template_status[i] = True
except:
template_status[i] = False
return {
"node_id": node_id,
"node_name": node.node_name,
"templates": template_status
}
except HTTPException:
raise
except Exception as e:
logger.error(f"Failed to check templates: {e}")
raise HTTPException(status_code=500, detail=str(e))
class SetupTemplatesRequest(BaseModel):
node_id: int
cloud_image_ids: List[int]
@router.post("/setup-templates")
def setup_templates_automated(
request: SetupTemplatesRequest,
background_tasks: BackgroundTasks,
db: Session = Depends(get_db),
current_user=Depends(get_current_user)
):
"""Automatically setup cloud image templates on a Proxmox node"""
try:
from app.models import ProxmoxNode, ProxmoxHost
from app.services.cloud_images import create_template_on_node
node = db.query(ProxmoxNode).filter(ProxmoxNode.id == request.node_id).first()
if not node:
raise HTTPException(status_code=404, detail="Node not found")
host = db.query(ProxmoxHost).filter(ProxmoxHost.id == node.host_id).first()
if not host:
raise HTTPException(status_code=404, detail="Proxmox host not found")
# Validate all cloud images exist
for image_id in request.cloud_image_ids:
image = db.query(CloudImage).filter(CloudImage.id == image_id).first()
if not image:
raise HTTPException(status_code=404, detail=f"Cloud image {image_id} not found")
# Start template creation in background
for image_id in request.cloud_image_ids:
template_vmid = 9000 + (image_id - 1)
background_tasks.add_task(
create_template_on_node,
image_id,
request.node_id,
template_vmid,
db
)
return {
"message": f"Template setup started for {len(request.cloud_image_ids)} cloud images on node {node.node_name}",
"node_id": request.node_id,
"node_name": node.node_name,
"cloud_image_ids": request.cloud_image_ids
}
except HTTPException:
raise
except Exception as e:
logger.error(f"Failed to start template setup: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.get("/ssh-status")
def check_ssh_status(
db: Session = Depends(get_db),
current_user=Depends(get_current_user)
):
"""Check if SSH access is configured for Proxmox hosts"""
try:
import subprocess
from app.models import ProxmoxHost
# Get the first Proxmox host
host = db.query(ProxmoxHost).first()
if not host:
return {
"configured": False,
"message": "No Proxmox host configured"
}
# Test SSH connection
try:
# Try to run a simple command via SSH
result = subprocess.run(
[
'ssh',
'-o', 'BatchMode=yes',
'-o', 'ConnectTimeout=5',
'-o', 'StrictHostKeyChecking=no',
f'root@{host.hostname}',
'echo SSH_KEY_CONFIGURED'
],
capture_output=True,
text=True,
timeout=10
)
if result.returncode == 0 and 'SSH_KEY_CONFIGURED' in result.stdout:
return {
"configured": True,
"message": "SSH access is configured",
"host": host.hostname
}
else:
return {
"configured": False,
"message": "SSH access not configured. Please run the setup script.",
"host": host.hostname
}
except subprocess.TimeoutExpired:
return {
"configured": False,
"message": "SSH connection timed out",
"host": host.hostname
}
except Exception as ssh_error:
logger.warning(f"SSH check failed: {ssh_error}")
return {
"configured": False,
"message": "SSH access not configured",
"host": host.hostname
}
except Exception as e:
logger.error(f"Failed to check SSH status: {e}")
raise HTTPException(status_code=500, detail=str(e))
+198
View File
@@ -0,0 +1,198 @@
"""Dashboard API routes"""
from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session
from sqlalchemy import func
from pydantic import BaseModel
import logging
from app.core.database import get_db
from app.models import (
VirtualMachine,
VMStatus,
ProxmoxHost,
ProxmoxNode,
ISOImage,
User,
UpdateLog,
)
from app.api.auth import get_current_user
router = APIRouter()
logger = logging.getLogger(__name__)
# Pydantic models
class DashboardStats(BaseModel):
total_vms: int
running_vms: int
stopped_vms: int
paused_vms: int
datacenters: int
total_nodes: int
total_isos: int
total_users: int
class ResourceStats(BaseModel):
total_cpu_cores: int
total_memory_gb: float
total_disk_gb: float
used_cpu_cores: int
used_memory_gb: float
used_disk_gb: float
@router.get("/stats", response_model=DashboardStats)
async def get_dashboard_stats(
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
"""Get dashboard statistics - queries actual Proxmox data"""
from app.services.proxmox import ProxmoxService
# Query all active Proxmox hosts and get VM counts from Proxmox itself
active_hosts = db.query(ProxmoxHost).filter(ProxmoxHost.is_active == True).all()
total_vms = 0
running_vms = 0
stopped_vms = 0
paused_vms = 0
for host in active_hosts:
try:
service = ProxmoxService(host)
vms = service.get_all_vms()
total_vms += len(vms)
for vm in vms:
status = vm.get('status', '').lower()
if status == 'running':
running_vms += 1
elif status == 'stopped':
stopped_vms += 1
elif status == 'paused':
paused_vms += 1
except Exception as e:
logger.error(f"Failed to get VMs from host {host.name}: {e}")
# Datacenter count (number of Proxmox hosts)
datacenters = len(active_hosts)
# Node statistics
total_nodes = db.query(ProxmoxNode).count()
# ISO statistics
total_isos = db.query(ISOImage).filter(ISOImage.is_available == True).count()
# User statistics (admin only)
if current_user.role.value == "admin":
total_users = db.query(User).count()
else:
total_users = 0
return {
"total_vms": total_vms,
"running_vms": running_vms,
"stopped_vms": stopped_vms,
"paused_vms": paused_vms,
"datacenters": datacenters,
"total_nodes": total_nodes,
"total_isos": total_isos,
"total_users": total_users,
}
@router.get("/resources", response_model=ResourceStats)
async def get_resource_stats(
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
"""Get resource statistics across all nodes"""
# Query node resources - these are ACTUAL usage from Proxmox
nodes = db.query(ProxmoxNode).all()
total_cpu_cores = 0
total_memory_bytes = 0
total_disk_bytes = 0
used_memory_bytes = 0
used_disk_bytes = 0
total_cpu_usage_percent = 0
node_count = 0
for node in nodes:
if node.cpu_cores:
total_cpu_cores += node.cpu_cores
if node.memory_total:
total_memory_bytes += node.memory_total
if node.memory_used:
used_memory_bytes += node.memory_used
if node.disk_total:
total_disk_bytes += node.disk_total
if node.disk_used:
used_disk_bytes += node.disk_used
if node.cpu_usage is not None:
total_cpu_usage_percent += node.cpu_usage
node_count += 1
# Calculate used CPU cores based on average CPU usage across nodes
# This gives actual CPU usage, not just allocated cores
if node_count > 0 and total_cpu_cores > 0:
avg_cpu_usage_percent = total_cpu_usage_percent / node_count
used_cpu_cores = int((avg_cpu_usage_percent / 100.0) * total_cpu_cores)
else:
used_cpu_cores = 0
return {
"total_cpu_cores": total_cpu_cores,
"total_memory_gb": round(total_memory_bytes / (1024**3), 2),
"total_disk_gb": round(total_disk_bytes / (1024**3), 2),
"used_cpu_cores": used_cpu_cores,
"used_memory_gb": round(used_memory_bytes / (1024**3), 2),
"used_disk_gb": round(used_disk_bytes / (1024**3), 2),
}
@router.get("/activity")
async def get_recent_activity(
limit: int = 10,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
"""Get recent activity"""
# Recent VMs
recent_vms = (
db.query(VirtualMachine)
.order_by(VirtualMachine.created_at.desc())
.limit(limit)
.all()
)
# Recent updates
recent_updates = (
db.query(UpdateLog)
.order_by(UpdateLog.started_at.desc())
.limit(limit)
.all()
)
return {
"recent_vms": [
{
"id": vm.id,
"name": vm.name,
"status": vm.status.value,
"created_at": vm.created_at.isoformat(),
}
for vm in recent_vms
],
"recent_updates": [
{
"id": log.id,
"vm_id": log.vm_id,
"status": log.status,
"packages_updated": log.packages_updated,
"started_at": log.started_at.isoformat(),
}
for log in recent_updates
],
}
+354
View File
@@ -0,0 +1,354 @@
"""Documentation API endpoints"""
from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import PlainTextResponse, JSONResponse, FileResponse
from app.api.auth import get_current_user
import logging
import os
import markdown
from weasyprint import HTML, CSS
from datetime import datetime
import tempfile
logger = logging.getLogger(__name__)
router = APIRouter()
# Documentation files directory
DOCS_DIR = "/opt/depl0y/docs"
DOCS_FILES = {
"install": "INSTALL.md",
"deployment": "DEPLOYMENT.md",
"cloud-quickstart": "CLOUD_IMAGES_QUICKSTART.md",
"cloud-guide": "CLOUD_IMAGES_GUIDE.md",
"readme": "README.md",
"proxmox-api-tokens": "PROXMOX_API_TOKENS.md",
"cloud-index": "docs/CLOUD_IMAGES_INDEX.md"
}
@router.get("/")
def list_documentation(current_user=Depends(get_current_user)):
"""List all available documentation"""
docs = []
for key, filename in DOCS_FILES.items():
filepath = os.path.join(DOCS_DIR, filename)
if os.path.exists(filepath):
size = os.path.getsize(filepath)
docs.append({
"id": key,
"filename": filename,
"title": _get_title(key),
"size": size,
"available": True
})
else:
docs.append({
"id": key,
"filename": filename,
"title": _get_title(key),
"available": False
})
return {"docs": docs}
@router.get("/{doc_id}")
def get_documentation(
doc_id: str,
format: str = "markdown",
current_user=Depends(get_current_user)
):
"""Get a specific documentation file"""
if doc_id not in DOCS_FILES:
raise HTTPException(status_code=404, detail="Documentation not found")
filename = DOCS_FILES[doc_id]
filepath = os.path.join(DOCS_DIR, filename)
if not os.path.exists(filepath):
raise HTTPException(
status_code=404,
detail=f"Documentation file {filename} not found on server"
)
try:
with open(filepath, 'r', encoding='utf-8') as f:
content = f.read()
if format == "json":
return JSONResponse({
"id": doc_id,
"filename": filename,
"title": _get_title(doc_id),
"content": content,
"format": "markdown"
})
else:
# Return plain text markdown
return PlainTextResponse(content, media_type="text/markdown")
except Exception as e:
logger.error(f"Failed to read documentation {doc_id}: {e}")
raise HTTPException(
status_code=500,
detail=f"Failed to read documentation: {str(e)}"
)
def _get_title(doc_id: str) -> str:
"""Get human-readable title for documentation"""
titles = {
"install": "Installation Guide",
"deployment": "Deployment Guide",
"cloud-quickstart": "Cloud Images - Quick Start",
"cloud-guide": "Cloud Images - Complete Guide",
"readme": "Getting Started with Depl0y",
"proxmox-api-tokens": "Proxmox API Tokens Setup",
"cloud-index": "Cloud Images Documentation Index"
}
return titles.get(doc_id, doc_id.replace("-", " ").title())
@router.get("/download/pdf")
def download_documentation_pdf(current_user=Depends(get_current_user)):
"""Generate and download complete documentation as PDF"""
try:
# Order of documentation to include in PDF
doc_order = [
"readme",
"install",
"deployment",
"cloud-quickstart",
"cloud-guide",
"proxmox-api-tokens"
]
# Build HTML content
html_content = """
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Depl0y Documentation</title>
<style>
@page {
size: A4;
margin: 2cm;
@top-center {
content: "Depl0y Documentation";
font-size: 10pt;
color: #666;
}
@bottom-center {
content: counter(page);
font-size: 10pt;
color: #666;
}
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
line-height: 1.6;
color: #333;
max-width: 100%;
}
h1 {
color: #2563eb;
border-bottom: 3px solid #2563eb;
padding-bottom: 0.5rem;
margin-top: 2rem;
page-break-before: always;
}
h1:first-of-type {
page-break-before: avoid;
}
h2 {
color: #1e40af;
border-bottom: 2px solid #dbeafe;
padding-bottom: 0.3rem;
margin-top: 1.5rem;
}
h3 {
color: #4338ca;
margin-top: 1rem;
}
code {
background: #f3f4f6;
padding: 0.2rem 0.4rem;
border-radius: 3px;
font-family: "Courier New", monospace;
font-size: 0.9em;
}
pre {
background: #1e293b;
color: #e2e8f0;
padding: 1rem;
border-radius: 5px;
overflow-x: auto;
page-break-inside: avoid;
}
pre code {
background: none;
color: #10b981;
padding: 0;
}
ul, ol {
margin-left: 1.5rem;
}
li {
margin-bottom: 0.5rem;
}
table {
width: 100%;
border-collapse: collapse;
margin: 1rem 0;
page-break-inside: avoid;
}
th, td {
border: 1px solid #e5e7eb;
padding: 0.5rem;
text-align: left;
}
th {
background: #f9fafb;
font-weight: 600;
}
blockquote {
border-left: 4px solid #2563eb;
padding-left: 1rem;
margin: 1rem 0;
color: #6b7280;
font-style: italic;
}
a {
color: #2563eb;
text-decoration: none;
}
.cover-page {
text-align: center;
padding: 5rem 2rem;
page-break-after: always;
}
.cover-title {
font-size: 3rem;
color: #2563eb;
margin-bottom: 1rem;
}
.cover-subtitle {
font-size: 1.5rem;
color: #6b7280;
margin-bottom: 3rem;
}
.cover-info {
font-size: 1rem;
color: #9ca3af;
}
.toc {
page-break-after: always;
}
.toc h1 {
page-break-before: avoid;
}
.toc ul {
list-style: none;
padding-left: 0;
}
.toc li {
margin-bottom: 0.5rem;
}
.doc-section {
page-break-before: always;
}
.doc-section:first-of-type {
page-break-before: avoid;
}
</style>
</head>
<body>
<!-- Cover Page -->
<div class="cover-page">
<h1 class="cover-title">Depl0y</h1>
<p class="cover-subtitle">Complete Documentation</p>
<p class="cover-info">Automated VM Deployment Panel for Proxmox VE</p>
<p class="cover-info">Generated: """ + datetime.now().strftime("%B %d, %Y") + """</p>
<p class="cover-info">Version 1.1.3</p>
</div>
<!-- Table of Contents -->
<div class="toc">
<h1>Table of Contents</h1>
<ul>
"""
# Add TOC entries
for doc_id in doc_order:
title = _get_title(doc_id)
html_content += f' <li>{title}</li>\n'
html_content += """
</ul>
</div>
"""
# Add each documentation section
for doc_id in doc_order:
if doc_id not in DOCS_FILES:
continue
filename = DOCS_FILES[doc_id]
filepath = os.path.join(DOCS_DIR, filename)
if not os.path.exists(filepath):
logger.warning(f"Documentation file {filename} not found, skipping")
continue
try:
with open(filepath, 'r', encoding='utf-8') as f:
md_content = f.read()
# Convert markdown to HTML
html_section = markdown.markdown(
md_content,
extensions=['extra', 'codehilite', 'tables', 'toc']
)
html_content += f'<div class="doc-section">\n{html_section}\n</div>\n'
except Exception as e:
logger.error(f"Failed to process {doc_id}: {e}")
continue
html_content += """
</body>
</html>
"""
# Generate PDF
logger.info("Generating PDF from HTML...")
# Create temporary file for PDF
with tempfile.NamedTemporaryFile(delete=False, suffix='.pdf') as tmp_file:
pdf_path = tmp_file.name
# Generate PDF using WeasyPrint
HTML(string=html_content).write_pdf(pdf_path)
logger.info(f"PDF generated successfully at {pdf_path}")
# Return PDF file
filename = f"Depl0y_Documentation_{datetime.now().strftime('%Y%m%d')}.pdf"
return FileResponse(
pdf_path,
media_type="application/pdf",
filename=filename,
headers={
"Content-Disposition": f"attachment; filename={filename}"
}
)
except Exception as e:
logger.error(f"Failed to generate PDF: {e}", exc_info=True)
raise HTTPException(
status_code=500,
detail=f"Failed to generate PDF: {str(e)}"
)
+524
View File
@@ -0,0 +1,524 @@
"""High Availability API endpoints"""
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from app.api.auth import get_current_user, require_admin
from app.core.database import get_db
from sqlalchemy.orm import Session
import logging
import subprocess
logger = logging.getLogger(__name__)
router = APIRouter()
class HAEnableRequest(BaseModel):
proxmox_password: str
@router.get("/status")
def check_ha_status(current_user=Depends(get_current_user), db: Session = Depends(get_db)):
"""Check if High Availability is enabled on Proxmox cluster"""
try:
from app.models import ProxmoxHost
import json
# Get first Proxmox host to check HA status
host = db.query(ProxmoxHost).first()
if not host:
return {
"enabled": False,
"protected_vms": 0,
"manager_status": "unknown",
"quorum": False,
"message": "No Proxmox hosts configured"
}
# Check if HA is enabled using pvesh
ssh_host = f"root@{host.hostname}"
check_ha = f"ssh -o StrictHostKeyChecking=no -o BatchMode=yes {ssh_host} 'pvesh get /cluster/ha/status/manager_status --output-format json 2>/dev/null'"
result = subprocess.run(check_ha, shell=True, capture_output=True, timeout=10, text=True)
manager_status = "unknown"
quorum = False
protected_vms = 0
if result.returncode == 0 and result.stdout:
try:
ha_data = json.loads(result.stdout)
# Parse quorum status
if "quorum" in ha_data and isinstance(ha_data["quorum"], dict):
quorum = ha_data["quorum"].get("quorate") == "1"
# Parse manager status
if "manager_status" in ha_data and isinstance(ha_data["manager_status"], dict):
node_status = ha_data["manager_status"].get("node_status", {})
if node_status:
# Get first node's status
first_node = next(iter(node_status.values()), {})
manager_status = first_node.get("state", "unknown")
else:
manager_status = "active"
# Get number of protected resources
get_resources = f"ssh -o StrictHostKeyChecking=no -o BatchMode=yes {ssh_host} 'pvesh get /cluster/ha/resources --output-format json 2>/dev/null'"
resources_result = subprocess.run(get_resources, shell=True, capture_output=True, timeout=10, text=True)
if resources_result.returncode == 0:
try:
resources = json.loads(resources_result.stdout)
protected_vms = len(resources) if isinstance(resources, list) else 0
except:
pass
return {
"enabled": True,
"protected_vms": protected_vms,
"manager_status": manager_status,
"quorum": quorum,
"message": "High Availability is enabled"
}
except json.JSONDecodeError:
logger.error(f"Failed to parse HA status JSON: {result.stdout}")
return {
"enabled": False,
"protected_vms": 0,
"manager_status": "unknown",
"quorum": False,
"message": "High Availability not configured"
}
except Exception as e:
logger.error(f"Failed to check HA status: {e}")
return {
"enabled": False,
"protected_vms": 0,
"manager_status": "unknown",
"quorum": False,
"message": "Failed to check HA status"
}
@router.post("/enable")
def enable_ha(
request: HAEnableRequest,
current_user=Depends(require_admin),
db: Session = Depends(get_db)
):
"""Enable High Availability on Proxmox cluster"""
try:
from app.models import ProxmoxHost
host = db.query(ProxmoxHost).first()
if not host:
raise HTTPException(status_code=400, detail="No Proxmox hosts configured")
ssh_host = f"root@{host.hostname}"
logger.info(f"Enabling HA on {ssh_host}")
# Enable HA Manager
# This requires the cluster to have a quorum
enable_script = f"""
ssh -o StrictHostKeyChecking=no -o BatchMode=yes {ssh_host} '
# Check if cluster has quorum
if ! pvesh get /cluster/status 2>/dev/null | grep -q "quorate.*1"; then
echo "ERROR: Cluster does not have quorum. HA requires a multi-node cluster with quorum."
exit 1
fi
# HA Manager is usually enabled by default if cluster exists
# Just verify it is running
if ! systemctl is-active --quiet pve-ha-lrm && ! systemctl is-active --quiet pve-ha-crm; then
echo "Starting HA services..."
systemctl start pve-ha-lrm
systemctl start pve-ha-crm
systemctl enable pve-ha-lrm
systemctl enable pve-ha-crm
fi
echo "HA services are running"
'
"""
result = subprocess.run(enable_script, shell=True, capture_output=True, timeout=30)
if result.returncode != 0:
error_msg = result.stderr.decode() if result.stderr else result.stdout.decode()
if "does not have quorum" in error_msg or "quorate" in error_msg:
raise HTTPException(
status_code=400,
detail="High Availability requires a multi-node Proxmox cluster with quorum. Single-node setups cannot use HA."
)
raise HTTPException(status_code=500, detail=f"Failed to enable HA: {error_msg}")
logger.info("HA enabled successfully")
return {
"success": True,
"message": "High Availability enabled successfully. You can now add VMs to HA groups via the Proxmox web interface."
}
except HTTPException:
raise
except Exception as e:
logger.error(f"Failed to enable HA: {e}")
raise HTTPException(status_code=500, detail=f"Failed to enable HA: {str(e)}")
@router.get("/groups")
def list_ha_groups(
current_user=Depends(get_current_user),
db: Session = Depends(get_db)
):
"""List HA groups (migrated to rules in Proxmox 8+)"""
try:
from app.models import ProxmoxHost
host = db.query(ProxmoxHost).first()
if not host:
return {"groups": [], "message": "No Proxmox hosts configured"}
ssh_host = f"root@{host.hostname}"
# Get HA groups - handle migration to rules
get_groups = f"ssh -o StrictHostKeyChecking=no -o BatchMode=yes {ssh_host} 'pvesh get /cluster/ha/groups --output-format json 2>&1'"
result = subprocess.run(get_groups, shell=True, capture_output=True, timeout=10, text=True)
# Check if groups migrated to rules (Proxmox 8+)
if "migrated to rules" in result.stderr or "migrated to rules" in result.stdout:
return {
"groups": [],
"message": "HA groups migrated to rules in Proxmox 8+. Manage via Proxmox web interface."
}
if result.returncode == 0:
import json
try:
groups = json.loads(result.stdout)
return {"groups": groups if isinstance(groups, list) else []}
except:
return {"groups": []}
else:
return {"groups": [], "message": "HA groups not configured"}
except Exception as e:
logger.error(f"Failed to list HA groups: {e}")
return {"groups": [], "error": str(e)}
@router.post("/disable")
def disable_ha(
current_user=Depends(require_admin),
db: Session = Depends(get_db)
):
"""Disable High Availability (note: this just stops the services, VMs remain in HA config)"""
try:
from app.models import ProxmoxHost
host = db.query(ProxmoxHost).first()
if not host:
raise HTTPException(status_code=400, detail="No Proxmox hosts configured")
ssh_host = f"root@{host.hostname}"
# Stop HA services
disable_script = f"""
ssh -o StrictHostKeyChecking=no -o BatchMode=yes {ssh_host} '
systemctl stop pve-ha-lrm
systemctl stop pve-ha-crm
systemctl disable pve-ha-lrm
systemctl disable pve-ha-crm
echo "HA services stopped"
'
"""
result = subprocess.run(disable_script, shell=True, capture_output=True, timeout=30)
if result.returncode != 0:
error_msg = result.stderr.decode() if result.stderr else "Unknown error"
raise HTTPException(status_code=500, detail=f"Failed to disable HA: {error_msg}")
return {
"success": True,
"message": "HA services disabled. VMs remain in HA configuration but will not be automatically restarted."
}
except HTTPException:
raise
except Exception as e:
logger.error(f"Failed to disable HA: {e}")
raise HTTPException(status_code=500, detail=f"Failed to disable HA: {str(e)}")
class HAGroupCreate(BaseModel):
group: str
nodes: str # Comma-separated list of nodes
restricted: int = 0 # 0 or 1
nofailback: int = 0 # 0 or 1
comment: str = None
@router.post("/groups")
def create_ha_group(
request: HAGroupCreate,
current_user=Depends(require_admin),
db: Session = Depends(get_db)
):
"""Create a new HA group"""
try:
from app.models import ProxmoxHost
host = db.query(ProxmoxHost).first()
if not host:
raise HTTPException(status_code=400, detail="No Proxmox hosts configured")
ssh_host = f"root@{host.hostname}"
# Build pvesh command to create HA group
cmd = f"pvesh create /cluster/ha/groups -group {request.group} -nodes {request.nodes}"
if request.restricted:
cmd += f" -restricted {request.restricted}"
if request.nofailback:
cmd += f" -nofailback {request.nofailback}"
if request.comment:
cmd += f" -comment '{request.comment}'"
create_cmd = f"ssh -o StrictHostKeyChecking=no -o BatchMode=yes {ssh_host} '{cmd}'"
result = subprocess.run(create_cmd, shell=True, capture_output=True, timeout=10)
if result.returncode != 0:
error_msg = result.stderr.decode() if result.stderr else "Unknown error"
raise HTTPException(status_code=500, detail=f"Failed to create HA group: {error_msg}")
logger.info(f"Created HA group {request.group}")
return {
"success": True,
"message": f"HA group {request.group} created successfully"
}
except HTTPException:
raise
except Exception as e:
logger.error(f"Failed to create HA group: {e}")
raise HTTPException(status_code=500, detail=f"Failed to create HA group: {str(e)}")
class HAGroupUpdate(BaseModel):
nodes: str = None
restricted: int = None
nofailback: int = None
comment: str = None
@router.put("/groups/{group_id}")
def update_ha_group(
group_id: str,
request: HAGroupUpdate,
current_user=Depends(require_admin),
db: Session = Depends(get_db)
):
"""Update an existing HA group"""
try:
from app.models import ProxmoxHost
host = db.query(ProxmoxHost).first()
if not host:
raise HTTPException(status_code=400, detail="No Proxmox hosts configured")
ssh_host = f"root@{host.hostname}"
# Build pvesh command to update HA group
cmd = f"pvesh set /cluster/ha/groups/{group_id}"
if request.nodes:
cmd += f" -nodes {request.nodes}"
if request.restricted is not None:
cmd += f" -restricted {request.restricted}"
if request.nofailback is not None:
cmd += f" -nofailback {request.nofailback}"
if request.comment is not None:
cmd += f" -comment '{request.comment}'"
update_cmd = f"ssh -o StrictHostKeyChecking=no -o BatchMode=yes {ssh_host} '{cmd}'"
result = subprocess.run(update_cmd, shell=True, capture_output=True, timeout=10)
if result.returncode != 0:
error_msg = result.stderr.decode() if result.stderr else "Unknown error"
raise HTTPException(status_code=500, detail=f"Failed to update HA group: {error_msg}")
logger.info(f"Updated HA group {group_id}")
return {
"success": True,
"message": f"HA group {group_id} updated successfully"
}
except HTTPException:
raise
except Exception as e:
logger.error(f"Failed to update HA group: {e}")
raise HTTPException(status_code=500, detail=f"Failed to update HA group: {str(e)}")
@router.delete("/groups/{group_id}")
def delete_ha_group(
group_id: str,
current_user=Depends(require_admin),
db: Session = Depends(get_db)
):
"""Delete an HA group"""
try:
from app.models import ProxmoxHost
host = db.query(ProxmoxHost).first()
if not host:
raise HTTPException(status_code=400, detail="No Proxmox hosts configured")
ssh_host = f"root@{host.hostname}"
delete_cmd = f"ssh -o StrictHostKeyChecking=no -o BatchMode=yes {ssh_host} 'pvesh delete /cluster/ha/groups/{group_id}'"
result = subprocess.run(delete_cmd, shell=True, capture_output=True, timeout=10)
if result.returncode != 0:
error_msg = result.stderr.decode() if result.stderr else "Unknown error"
raise HTTPException(status_code=500, detail=f"Failed to delete HA group: {error_msg}")
logger.info(f"Deleted HA group {group_id}")
return {
"success": True,
"message": f"HA group {group_id} deleted successfully"
}
except HTTPException:
raise
except Exception as e:
logger.error(f"Failed to delete HA group: {e}")
raise HTTPException(status_code=500, detail=f"Failed to delete HA group: {str(e)}")
@router.get("/resources")
def list_ha_resources(
current_user=Depends(get_current_user),
db: Session = Depends(get_db)
):
"""List all HA-protected resources"""
try:
from app.models import ProxmoxHost
host = db.query(ProxmoxHost).first()
if not host:
raise HTTPException(status_code=400, detail="No Proxmox hosts configured")
ssh_host = f"root@{host.hostname}"
get_resources = f"ssh -o StrictHostKeyChecking=no -o BatchMode=yes {ssh_host} 'pvesh get /cluster/ha/resources --output-format json 2>/dev/null'"
result = subprocess.run(get_resources, shell=True, capture_output=True, timeout=10)
if result.returncode == 0:
import json
try:
resources = json.loads(result.stdout.decode())
return {"resources": resources if isinstance(resources, list) else []}
except:
return {"resources": []}
else:
return {"resources": []}
except Exception as e:
logger.error(f"Failed to list HA resources: {e}")
raise HTTPException(status_code=500, detail=f"Failed to list HA resources: {str(e)}")
class HAResourceAdd(BaseModel):
sid: str # Resource ID (e.g., "vm:100")
group: str = None # HA group name
max_relocate: int = 1 # Maximum relocate attempts
max_restart: int = 1 # Maximum restart attempts
state: str = "started" # started, stopped, ignored, disabled
comment: str = None
@router.post("/resources")
def add_ha_resource(
request: HAResourceAdd,
current_user=Depends(require_admin),
db: Session = Depends(get_db)
):
"""Add a VM to HA protection"""
try:
from app.models import ProxmoxHost
host = db.query(ProxmoxHost).first()
if not host:
raise HTTPException(status_code=400, detail="No Proxmox hosts configured")
ssh_host = f"root@{host.hostname}"
# Build pvesh command to add HA resource
cmd = f"pvesh create /cluster/ha/resources -sid {request.sid}"
if request.group:
cmd += f" -group {request.group}"
cmd += f" -max_relocate {request.max_relocate}"
cmd += f" -max_restart {request.max_restart}"
cmd += f" -state {request.state}"
if request.comment:
cmd += f" -comment '{request.comment}'"
add_cmd = f"ssh -o StrictHostKeyChecking=no -o BatchMode=yes {ssh_host} '{cmd}'"
result = subprocess.run(add_cmd, shell=True, capture_output=True, timeout=10)
if result.returncode != 0:
error_msg = result.stderr.decode() if result.stderr else "Unknown error"
raise HTTPException(status_code=500, detail=f"Failed to add HA resource: {error_msg}")
logger.info(f"Added HA resource {request.sid}")
return {
"success": True,
"message": f"Resource {request.sid} added to HA protection"
}
except HTTPException:
raise
except Exception as e:
logger.error(f"Failed to add HA resource: {e}")
raise HTTPException(status_code=500, detail=f"Failed to add HA resource: {str(e)}")
@router.delete("/resources/{sid}")
def remove_ha_resource(
sid: str,
current_user=Depends(require_admin),
db: Session = Depends(get_db)
):
"""Remove a VM from HA protection"""
try:
from app.models import ProxmoxHost
host = db.query(ProxmoxHost).first()
if not host:
raise HTTPException(status_code=400, detail="No Proxmox hosts configured")
ssh_host = f"root@{host.hostname}"
# URL encode the sid (e.g., vm:100 becomes vm%3A100)
import urllib.parse
encoded_sid = urllib.parse.quote(sid, safe='')
delete_cmd = f"ssh -o StrictHostKeyChecking=no -o BatchMode=yes {ssh_host} 'pvesh delete /cluster/ha/resources/{encoded_sid}'"
result = subprocess.run(delete_cmd, shell=True, capture_output=True, timeout=10)
if result.returncode != 0:
error_msg = result.stderr.decode() if result.stderr else "Unknown error"
raise HTTPException(status_code=500, detail=f"Failed to remove HA resource: {error_msg}")
logger.info(f"Removed HA resource {sid}")
return {
"success": True,
"message": f"Resource {sid} removed from HA protection"
}
except HTTPException:
raise
except Exception as e:
logger.error(f"Failed to remove HA resource: {e}")
raise HTTPException(status_code=500, detail=f"Failed to remove HA resource: {str(e)}")
+423
View File
@@ -0,0 +1,423 @@
"""ISO Images API routes"""
from fastapi import APIRouter, Depends, HTTPException, status, UploadFile, File, BackgroundTasks
from sqlalchemy.orm import Session
from pydantic import BaseModel, HttpUrl
from typing import List, Optional, Dict
from datetime import datetime
import os
import hashlib
import shutil
import requests
import tempfile
import logging
from app.core.database import get_db
from app.core.config import settings
from app.models import ISOImage, OSType, User
from app.api.auth import get_current_user, require_operator
router = APIRouter()
logger = logging.getLogger(__name__)
# In-memory progress tracking
upload_progress: Dict[str, Dict] = {}
# Pydantic models
class ISOImageResponse(BaseModel):
id: int
name: str
filename: str
os_type: OSType
version: Optional[str]
architecture: str
file_size: Optional[int]
checksum: Optional[str]
storage_path: str
created_at: datetime
is_available: bool
class Config:
from_attributes = True
class ISODownloadRequest(BaseModel):
url: str
name: str
os_type: OSType
version: Optional[str] = None
architecture: str = "amd64"
def process_iso_file(
temp_path: str,
storage_path: str,
upload_id: str,
iso_id: int,
db_session
):
"""Process ISO file in background: copy with progress and calculate checksum"""
try:
upload_progress[upload_id] = {
"status": "copying",
"progress": 0,
"message": "Copying file to storage..."
}
# Get file size
file_size = os.path.getsize(temp_path)
# Copy with progress tracking
bytes_copied = 0
chunk_size = 1024 * 1024 # 1MB chunks
with open(temp_path, 'rb') as src:
with open(storage_path, 'wb') as dst:
while True:
chunk = src.read(chunk_size)
if not chunk:
break
dst.write(chunk)
bytes_copied += len(chunk)
progress = int((bytes_copied / file_size) * 50) # First 50% for copying
upload_progress[upload_id]["progress"] = progress
# Remove temp file
os.remove(temp_path)
# Calculate checksum with progress
upload_progress[upload_id].update({
"status": "calculating_checksum",
"progress": 50,
"message": "Calculating checksum..."
})
sha256_hash = hashlib.sha256()
bytes_hashed = 0
with open(storage_path, "rb") as f:
while True:
chunk = f.read(chunk_size)
if not chunk:
break
sha256_hash.update(chunk)
bytes_hashed += len(chunk)
progress = 50 + int((bytes_hashed / file_size) * 50) # Last 50% for checksum
upload_progress[upload_id]["progress"] = progress
checksum = sha256_hash.hexdigest()
# Update database
from app.core.database import SessionLocal
db = SessionLocal()
try:
iso = db.query(ISOImage).filter(ISOImage.id == iso_id).first()
if iso:
iso.checksum = checksum
db.commit()
finally:
db.close()
upload_progress[upload_id] = {
"status": "completed",
"progress": 100,
"message": "Upload completed successfully",
"checksum": checksum
}
except Exception as e:
logger.error(f"Error processing ISO: {e}")
upload_progress[upload_id] = {
"status": "error",
"progress": 0,
"message": f"Error: {str(e)}"
}
# Clean up on error
if os.path.exists(storage_path):
os.remove(storage_path)
@router.get("/", response_model=List[ISOImageResponse])
async def list_isos(
skip: int = 0,
limit: int = 100,
os_type: Optional[OSType] = None,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
"""List all ISO images"""
query = db.query(ISOImage)
if os_type:
query = query.filter(ISOImage.os_type == os_type)
isos = query.filter(ISOImage.is_available == True).offset(skip).limit(limit).all()
return isos
@router.post("/", response_model=ISOImageResponse, status_code=status.HTTP_201_CREATED)
async def upload_iso(
background_tasks: BackgroundTasks,
file: UploadFile = File(...),
name: str = None,
os_type: OSType = OSType.UBUNTU,
version: str = None,
architecture: str = "amd64",
current_user: User = Depends(require_operator),
db: Session = Depends(get_db),
):
"""Upload an ISO image with progress tracking"""
# Validate file extension
if not file.filename.endswith('.iso'):
raise HTTPException(status_code=400, detail="Only ISO files are allowed")
# Create storage directory if it doesn't exist
os.makedirs(settings.ISO_STORAGE_PATH, exist_ok=True)
# Generate unique filename
filename = file.filename
storage_path = os.path.join(settings.ISO_STORAGE_PATH, filename)
# Check if file already exists
if os.path.exists(storage_path):
raise HTTPException(status_code=400, detail="ISO file already exists")
try:
# Save to temporary file first (fast - already in memory from upload)
with tempfile.NamedTemporaryFile(delete=False, suffix='.iso') as tmp_file:
shutil.copyfileobj(file.file, tmp_file)
temp_path = tmp_file.name
# Calculate file size
file_size = os.path.getsize(temp_path)
if file_size > settings.MAX_ISO_SIZE:
os.remove(temp_path)
raise HTTPException(
status_code=400,
detail=f"File too large. Maximum size is {settings.MAX_ISO_SIZE / (1024**3)}GB"
)
# Create database record with "processing" status
iso_name = name or filename.replace('.iso', '')
new_iso = ISOImage(
name=iso_name,
filename=filename,
os_type=os_type,
version=version,
architecture=architecture,
file_size=file_size,
checksum="processing...",
storage_path=storage_path,
uploaded_by=current_user.id,
is_available=True,
)
db.add(new_iso)
db.commit()
db.refresh(new_iso)
# Generate upload ID for progress tracking
upload_id = f"upload_{new_iso.id}"
upload_progress[upload_id] = {
"status": "queued",
"progress": 0,
"message": "Upload queued for processing..."
}
# Process file in background
background_tasks.add_task(
process_iso_file,
temp_path,
storage_path,
upload_id,
new_iso.id,
db
)
return new_iso
except Exception as e:
# Clean up temp file if database operation fails
if 'temp_path' in locals() and os.path.exists(temp_path):
os.remove(temp_path)
raise HTTPException(status_code=500, detail=f"Failed to upload ISO: {str(e)}")
@router.post("/download", response_model=ISOImageResponse, status_code=status.HTTP_201_CREATED)
async def download_iso_from_url(
download_request: ISODownloadRequest,
current_user: User = Depends(require_operator),
db: Session = Depends(get_db),
):
"""Download an ISO image from a URL"""
# Create storage directory if it doesn't exist
os.makedirs(settings.ISO_STORAGE_PATH, exist_ok=True)
# Extract filename from URL or use provided name
url_filename = download_request.url.split('/')[-1]
if not url_filename.endswith('.iso'):
url_filename = download_request.name.replace(' ', '_') + '.iso'
filename = url_filename
storage_path = os.path.join(settings.ISO_STORAGE_PATH, filename)
# Check if file already exists
if os.path.exists(storage_path):
raise HTTPException(status_code=400, detail="ISO file already exists")
try:
# Download file from URL
response = requests.get(download_request.url, stream=True, timeout=30)
response.raise_for_status()
# Get total file size if available
total_size = int(response.headers.get('content-length', 0))
if total_size > settings.MAX_ISO_SIZE:
raise HTTPException(
status_code=400,
detail=f"File too large. Maximum size is {settings.MAX_ISO_SIZE / (1024**3)}GB"
)
# Download to temporary file first
with tempfile.NamedTemporaryFile(delete=False, suffix='.iso') as tmp_file:
for chunk in response.iter_content(chunk_size=8192):
if chunk:
tmp_file.write(chunk)
tmp_path = tmp_file.name
# Move to final location
shutil.move(tmp_path, storage_path)
# Calculate file size
file_size = os.path.getsize(storage_path)
# Set checksum to calculating for now
checksum = "calculating..."
# Create database record
new_iso = ISOImage(
name=download_request.name,
filename=filename,
os_type=download_request.os_type,
version=download_request.version,
architecture=download_request.architecture,
file_size=file_size,
checksum=checksum,
storage_path=storage_path,
uploaded_by=current_user.id,
is_available=True,
)
db.add(new_iso)
db.commit()
db.refresh(new_iso)
return new_iso
except requests.exceptions.RequestException as e:
# Clean up file if download fails
if os.path.exists(storage_path):
os.remove(storage_path)
raise HTTPException(status_code=400, detail=f"Failed to download ISO: {str(e)}")
except Exception as e:
# Clean up file if database operation fails
if os.path.exists(storage_path):
os.remove(storage_path)
raise HTTPException(status_code=500, detail=f"Failed to download ISO: {str(e)}")
@router.get("/{iso_id}", response_model=ISOImageResponse)
async def get_iso(
iso_id: int,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
"""Get ISO image by ID"""
iso = db.query(ISOImage).filter(ISOImage.id == iso_id).first()
if not iso:
raise HTTPException(status_code=404, detail="ISO not found")
return iso
@router.delete("/{iso_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_iso(
iso_id: int,
current_user: User = Depends(require_operator),
db: Session = Depends(get_db),
):
"""Delete ISO image"""
iso = db.query(ISOImage).filter(ISOImage.id == iso_id).first()
if not iso:
raise HTTPException(status_code=404, detail="ISO not found")
# Delete file from storage
if os.path.exists(iso.storage_path):
try:
os.remove(iso.storage_path)
except Exception as e:
raise HTTPException(status_code=500, detail=f"Failed to delete file: {str(e)}")
# Delete from database
db.delete(iso)
db.commit()
return None
@router.get("/{iso_id}/progress")
async def get_upload_progress(
iso_id: int,
current_user: User = Depends(get_current_user),
):
"""Get upload progress for an ISO"""
upload_id = f"upload_{iso_id}"
if upload_id not in upload_progress:
return {
"status": "not_found",
"progress": 100,
"message": "Upload completed or not tracked"
}
return upload_progress[upload_id]
@router.post("/{iso_id}/verify")
async def verify_iso(
iso_id: int,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
"""Verify ISO checksum"""
iso = db.query(ISOImage).filter(ISOImage.id == iso_id).first()
if not iso:
raise HTTPException(status_code=404, detail="ISO not found")
if not os.path.exists(iso.storage_path):
iso.is_available = False
db.commit()
raise HTTPException(status_code=404, detail="ISO file not found on disk")
# Calculate current checksum
sha256_hash = hashlib.sha256()
with open(iso.storage_path, "rb") as f:
for byte_block in iter(lambda: f.read(4096), b""):
sha256_hash.update(byte_block)
current_checksum = sha256_hash.hexdigest()
# Compare with stored checksum
if current_checksum == iso.checksum:
return {
"status": "valid",
"message": "ISO checksum matches",
"checksum": current_checksum,
}
else:
return {
"status": "invalid",
"message": "ISO checksum does not match",
"expected": iso.checksum,
"actual": current_checksum,
}
+73
View File
@@ -0,0 +1,73 @@
"""System logs API routes"""
from fastapi import APIRouter, Depends, HTTPException
from typing import Optional
import subprocess
import logging
from app.api.auth import get_current_user, require_admin
from app.models import User
router = APIRouter()
logger = logging.getLogger(__name__)
@router.get("/backend")
async def get_backend_logs(
lines: Optional[int] = 100,
current_user: User = Depends(require_admin),
):
"""Get backend service logs (admin only)"""
try:
# Get logs from systemd journal (requires sudo)
result = subprocess.run(
["sudo", "journalctl", "-u", "depl0y-backend", "-n", str(lines), "--no-pager"],
capture_output=True,
text=True,
timeout=10
)
if result.returncode != 0:
raise Exception(f"Failed to retrieve logs: {result.stderr}")
return {
"logs": result.stdout,
"lines": lines
}
except subprocess.TimeoutExpired:
raise HTTPException(status_code=500, detail="Timeout while retrieving logs")
except Exception as e:
logger.error(f"Failed to get backend logs: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.get("/backend/tail")
async def tail_backend_logs(
lines: Optional[int] = 50,
current_user: User = Depends(require_admin),
):
"""Get most recent backend logs (admin only)"""
try:
# Get most recent logs (requires sudo)
result = subprocess.run(
["sudo", "journalctl", "-u", "depl0y-backend", "-n", str(lines), "--no-pager", "-o", "cat"],
capture_output=True,
text=True,
timeout=10
)
if result.returncode != 0:
raise Exception(f"Failed to retrieve logs: {result.stderr}")
# Return as array of lines for easier frontend processing
log_lines = result.stdout.strip().split('\n') if result.stdout else []
return {
"logs": log_lines,
"count": len(log_lines)
}
except subprocess.TimeoutExpired:
raise HTTPException(status_code=500, detail="Timeout while retrieving logs")
except Exception as e:
logger.error(f"Failed to tail backend logs: {e}")
raise HTTPException(status_code=500, detail=str(e))
+384
View File
@@ -0,0 +1,384 @@
"""Proxmox hosts API routes"""
from fastapi import APIRouter, Depends, HTTPException, status, BackgroundTasks
from sqlalchemy.orm import Session
from pydantic import BaseModel
from typing import List, Optional
from datetime import datetime
from app.core.database import get_db
from app.core.security import encrypt_data, decrypt_data
from app.models import ProxmoxHost, ProxmoxNode
from app.api.auth import get_current_user, require_admin
from app.models import User
from app.services.proxmox import ProxmoxService, poll_proxmox_resources
router = APIRouter()
# Pydantic models
class ProxmoxHostCreate(BaseModel):
name: str
hostname: str
port: int = 8006
username: str
password: Optional[str] = None
api_token_id: Optional[str] = None # e.g., "mytoken" or "root@pam!mytoken"
api_token_secret: Optional[str] = None
verify_ssl: bool = False
class ProxmoxHostUpdate(BaseModel):
name: Optional[str] = None
hostname: Optional[str] = None
port: Optional[int] = None
username: Optional[str] = None
password: Optional[str] = None
api_token_id: Optional[str] = None
api_token_secret: Optional[str] = None
verify_ssl: Optional[bool] = None
is_active: Optional[bool] = None
class ProxmoxHostResponse(BaseModel):
id: int
name: str
hostname: str
port: int
username: str
verify_ssl: bool
is_active: bool
last_poll: Optional[datetime]
created_at: datetime
updated_at: datetime
class Config:
from_attributes = True
class ProxmoxNodeResponse(BaseModel):
id: int
host_id: int
node_name: str
status: Optional[str]
cpu_cores: Optional[int]
cpu_usage: Optional[int]
memory_total: Optional[int]
memory_used: Optional[int]
disk_total: Optional[int]
disk_used: Optional[int]
uptime: Optional[int]
last_updated: datetime
class Config:
from_attributes = True
@router.get("/", response_model=List[ProxmoxHostResponse])
async def list_proxmox_hosts(
skip: int = 0,
limit: int = 100,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
"""List all Proxmox hosts"""
hosts = db.query(ProxmoxHost).offset(skip).limit(limit).all()
return hosts
@router.post("/", response_model=ProxmoxHostResponse, status_code=status.HTTP_201_CREATED)
async def create_proxmox_host(
host_data: ProxmoxHostCreate,
current_user: User = Depends(require_admin),
db: Session = Depends(get_db),
):
"""Create a new Proxmox host (admin only)"""
import logging
logger = logging.getLogger(__name__)
logger.info(f"Received Proxmox host create request: name={host_data.name}, hostname={host_data.hostname}, username={host_data.username}, has_password={bool(host_data.password)}, has_token_id={bool(host_data.api_token_id)}, has_token_secret={bool(host_data.api_token_secret)}")
# Check if name already exists
existing_host = db.query(ProxmoxHost).filter(ProxmoxHost.name == host_data.name).first()
if existing_host:
raise HTTPException(status_code=400, detail="Host name already exists")
# Validate that either password or API token is provided
if not host_data.password and not (host_data.api_token_id and host_data.api_token_secret):
raise HTTPException(
status_code=400,
detail="Either password or API token (both token ID and secret) must be provided"
)
# Encrypt password if provided
encrypted_password = encrypt_data(host_data.password) if host_data.password else None
# Encrypt API token secret if provided
encrypted_token_secret = encrypt_data(host_data.api_token_secret) if host_data.api_token_secret else None
# Create new host
new_host = ProxmoxHost(
name=host_data.name,
hostname=host_data.hostname,
port=host_data.port,
username=host_data.username,
password=encrypted_password,
api_token_id=host_data.api_token_id,
api_token_secret=encrypted_token_secret,
verify_ssl=host_data.verify_ssl,
)
db.add(new_host)
db.commit()
db.refresh(new_host)
# Test connection
try:
service = ProxmoxService(new_host)
if not service.test_connection():
raise HTTPException(
status_code=400, detail="Cannot connect to Proxmox host. Check credentials and connectivity."
)
except Exception as e:
db.delete(new_host)
db.commit()
raise HTTPException(status_code=400, detail=f"Failed to connect: {str(e)}")
return new_host
@router.get("/{host_id}", response_model=ProxmoxHostResponse)
async def get_proxmox_host(
host_id: int,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
"""Get Proxmox host by ID"""
host = db.query(ProxmoxHost).filter(ProxmoxHost.id == host_id).first()
if not host:
raise HTTPException(status_code=404, detail="Host not found")
return host
@router.put("/{host_id}", response_model=ProxmoxHostResponse)
async def update_proxmox_host(
host_id: int,
host_data: ProxmoxHostUpdate,
current_user: User = Depends(require_admin),
db: Session = Depends(get_db),
):
"""Update Proxmox host (admin only)"""
host = db.query(ProxmoxHost).filter(ProxmoxHost.id == host_id).first()
if not host:
raise HTTPException(status_code=404, detail="Host not found")
# Update fields
if host_data.name is not None:
# Check if name already exists
existing_host = (
db.query(ProxmoxHost)
.filter(ProxmoxHost.name == host_data.name, ProxmoxHost.id != host_id)
.first()
)
if existing_host:
raise HTTPException(status_code=400, detail="Host name already exists")
host.name = host_data.name
if host_data.hostname is not None:
host.hostname = host_data.hostname
if host_data.port is not None:
host.port = host_data.port
if host_data.username is not None:
host.username = host_data.username
if host_data.password is not None:
host.password = encrypt_data(host_data.password)
if host_data.api_token_id is not None:
host.api_token_id = host_data.api_token_id
if host_data.api_token_secret is not None:
host.api_token_secret = encrypt_data(host_data.api_token_secret)
if host_data.verify_ssl is not None:
host.verify_ssl = host_data.verify_ssl
if host_data.is_active is not None:
host.is_active = host_data.is_active
host.updated_at = datetime.utcnow()
db.commit()
db.refresh(host)
return host
@router.delete("/{host_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_proxmox_host(
host_id: int,
current_user: User = Depends(require_admin),
db: Session = Depends(get_db),
):
"""Delete Proxmox host (admin only)"""
host = db.query(ProxmoxHost).filter(ProxmoxHost.id == host_id).first()
if not host:
raise HTTPException(status_code=404, detail="Host not found")
db.delete(host)
db.commit()
return None
@router.post("/{host_id}/test")
async def test_proxmox_connection(
host_id: int,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
"""Test connection to Proxmox host"""
host = db.query(ProxmoxHost).filter(ProxmoxHost.id == host_id).first()
if not host:
raise HTTPException(status_code=404, detail="Host not found")
try:
service = ProxmoxService(host)
success = service.test_connection()
if success:
return {"status": "success", "message": "Connection successful"}
else:
return {"status": "error", "message": "Connection failed"}
except Exception as e:
return {"status": "error", "message": str(e)}
@router.post("/{host_id}/poll")
async def poll_proxmox_host(
host_id: int,
background_tasks: BackgroundTasks,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
"""Poll Proxmox host for resources"""
host = db.query(ProxmoxHost).filter(ProxmoxHost.id == host_id).first()
if not host:
raise HTTPException(status_code=404, detail="Host not found")
# Run polling in background
background_tasks.add_task(poll_proxmox_resources, db, host_id)
return {"status": "success", "message": "Polling started"}
@router.get("/{host_id}/nodes", response_model=List[ProxmoxNodeResponse])
async def list_proxmox_nodes(
host_id: int,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
"""List nodes for a Proxmox host"""
host = db.query(ProxmoxHost).filter(ProxmoxHost.id == host_id).first()
if not host:
raise HTTPException(status_code=404, detail="Host not found")
nodes = db.query(ProxmoxNode).filter(ProxmoxNode.host_id == host_id).all()
return nodes
@router.get("/{host_id}/stats")
async def get_datacenter_stats(
host_id: int,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
"""Get datacenter statistics including VM counts"""
from app.models import VirtualMachine, VMStatus
from sqlalchemy import func
host = db.query(ProxmoxHost).filter(ProxmoxHost.id == host_id).first()
if not host:
raise HTTPException(status_code=404, detail="Host not found")
# Get VM counts by status
vm_counts = db.query(
VirtualMachine.status,
func.count(VirtualMachine.id).label('count')
).filter(
VirtualMachine.proxmox_host_id == host_id
).group_by(VirtualMachine.status).all()
# Convert to dict
status_counts = {status: count for status, count in vm_counts}
# Get total VMs
total_vms = sum(status_counts.values())
return {
"total_vms": total_vms,
"running": status_counts.get(VMStatus.RUNNING, 0),
"stopped": status_counts.get(VMStatus.STOPPED, 0),
"creating": status_counts.get(VMStatus.CREATING, 0),
"error": status_counts.get(VMStatus.ERROR, 0),
}
@router.get("/nodes/{node_id}", response_model=ProxmoxNodeResponse)
async def get_proxmox_node(
node_id: int,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
"""Get Proxmox node by ID"""
node = db.query(ProxmoxNode).filter(ProxmoxNode.id == node_id).first()
if not node:
raise HTTPException(status_code=404, detail="Node not found")
return node
@router.get("/nodes/{node_id}/storage")
async def get_node_storage(
node_id: int,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
"""Get storage pools available on a node"""
node = db.query(ProxmoxNode).filter(ProxmoxNode.id == node_id).first()
if not node:
raise HTTPException(status_code=404, detail="Node not found")
host = db.query(ProxmoxHost).filter(ProxmoxHost.id == node.host_id).first()
if not host:
raise HTTPException(status_code=404, detail="Host not found")
try:
service = ProxmoxService(host)
storage_list = service.get_storage_list(node.node_name)
return {"storage": storage_list}
except Exception as e:
logger.error(f"Failed to get storage for node {node.node_name}: {e}")
raise HTTPException(status_code=500, detail=f"Failed to get storage: {str(e)}")
@router.get("/nodes/{node_id}/network")
async def get_node_network(
node_id: int,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
"""Get network interfaces/bridges available on a node"""
node = db.query(ProxmoxNode).filter(ProxmoxNode.id == node_id).first()
if not node:
raise HTTPException(status_code=404, detail="Node not found")
host = db.query(ProxmoxHost).filter(ProxmoxHost.id == node.host_id).first()
if not host:
raise HTTPException(status_code=404, detail="Host not found")
try:
service = ProxmoxService(host)
network_list = service.get_network_interfaces(node.node_name)
return {"network": network_list}
except Exception as e:
logger.error(f"Failed to get network for node {node.node_name}: {e}")
raise HTTPException(status_code=500, detail=f"Failed to get network: {str(e)}")
+319
View File
@@ -0,0 +1,319 @@
"""Setup API endpoints for automated configuration"""
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from app.api.auth import get_current_user
from app.models import ProxmoxHost, ProxmoxNode
from app.core.database import get_db
from sqlalchemy.orm import Session
import logging
import subprocess
import os
logger = logging.getLogger(__name__)
router = APIRouter()
class CloudImageSetupRequest(BaseModel):
proxmox_password: str
class ProxmoxClusterSSHRequest(BaseModel):
proxmox_password: str
@router.post("/cloud-images/enable")
def enable_cloud_images(
request: CloudImageSetupRequest,
db: Session = Depends(get_db),
current_user=Depends(get_current_user)
):
"""
Automatically enable cloud images by running the setup script
This sets up SSH access to Proxmox for cloud image template creation
"""
try:
# Get Proxmox host
host = db.query(ProxmoxHost).first()
if not host:
raise HTTPException(
status_code=404,
detail="No Proxmox host configured. Please add a Proxmox host first."
)
proxmox_host = host.hostname
password = request.proxmox_password
if not password:
raise HTTPException(
status_code=400,
detail="Proxmox root password is required"
)
logger.info(f"Starting cloud image setup for host {proxmox_host}")
# Check if SSH is already configured
check_ssh = subprocess.run(
[
'sudo', '-u', 'depl0y',
'ssh', '-o', 'BatchMode=yes', '-o', 'ConnectTimeout=5',
'-o', 'StrictHostKeyChecking=no',
f'root@{proxmox_host}',
'echo test'
],
capture_output=True,
text=True,
timeout=10
)
if check_ssh.returncode == 0:
logger.info("SSH already configured")
return {
"success": True,
"already_configured": True,
"message": "SSH access is already configured! Cloud images are ready to use."
}
# Install sshpass if not present
logger.info("Checking for sshpass")
check_sshpass = subprocess.run(
['which', 'sshpass'],
capture_output=True
)
if check_sshpass.returncode != 0:
logger.info("Installing sshpass")
install_result = subprocess.run(
[
'sudo', 'DEBIAN_FRONTEND=noninteractive',
'apt-get', 'install', '-y', '-qq', 'sshpass'
],
capture_output=True,
text=True,
timeout=60
)
if install_result.returncode != 0:
raise Exception(f"Failed to install sshpass: {install_result.stderr}")
# Generate SSH key if it doesn't exist
ssh_key_path = '/opt/depl0y/.ssh/id_rsa'
if not os.path.exists(ssh_key_path):
logger.info("Generating SSH key")
subprocess.run(
[
'sudo', '-u', 'depl0y',
'mkdir', '-p', '/opt/depl0y/.ssh'
],
check=True
)
subprocess.run(
[
'sudo', '-u', 'depl0y',
'ssh-keygen', '-t', 'rsa', '-b', '4096',
'-f', ssh_key_path,
'-N', '', '-q'
],
check=True
)
# Copy SSH key to Proxmox using sshpass
logger.info(f"Copying SSH key to {proxmox_host}")
copy_result = subprocess.run(
[
'sudo', '-u', 'depl0y',
'sshpass', '-p', password,
'ssh-copy-id',
'-o', 'StrictHostKeyChecking=no',
'-i', f'{ssh_key_path}.pub',
f'root@{proxmox_host}'
],
capture_output=True,
text=True,
timeout=30
)
if copy_result.returncode != 0:
# Try alternative method
logger.info("Trying alternative SSH key copy method")
with open(f'{ssh_key_path}.pub', 'r') as f:
public_key = f.read().strip()
alt_result = subprocess.run(
[
'sudo', '-u', 'depl0y',
'sshpass', '-p', password,
'ssh', '-o', 'StrictHostKeyChecking=no',
f'root@{proxmox_host}',
f"mkdir -p ~/.ssh && chmod 700 ~/.ssh && echo '{public_key}' >> ~/.ssh/authorized_keys && chmod 600 ~/.ssh/authorized_keys && sort -u ~/.ssh/authorized_keys -o ~/.ssh/authorized_keys"
],
capture_output=True,
text=True,
timeout=30
)
if alt_result.returncode != 0:
raise Exception(f"Failed to copy SSH key: {alt_result.stderr}")
# Verify SSH access works
logger.info("Verifying SSH access")
verify_result = subprocess.run(
[
'sudo', '-u', 'depl0y',
'ssh', '-o', 'BatchMode=yes', '-o', 'ConnectTimeout=5',
'-o', 'StrictHostKeyChecking=no',
f'root@{proxmox_host}',
'echo SSH_CONFIGURED'
],
capture_output=True,
text=True,
timeout=10
)
if verify_result.returncode != 0 or 'SSH_CONFIGURED' not in verify_result.stdout:
raise Exception(f"SSH verification failed: {verify_result.stderr}")
logger.info("Cloud image setup completed successfully")
return {
"success": True,
"already_configured": False,
"message": "SSH access configured successfully! Cloud images are now enabled.",
"details": {
"host": proxmox_host,
"ssh_key": f"{ssh_key_path}.pub"
}
}
except subprocess.TimeoutExpired:
logger.error("Setup timed out")
raise HTTPException(
status_code=408,
detail="Setup operation timed out. Please check network connectivity to Proxmox."
)
except HTTPException:
raise
except Exception as e:
logger.error(f"Cloud image setup failed: {e}")
raise HTTPException(
status_code=500,
detail=f"Failed to enable cloud images: {str(e)}"
)
@router.post("/proxmox-cluster-ssh/enable")
def enable_proxmox_cluster_ssh(
request: ProxmoxClusterSSHRequest,
db: Session = Depends(get_db),
current_user=Depends(get_current_user)
):
"""
Set up SSH access between Proxmox cluster nodes
This allows the deployment system to target specific nodes for template creation
"""
try:
# Get Proxmox host
host = db.query(ProxmoxHost).first()
if not host:
raise HTTPException(
status_code=404,
detail="No Proxmox host configured. Please add a Proxmox host first."
)
# Get all nodes
nodes = db.query(ProxmoxNode).filter(ProxmoxNode.host_id == host.id).all()
if len(nodes) < 2:
return {
"success": True,
"already_configured": True,
"message": "Only one node detected - inter-node SSH not needed for single node setups."
}
proxmox_host = host.hostname
password = request.proxmox_password
if not password:
raise HTTPException(
status_code=400,
detail="Proxmox root password is required"
)
logger.info(f"Starting inter-node SSH setup for Proxmox cluster")
# Check if inter-node SSH is already working
node_names = [n.node_name for n in nodes]
test_node_1 = node_names[0]
test_node_2 = node_names[1] if len(node_names) > 1 else node_names[0]
check_cmd = f"sudo -u depl0y ssh -o BatchMode=yes -o ConnectTimeout=5 -o StrictHostKeyChecking=no root@{proxmox_host} 'ssh -o BatchMode=yes -o ConnectTimeout=5 -o StrictHostKeyChecking=no {test_node_2} echo test' 2>&1"
check_result = subprocess.run(check_cmd, shell=True, capture_output=True, text=True, timeout=15)
if check_result.returncode == 0 and 'test' in check_result.stdout:
logger.info("Inter-node SSH already configured")
return {
"success": True,
"already_configured": True,
"message": f"Inter-node SSH is already configured! Nodes can communicate with each other."
}
# Set up SSH keys on Proxmox cluster nodes
logger.info("Setting up SSH keys between nodes...")
setup_script = f"""
# Generate SSH key on first node if it doesn't exist
if [ ! -f ~/.ssh/id_rsa ]; then
ssh-keygen -t rsa -b 4096 -f ~/.ssh/id_rsa -N '' -q
fi
# Get the public key
PUB_KEY=$(cat ~/.ssh/id_rsa.pub)
# Copy key to all other nodes
{' '.join([f'sshpass -p "{password}" ssh-copy-id -o StrictHostKeyChecking=no -i ~/.ssh/id_rsa.pub root@{node} 2>/dev/null || sshpass -p "{password}" ssh -o StrictHostKeyChecking=no root@{node} "mkdir -p ~/.ssh && chmod 700 ~/.ssh && echo \\"$PUB_KEY\\" >> ~/.ssh/authorized_keys && chmod 600 ~/.ssh/authorized_keys && sort -u ~/.ssh/authorized_keys -o ~/.ssh/authorized_keys"' for node in node_names])}
# Also set up reverse keys (each node can SSH to others)
{' '.join([f'ssh -o StrictHostKeyChecking=no root@{node} "ssh-keygen -t rsa -b 4096 -f ~/.ssh/id_rsa -N \\"\\" -q 2>/dev/null || true; ssh-copy-id -o StrictHostKeyChecking=no -i ~/.ssh/id_rsa.pub root@{proxmox_host} 2>/dev/null || true"' for node in node_names])}
echo "SSH_SETUP_COMPLETE"
"""
# Execute setup via SSH to Proxmox
cmd = f"sudo -u depl0y sshpass -p '{password}' ssh -o StrictHostKeyChecking=no root@{proxmox_host} '{setup_script}'"
result = subprocess.run(cmd, shell=True, capture_output=True, text=True, timeout=60)
if result.returncode != 0:
raise Exception(f"Failed to setup inter-node SSH: {result.stderr}")
# Verify connectivity
logger.info("Verifying inter-node SSH connectivity...")
verify_cmd = f"sudo -u depl0y ssh -o BatchMode=yes -o ConnectTimeout=5 -o StrictHostKeyChecking=no root@{proxmox_host} 'ssh -o BatchMode=yes -o ConnectTimeout=5 -o StrictHostKeyChecking=no {test_node_2} echo VERIFIED'"
verify_result = subprocess.run(verify_cmd, shell=True, capture_output=True, text=True, timeout=15)
if verify_result.returncode != 0 or 'VERIFIED' not in verify_result.stdout:
raise Exception(f"Inter-node SSH verification failed")
logger.info("Inter-node SSH setup completed successfully")
return {
"success": True,
"already_configured": False,
"message": f"Inter-node SSH configured successfully! All {len(nodes)} nodes can now communicate.",
"details": {
"nodes": node_names,
"tested": f"{test_node_1}{test_node_2}"
}
}
except subprocess.TimeoutExpired:
logger.error("Setup timed out")
raise HTTPException(
status_code=408,
detail="Setup operation timed out. Please check network connectivity."
)
except HTTPException:
raise
except Exception as e:
logger.error(f"Inter-node SSH setup failed: {e}")
raise HTTPException(
status_code=500,
detail=f"Failed to enable inter-node SSH: {str(e)}"
)
+69
View File
@@ -0,0 +1,69 @@
"""System information API endpoints"""
from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session
from app.core.database import get_db
from app.models.database import SystemSettings
from typing import Dict
import logging
logger = logging.getLogger(__name__)
router = APIRouter()
@router.get("/info")
def get_system_info(db: Session = Depends(get_db)) -> Dict[str, str]:
"""Get system information including version"""
try:
# Get version from database
version_setting = db.query(SystemSettings).filter(SystemSettings.key == "app_version").first()
app_name_setting = db.query(SystemSettings).filter(SystemSettings.key == "app_name").first()
version = version_setting.value if version_setting else "1.1.3"
app_name = app_name_setting.value if app_name_setting else "Depl0y"
return {
"version": version,
"app_name": app_name,
"status": "running"
}
except Exception as e:
logger.error(f"Failed to get system info: {e}")
# Fallback to hardcoded version if database query fails
return {
"version": "1.1.3",
"app_name": "Depl0y",
"status": "running"
}
@router.put("/version")
def update_version(new_version: str, db: Session = Depends(get_db)) -> Dict[str, str]:
"""Update system version (admin only)"""
try:
version_setting = db.query(SystemSettings).filter(SystemSettings.key == "app_version").first()
if version_setting:
version_setting.value = new_version
else:
version_setting = SystemSettings(
key="app_version",
value=new_version,
description="Current application version"
)
db.add(version_setting)
db.commit()
return {
"success": True,
"version": new_version,
"message": "Version updated successfully"
}
except Exception as e:
logger.error(f"Failed to update version: {e}")
db.rollback()
return {
"success": False,
"message": str(e)
}
+194
View File
@@ -0,0 +1,194 @@
"""System update API endpoints"""
from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import FileResponse
from pydantic import BaseModel
from typing import Optional
from app.api.auth import get_current_user
from app.core.config import settings
import logging
import subprocess
import os
import requests
logger = logging.getLogger(__name__)
router = APIRouter()
# Update server configuration
UPDATE_SERVER = "http://deploy.agit8or.net"
UPDATE_ENDPOINT = f"{UPDATE_SERVER}/api/v1/system-updates/version"
class UpdateCheckResponse(BaseModel):
current_version: str
latest_version: str
update_available: bool
download_url: Optional[str] = None
release_notes: Optional[str] = None
@router.get("/check")
def check_for_updates(current_user=Depends(get_current_user)):
"""Check if updates are available from the main server"""
try:
# Get current version
current_version = settings.APP_VERSION
# Query update server for latest version
try:
response = requests.get(UPDATE_ENDPOINT, timeout=10)
if response.status_code == 200:
update_info = response.json()
latest_version = update_info.get("version", current_version)
# Simple version comparison (assumes semantic versioning)
update_available = latest_version != current_version
return UpdateCheckResponse(
current_version=current_version,
latest_version=latest_version,
update_available=update_available,
download_url=update_info.get("download_url") if update_available else None,
release_notes=update_info.get("release_notes")
)
else:
raise Exception(f"Update server returned {response.status_code}")
except requests.RequestException as e:
logger.warning(f"Could not reach update server: {e}")
return UpdateCheckResponse(
current_version=current_version,
latest_version=current_version,
update_available=False,
release_notes="Could not reach update server"
)
except Exception as e:
logger.error(f"Failed to check for updates: {e}")
raise HTTPException(
status_code=500,
detail=f"Failed to check for updates: {str(e)}"
)
@router.get("/version")
def get_version_info():
"""
Serve version information for update clients
This endpoint is called BY OTHER INSTANCES to check for updates
"""
return {
"version": settings.APP_VERSION,
"download_url": f"{UPDATE_SERVER}/api/v1/system-updates/download",
"install_url": f"{UPDATE_SERVER}/install.sh",
"release_notes": f"""
Depl0y {settings.APP_VERSION} Release Notes:
✨ New in v1.1.9:
- Automatic backend restart after upgrades
- Updates now apply immediately without manual intervention
- Backend automatically loads new code after update completes
✨ New in v1.1.8:
- Fixed JavaScript error preventing Settings page from loading
- Fixed installer version mismatch
- Updates work correctly in one step
✨ Previous versions:
- One-click automatic updates from Settings (v1.1.5)
- Fast 30-second upgrades with pre-built frontend (v1.1.5)
- System Updates section with loading/error states (v1.1.7)
- Automated cloud image deployment
- Inter-node SSH setup for clusters
""".strip()
}
@router.get("/download")
def download_update():
"""Download the latest update package (public endpoint for automated updates)"""
try:
# Use pre-packaged file
package_path = "/opt/depl0y/depl0y-v1.1.5.tar.gz"
if not os.path.exists(package_path):
# Fallback: create package on-the-fly
temp_package = "/tmp/depl0y-update.tar.gz"
create_package_cmd = f"""
cd /home/administrator/depl0y && \
tar -czf {temp_package} \
--exclude='node_modules' \
--exclude='dist' \
--exclude='.git' \
--exclude='__pycache__' \
--exclude='*.pyc' \
--exclude='venv' \
backend/ frontend/ *.md *.sh 2>/dev/null || true
"""
subprocess.run(create_package_cmd, shell=True, check=True)
package_path = temp_package
if not os.path.exists(package_path):
raise HTTPException(status_code=500, detail="Failed to find or create update package")
return FileResponse(
package_path,
media_type="application/gzip",
filename="depl0y-latest.tar.gz",
headers={
"Cache-Control": "no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0",
"Pragma": "no-cache",
"Expires": "0"
}
)
except Exception as e:
logger.error(f"Failed to serve update package: {e}")
raise HTTPException(
status_code=500,
detail=f"Failed to serve update package: {str(e)}"
)
@router.post("/apply")
def apply_update(current_user=Depends(get_current_user)):
"""Apply update by downloading and running the installer script"""
try:
logger.info("Starting update process via installer...")
# Download installer script
installer_url = f"{UPDATE_SERVER}/downloads/install.sh"
installer_path = "/tmp/depl0y-update-install.sh"
logger.info(f"Downloading installer from {installer_url}")
response = requests.get(installer_url, timeout=30)
if response.status_code != 200:
raise Exception(f"Failed to download installer: HTTP {response.status_code}")
# Save installer
with open(installer_path, 'wb') as f:
f.write(response.content)
# Make executable
os.chmod(installer_path, 0o755)
logger.info("Installer downloaded, starting update in background...")
# Run installer in background (it will detect existing installation and upgrade)
update_script = f"/usr/bin/sudo /opt/depl0y/scripts/update-wrapper.sh {installer_path}"
subprocess.Popen(update_script, shell=True)
return {
"success": True,
"message": "Update is being applied. The installer will upgrade your installation while preserving your data. The service will restart automatically.",
"log_file": "/tmp/depl0y-update.log"
}
except Exception as e:
logger.error(f"Failed to start update: {e}")
raise HTTPException(
status_code=500,
detail=f"Failed to start update: {str(e)}"
)
+112
View File
@@ -0,0 +1,112 @@
"""Updates API routes"""
from fastapi import APIRouter, Depends, HTTPException, status, BackgroundTasks
from sqlalchemy.orm import Session
from pydantic import BaseModel
from typing import List, Optional
from datetime import datetime
from app.core.database import get_db
from app.models import UpdateLog, User
from app.api.auth import get_current_user, require_operator
from app.services.updates import UpdateService
router = APIRouter()
# Pydantic models
class UpdateLogResponse(BaseModel):
id: int
vm_id: int
initiated_by: int
status: str
packages_updated: int
output: Optional[str]
error_message: Optional[str]
started_at: datetime
completed_at: Optional[datetime]
class Config:
from_attributes = True
@router.post("/vm/{vm_id}/check")
async def check_vm_updates(
vm_id: int,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
"""Check for available updates on a VM"""
update_service = UpdateService(db)
result = update_service.check_updates(vm_id)
if not result:
raise HTTPException(status_code=500, detail="Failed to check updates")
return result
@router.post("/vm/{vm_id}/install")
async def install_vm_updates(
vm_id: int,
background_tasks: BackgroundTasks,
current_user: User = Depends(require_operator),
db: Session = Depends(get_db),
):
"""Install updates on a VM"""
update_service = UpdateService(db)
# Run update in background
background_tasks.add_task(update_service.install_updates, vm_id, current_user.id)
return {"status": "started", "message": "Update process started"}
@router.get("/vm/{vm_id}/history", response_model=List[UpdateLogResponse])
async def get_vm_update_history(
vm_id: int,
skip: int = 0,
limit: int = 100,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
"""Get update history for a VM"""
logs = (
db.query(UpdateLog)
.filter(UpdateLog.vm_id == vm_id)
.order_by(UpdateLog.started_at.desc())
.offset(skip)
.limit(limit)
.all()
)
return logs
@router.get("/log/{log_id}", response_model=UpdateLogResponse)
async def get_update_log(
log_id: int,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
"""Get update log by ID"""
log = db.query(UpdateLog).filter(UpdateLog.id == log_id).first()
if not log:
raise HTTPException(status_code=404, detail="Update log not found")
return log
@router.post("/vm/{vm_id}/install-qemu-agent")
async def install_vm_qemu_agent(
vm_id: int,
current_user: User = Depends(require_operator),
db: Session = Depends(get_db),
):
"""Install QEMU guest agent on a VM"""
update_service = UpdateService(db)
success = update_service.install_qemu_agent(vm_id)
if success:
return {"status": "success", "message": "QEMU guest agent installed"}
else:
raise HTTPException(status_code=500, detail="Failed to install QEMU guest agent")
+187
View File
@@ -0,0 +1,187 @@
"""Users API routes"""
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from pydantic import BaseModel, EmailStr
from typing import List, Optional
from datetime import datetime
from app.core.database import get_db
from app.core.security import get_password_hash, verify_password
from app.models import User, UserRole
from app.api.auth import get_current_user, require_admin
router = APIRouter()
# Pydantic models
class UserCreate(BaseModel):
username: str
email: EmailStr
password: str
role: UserRole = UserRole.VIEWER
class UserUpdate(BaseModel):
email: Optional[EmailStr] = None
role: Optional[UserRole] = None
is_active: Optional[bool] = None
class UserPasswordChange(BaseModel):
current_password: str
new_password: str
class UserResponse(BaseModel):
id: int
username: str
email: str
role: UserRole
is_active: bool
totp_enabled: bool
created_at: datetime
last_login: Optional[datetime]
class Config:
from_attributes = True
@router.get("/", response_model=List[UserResponse])
async def list_users(
skip: int = 0,
limit: int = 100,
current_user: User = Depends(require_admin),
db: Session = Depends(get_db),
):
"""List all users (admin only)"""
users = db.query(User).offset(skip).limit(limit).all()
return users
@router.post("/", response_model=UserResponse, status_code=status.HTTP_201_CREATED)
async def create_user(
user_data: UserCreate,
current_user: User = Depends(require_admin),
db: Session = Depends(get_db),
):
"""Create a new user (admin only)"""
# Check if username already exists
existing_user = db.query(User).filter(User.username == user_data.username).first()
if existing_user:
raise HTTPException(status_code=400, detail="Username already exists")
# Check if email already exists
existing_email = db.query(User).filter(User.email == user_data.email).first()
if existing_email:
raise HTTPException(status_code=400, detail="Email already exists")
# Create new user
hashed_password = get_password_hash(user_data.password)
new_user = User(
username=user_data.username,
email=user_data.email,
hashed_password=hashed_password,
role=user_data.role,
)
db.add(new_user)
db.commit()
db.refresh(new_user)
return new_user
@router.get("/{user_id}", response_model=UserResponse)
async def get_user(
user_id: int,
current_user: User = Depends(require_admin),
db: Session = Depends(get_db),
):
"""Get user by ID (admin only)"""
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise HTTPException(status_code=404, detail="User not found")
return user
@router.put("/{user_id}", response_model=UserResponse)
async def update_user(
user_id: int,
user_data: UserUpdate,
current_user: User = Depends(require_admin),
db: Session = Depends(get_db),
):
"""Update user (admin only)"""
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise HTTPException(status_code=404, detail="User not found")
# Prevent admin from disabling themselves
if user.id == current_user.id and user_data.is_active is False:
raise HTTPException(
status_code=400, detail="Cannot deactivate your own account"
)
# Update fields
if user_data.email is not None:
# Check if email already exists
existing_email = (
db.query(User)
.filter(User.email == user_data.email, User.id != user_id)
.first()
)
if existing_email:
raise HTTPException(status_code=400, detail="Email already exists")
user.email = user_data.email
if user_data.role is not None:
user.role = user_data.role
if user_data.is_active is not None:
user.is_active = user_data.is_active
user.updated_at = datetime.utcnow()
db.commit()
db.refresh(user)
return user
@router.delete("/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_user(
user_id: int,
current_user: User = Depends(require_admin),
db: Session = Depends(get_db),
):
"""Delete user (admin only)"""
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise HTTPException(status_code=404, detail="User not found")
# Prevent admin from deleting themselves
if user.id == current_user.id:
raise HTTPException(status_code=400, detail="Cannot delete your own account")
db.delete(user)
db.commit()
return None
@router.post("/change-password")
async def change_password(
password_data: UserPasswordChange,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
"""Change current user's password"""
# Verify current password
if not verify_password(password_data.current_password, current_user.hashed_password):
raise HTTPException(status_code=400, detail="Incorrect current password")
# Update password
current_user.hashed_password = get_password_hash(password_data.new_password)
current_user.updated_at = datetime.utcnow()
db.commit()
return {"message": "Password changed successfully"}
+623
View File
@@ -0,0 +1,623 @@
"""Virtual Machines API routes"""
from fastapi import APIRouter, Depends, HTTPException, status, BackgroundTasks
from sqlalchemy.orm import Session
from pydantic import BaseModel, field_validator
from typing import List, Optional
from datetime import datetime
from app.core.database import get_db
from app.core.security import encrypt_data
from app.models import VirtualMachine, VMStatus, OSType, User
from app.api.auth import get_current_user, require_operator
from app.services.deployment import DeploymentService
router = APIRouter()
# Pydantic models
class VMCreate(BaseModel):
name: str
hostname: str
proxmox_host_id: int
node_id: int
iso_id: Optional[int] = None
cloud_image_id: Optional[int] = None # Use cloud image template instead of ISO
os_type: str # Accept any string, will be validated/converted internally
# CPU options
cpu_sockets: int = 1
cpu_cores: int
cpu_type: Optional[str] = "host" # host, qemu64, kvm64, etc.
cpu_flags: Optional[str] = None # Additional CPU flags
cpu_limit: Optional[int] = None # CPU usage limit (0 = unlimited)
cpu_units: int = 1024 # CPU weight for scheduler
numa_enabled: bool = False # NUMA support
# Memory and disk
memory: int # MB
balloon: Optional[int] = None # Balloon device (0 = disabled, MB)
shares: Optional[int] = None # Memory shares for scheduler
disk_size: int # GB
storage: Optional[str] = None # Storage pool name for VM disks
iso_storage: Optional[str] = None # Storage pool name for ISO files
scsihw: str = "virtio-scsi-pci" # SCSI controller type
# Hardware options
bios_type: str = "seabios" # seabios or ovmf (UEFI)
machine_type: Optional[str] = "pc" # pc, q35, etc.
vga_type: str = "std" # std, virtio, qxl, vmware, cirrus
boot_order: str = "cdn" # c=disk, d=cdrom, n=network
onboot: bool = True # Start VM at boot
tablet: bool = True # Enable tablet pointer device
hotplug: Optional[str] = None # Hotplug options: disk,network,usb,memory,cpu
protection: bool = False # Prevent accidental deletion
startup_order: Optional[int] = None # Startup order
startup_up: Optional[int] = None # Startup delay in seconds
startup_down: Optional[int] = None # Shutdown timeout in seconds
kvm: bool = True # Enable KVM hardware virtualization
acpi: bool = True # Enable ACPI
agent_enabled: bool = True # QEMU guest agent
description: Optional[str] = None # VM description
tags: Optional[str] = None # VM tags (semicolon-separated)
# Network configuration
network_bridge: Optional[str] = None # Primary network bridge
network_interfaces: Optional[list] = None # Additional network interfaces
ip_address: Optional[str] = None
gateway: Optional[str] = None
netmask: Optional[str] = None
dns_servers: Optional[str] = None
# Credentials
username: str
password: str
ssh_key: Optional[str] = None
class VMUpdate(BaseModel):
name: Optional[str] = None
status: Optional[VMStatus] = None
ip_address: Optional[str] = None
class VMResponse(BaseModel):
id: int
vmid: Optional[int]
name: str
hostname: str
proxmox_host_id: int
node_id: Optional[int]
iso_id: Optional[int]
cloud_image_id: Optional[int]
os_type: str # Return as string for flexibility
cpu_cores: int
memory: int
disk_size: int
ip_address: Optional[str]
gateway: Optional[str]
netmask: Optional[str]
dns_servers: Optional[str]
username: str
status: str # Return as string for flexibility
error_message: Optional[str]
created_at: datetime
deployed_at: Optional[datetime]
created_by: int
class Config:
from_attributes = True
class ProxmoxVMResponse(BaseModel):
"""Response model for VMs queried directly from Proxmox"""
vmid: int
name: str
status: str
node: str
cpus: int
maxmem: int # bytes
maxdisk: int # bytes
class Config:
from_attributes = True
@router.get("/", response_model=List[ProxmoxVMResponse])
async def list_vms(
skip: int = 0,
limit: int = 1000,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
"""List all virtual machines from Proxmox"""
from app.services.proxmox import ProxmoxService
from app.models import ProxmoxHost
import logging
logger = logging.getLogger(__name__)
# Query all active Proxmox hosts and get VMs from Proxmox itself
active_hosts = db.query(ProxmoxHost).filter(ProxmoxHost.is_active == True).all()
all_vms = []
for host in active_hosts:
try:
service = ProxmoxService(host)
vms = service.get_all_vms()
# Convert to response format
for vm in vms:
all_vms.append({
'vmid': vm.get('vmid'),
'name': vm.get('name', f"VM {vm.get('vmid')}"),
'status': vm.get('status', 'unknown'),
'node': vm.get('node', 'unknown'),
'cpus': vm.get('cpus', 0),
'maxmem': vm.get('maxmem', 0),
'maxdisk': vm.get('maxdisk', 0),
})
except Exception as e:
logger.error(f"Failed to get VMs from host {host.name}: {e}")
return all_vms
@router.post("/", response_model=VMResponse, status_code=status.HTTP_201_CREATED)
async def create_vm(
vm_data: VMCreate,
background_tasks: BackgroundTasks,
current_user: User = Depends(require_operator),
db: Session = Depends(get_db),
):
"""Create and deploy a new virtual machine"""
import logging
logger = logging.getLogger(__name__)
logger.info(f"Received VM create request: {vm_data.model_dump()}")
# Convert os_type string to enum
try:
os_type_enum = OSType(vm_data.os_type)
except ValueError:
# If not a valid enum value, default to OTHER
os_type_enum = OSType.OTHER
logger.warning(f"Unknown os_type '{vm_data.os_type}', defaulting to OTHER")
# Create VM record
new_vm = VirtualMachine(
name=vm_data.name,
hostname=vm_data.hostname,
proxmox_host_id=vm_data.proxmox_host_id,
node_id=vm_data.node_id,
iso_id=vm_data.iso_id,
cloud_image_id=vm_data.cloud_image_id,
os_type=os_type_enum,
# CPU options
cpu_sockets=vm_data.cpu_sockets,
cpu_cores=vm_data.cpu_cores,
cpu_type=vm_data.cpu_type,
cpu_flags=vm_data.cpu_flags,
cpu_limit=vm_data.cpu_limit,
cpu_units=vm_data.cpu_units,
numa_enabled=vm_data.numa_enabled,
# Memory and disk
memory=vm_data.memory,
balloon=vm_data.balloon,
shares=vm_data.shares,
disk_size=vm_data.disk_size,
storage=vm_data.storage,
iso_storage=vm_data.iso_storage,
scsihw=vm_data.scsihw,
# Hardware options
bios_type=vm_data.bios_type,
machine_type=vm_data.machine_type,
vga_type=vm_data.vga_type,
boot_order=vm_data.boot_order,
onboot=vm_data.onboot,
tablet=vm_data.tablet,
hotplug=vm_data.hotplug,
protection=vm_data.protection,
startup_order=vm_data.startup_order,
startup_up=vm_data.startup_up,
startup_down=vm_data.startup_down,
kvm=vm_data.kvm,
acpi=vm_data.acpi,
agent_enabled=vm_data.agent_enabled,
description=vm_data.description,
tags=vm_data.tags,
# Network configuration
network_bridge=vm_data.network_bridge,
network_interfaces=vm_data.network_interfaces,
ip_address=vm_data.ip_address,
gateway=vm_data.gateway,
netmask=vm_data.netmask,
dns_servers=vm_data.dns_servers,
# Credentials
username=vm_data.username,
password=vm_data.password, # Should be encrypted in production
ssh_key=vm_data.ssh_key,
status=VMStatus.CREATING,
created_by=current_user.id,
)
db.add(new_vm)
db.commit()
db.refresh(new_vm)
# Deploy VM in background
deployment_service = DeploymentService(db)
# Determine deployment type based on OS
windows_types = [
OSType.WINDOWS_SERVER_2016,
OSType.WINDOWS_SERVER_2019,
OSType.WINDOWS_SERVER_2022,
OSType.WINDOWS_10,
OSType.WINDOWS_11
]
if os_type_enum in windows_types:
logger.info(f"Deploying Windows VM {new_vm.id} ({os_type_enum.value})")
background_tasks.add_task(deployment_service.deploy_windows_vm, new_vm.id)
else:
logger.info(f"Deploying Linux/Other VM {new_vm.id} ({os_type_enum.value})")
background_tasks.add_task(deployment_service.deploy_linux_vm, new_vm.id)
return new_vm
@router.get("/{vm_id}", response_model=VMResponse)
async def get_vm(
vm_id: int,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
"""Get virtual machine by ID"""
vm = db.query(VirtualMachine).filter(VirtualMachine.id == vm_id).first()
if not vm:
raise HTTPException(status_code=404, detail="VM not found")
# Check permissions
if current_user.role.value == "viewer" and vm.created_by != current_user.id:
raise HTTPException(status_code=403, detail="Not authorized to view this VM")
return vm
@router.put("/{vm_id}", response_model=VMResponse)
async def update_vm(
vm_id: int,
vm_data: VMUpdate,
current_user: User = Depends(require_operator),
db: Session = Depends(get_db),
):
"""Update virtual machine"""
vm = db.query(VirtualMachine).filter(VirtualMachine.id == vm_id).first()
if not vm:
raise HTTPException(status_code=404, detail="VM not found")
# Update fields
if vm_data.name is not None:
vm.name = vm_data.name
if vm_data.status is not None:
vm.status = vm_data.status
if vm_data.ip_address is not None:
vm.ip_address = vm_data.ip_address
vm.last_updated = datetime.utcnow()
db.commit()
db.refresh(vm)
return vm
@router.delete("/{vm_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_vm(
vm_id: int,
current_user: User = Depends(require_operator),
db: Session = Depends(get_db),
):
"""Delete virtual machine"""
vm = db.query(VirtualMachine).filter(VirtualMachine.id == vm_id).first()
if not vm:
raise HTTPException(status_code=404, detail="VM not found")
# Delete from Proxmox and database
deployment_service = DeploymentService(db)
success = deployment_service.delete_vm(vm_id)
if not success:
raise HTTPException(status_code=500, detail="Failed to delete VM")
return None
@router.post("/{vm_id}/start")
async def start_vm(
vm_id: int,
current_user: User = Depends(require_operator),
db: Session = Depends(get_db),
):
"""Start virtual machine"""
vm = db.query(VirtualMachine).filter(VirtualMachine.id == vm_id).first()
if not vm:
raise HTTPException(status_code=404, detail="VM not found")
from app.services.proxmox import ProxmoxService
from app.models import ProxmoxHost, ProxmoxNode
host = db.query(ProxmoxHost).filter(ProxmoxHost.id == vm.proxmox_host_id).first()
node = db.query(ProxmoxNode).filter(ProxmoxNode.id == vm.node_id).first()
if not host or not node or not vm.vmid:
raise HTTPException(status_code=400, detail="VM not fully configured")
proxmox = ProxmoxService(host)
success = proxmox.start_vm(node.node_name, vm.vmid)
if success:
vm.status = VMStatus.RUNNING
db.commit()
return {"status": "success", "message": "VM started"}
else:
raise HTTPException(status_code=500, detail="Failed to start VM")
@router.post("/{vm_id}/stop")
async def stop_vm(
vm_id: int,
current_user: User = Depends(require_operator),
db: Session = Depends(get_db),
):
"""Stop virtual machine"""
vm = db.query(VirtualMachine).filter(VirtualMachine.id == vm_id).first()
if not vm:
raise HTTPException(status_code=404, detail="VM not found")
from app.services.proxmox import ProxmoxService
from app.models import ProxmoxHost, ProxmoxNode
host = db.query(ProxmoxHost).filter(ProxmoxHost.id == vm.proxmox_host_id).first()
node = db.query(ProxmoxNode).filter(ProxmoxNode.id == vm.node_id).first()
if not host or not node or not vm.vmid:
raise HTTPException(status_code=400, detail="VM not fully configured")
proxmox = ProxmoxService(host)
success = proxmox.stop_vm(node.node_name, vm.vmid)
if success:
vm.status = VMStatus.STOPPED
db.commit()
return {"status": "success", "message": "VM stopped"}
else:
raise HTTPException(status_code=500, detail="Failed to stop VM")
@router.get("/{vm_id}/status")
async def get_vm_status(
vm_id: int,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
"""Get VM status from Proxmox"""
deployment_service = DeploymentService(db)
status_info = deployment_service.check_vm_status(vm_id)
if not status_info:
raise HTTPException(status_code=404, detail="Could not retrieve VM status")
return status_info
@router.get("/{vm_id}/progress")
async def get_vm_progress(
vm_id: int,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
"""Get VM deployment progress"""
vm = db.query(VirtualMachine).filter(VirtualMachine.id == vm_id).first()
if not vm:
raise HTTPException(status_code=404, detail="VM not found")
return {
"id": vm.id,
"name": vm.name,
"status": vm.status.value,
"status_message": vm.status_message,
"error_message": vm.error_message,
"vmid": vm.vmid
}
@router.post("/control/{node_name}/{vmid}/start")
async def start_vm_by_vmid(
node_name: str,
vmid: int,
current_user: User = Depends(require_operator),
db: Session = Depends(get_db),
):
"""Start a VM by VMID and node name"""
from app.services.proxmox import ProxmoxService
from app.models import ProxmoxHost
import logging
logger = logging.getLogger(__name__)
# Find the host that has this node
active_hosts = db.query(ProxmoxHost).filter(ProxmoxHost.is_active == True).all()
for host in active_hosts:
try:
service = ProxmoxService(host)
nodes = service.get_nodes()
# Check if this host has the requested node
if any(n.get('node') == node_name for n in nodes):
success = service.start_vm(node_name, vmid)
if success:
return {"status": "success", "message": f"VM {vmid} started"}
else:
raise HTTPException(status_code=500, detail="Failed to start VM")
except Exception as e:
logger.error(f"Error starting VM on host {host.name}: {e}")
continue
raise HTTPException(status_code=404, detail="Node or VM not found")
@router.post("/control/{node_name}/{vmid}/stop")
async def stop_vm_by_vmid(
node_name: str,
vmid: int,
current_user: User = Depends(require_operator),
db: Session = Depends(get_db),
):
"""Stop a VM by VMID and node name (graceful shutdown)"""
from app.services.proxmox import ProxmoxService
from app.models import ProxmoxHost
import logging
logger = logging.getLogger(__name__)
active_hosts = db.query(ProxmoxHost).filter(ProxmoxHost.is_active == True).all()
for host in active_hosts:
try:
service = ProxmoxService(host)
nodes = service.get_nodes()
if any(n.get('node') == node_name for n in nodes):
success = service.stop_vm(node_name, vmid)
if success:
return {"status": "success", "message": f"VM {vmid} stopped"}
else:
raise HTTPException(status_code=500, detail="Failed to stop VM")
except Exception as e:
logger.error(f"Error stopping VM on host {host.name}: {e}")
continue
raise HTTPException(status_code=404, detail="Node or VM not found")
@router.post("/control/{node_name}/{vmid}/shutdown")
async def shutdown_vm_by_vmid(
node_name: str,
vmid: int,
current_user: User = Depends(require_operator),
db: Session = Depends(get_db),
):
"""Shutdown a VM by VMID and node name (force power off)"""
from app.services.proxmox import ProxmoxService
from app.models import ProxmoxHost
import logging
logger = logging.getLogger(__name__)
active_hosts = db.query(ProxmoxHost).filter(ProxmoxHost.is_active == True).all()
for host in active_hosts:
try:
service = ProxmoxService(host)
nodes = service.get_nodes()
if any(n.get('node') == node_name for n in nodes):
success = service.shutdown_vm(node_name, vmid)
if success:
return {"status": "success", "message": f"VM {vmid} powered off"}
else:
raise HTTPException(status_code=500, detail="Failed to power off VM")
except Exception as e:
logger.error(f"Error powering off VM on host {host.name}: {e}")
continue
raise HTTPException(status_code=404, detail="Node or VM not found")
@router.post("/control/{node_name}/{vmid}/restart")
async def restart_vm_by_vmid(
node_name: str,
vmid: int,
current_user: User = Depends(require_operator),
db: Session = Depends(get_db),
):
"""Restart a VM by VMID and node name"""
from app.services.proxmox import ProxmoxService
from app.models import ProxmoxHost
import logging
logger = logging.getLogger(__name__)
active_hosts = db.query(ProxmoxHost).filter(ProxmoxHost.is_active == True).all()
for host in active_hosts:
try:
service = ProxmoxService(host)
nodes = service.get_nodes()
if any(n.get('node') == node_name for n in nodes):
success = service.restart_vm(node_name, vmid)
if success:
return {"status": "success", "message": f"VM {vmid} restarted"}
else:
raise HTTPException(status_code=500, detail="Failed to restart VM")
except Exception as e:
logger.error(f"Error restarting VM on host {host.name}: {e}")
continue
raise HTTPException(status_code=404, detail="Node or VM not found")
@router.delete("/control/{node_name}/{vmid}/delete")
async def delete_vm_by_vmid(
node_name: str,
vmid: int,
current_user: User = Depends(require_operator),
db: Session = Depends(get_db),
):
"""Delete a VM by VMID and node name - permanently removes from Proxmox"""
from app.services.proxmox import ProxmoxService
from app.models import ProxmoxHost
import logging
logger = logging.getLogger(__name__)
logger.info(f"Delete request for VM {vmid} on node {node_name} by user {current_user.username}")
active_hosts = db.query(ProxmoxHost).filter(ProxmoxHost.is_active == True).all()
for host in active_hosts:
try:
service = ProxmoxService(host)
nodes = service.get_nodes()
if any(n.get('node') == node_name for n in nodes):
# Delete VM from Proxmox
try:
service.proxmox.nodes(node_name).qemu(vmid).delete()
logger.info(f"Successfully deleted VM {vmid} from node {node_name}")
# Also remove from database if it exists
vm_record = db.query(VirtualMachine).filter(VirtualMachine.vmid == vmid).first()
if vm_record:
db.delete(vm_record)
db.commit()
logger.info(f"Removed VM {vmid} record from database")
return {"status": "success", "message": f"VM {vmid} deleted successfully"}
except Exception as e:
error_msg = str(e)
logger.error(f"Failed to delete VM {vmid}: {error_msg}")
raise HTTPException(status_code=500, detail=f"Failed to delete VM: {error_msg}")
except Exception as e:
logger.error(f"Error accessing host {host.name}: {e}")
continue
raise HTTPException(status_code=404, detail="Node or VM not found")
+1
View File
@@ -0,0 +1 @@
"""Core application package"""
+101
View File
@@ -0,0 +1,101 @@
"""Application configuration"""
from pydantic_settings import BaseSettings
from typing import Optional
import os
def get_app_version():
"""Get application version from database or fallback to default"""
try:
import sqlite3
db_path = os.getenv("DATABASE_URL", "sqlite:////var/lib/depl0y/db/depl0y.db")
# Extract path from sqlite URL
if db_path.startswith("sqlite:///"):
db_path = db_path.replace("sqlite:///", "")
conn = sqlite3.connect(db_path)
cursor = conn.cursor()
cursor.execute("SELECT value FROM system_settings WHERE key = 'app_version'")
result = cursor.fetchone()
conn.close()
if result:
return result[0]
except Exception as e:
# Fallback to hardcoded version if database query fails
pass
return "1.1.9"
class Settings(BaseSettings):
"""Application settings"""
# Application
APP_NAME: str = "Depl0y"
APP_VERSION: str = get_app_version()
DEBUG: bool = False
# API
API_V1_PREFIX: str = "/api/v1"
# Security
SECRET_KEY: str = os.getenv("SECRET_KEY", "change-me-in-production-please-use-strong-secret")
ALGORITHM: str = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES: int = 30
REFRESH_TOKEN_EXPIRE_DAYS: int = 7
# Password hashing
PASSWORD_BCRYPT_ROUNDS: int = 12
# Database
DATABASE_URL: str = os.getenv(
"DATABASE_URL",
"sqlite:////var/lib/depl0y/db/depl0y.db"
)
# CORS
BACKEND_CORS_ORIGINS: list = [
"http://localhost:3000",
"http://localhost:8080",
"http://127.0.0.1:3000",
"http://127.0.0.1:8080",
]
# ISO Storage
ISO_STORAGE_PATH: str = os.getenv("ISO_STORAGE_PATH", "/var/lib/depl0y/isos")
MAX_ISO_SIZE: int = 10 * 1024 * 1024 * 1024 # 10GB
# Upload directory for cloud images and other files
UPLOAD_DIR: str = os.getenv("UPLOAD_DIR", "/var/lib/depl0y")
# Proxmox polling
PROXMOX_POLL_INTERVAL: int = 60 # seconds
# Encryption key for sensitive data
ENCRYPTION_KEY: Optional[str] = os.getenv("ENCRYPTION_KEY")
# Cloud-init
CLOUDINIT_TEMPLATE_PATH: str = os.getenv(
"CLOUDINIT_TEMPLATE_PATH",
"/var/lib/depl0y/cloud-init"
)
# SSH
SSH_TIMEOUT: int = 30
SSH_KEY_PATH: str = os.getenv("SSH_KEY_PATH", "/var/lib/depl0y/ssh_keys")
# Default VM settings
DEFAULT_QEMU_AGENT_INSTALL: bool = True
DEFAULT_LINUX_PARTITION_SCHEME: str = "single" # single or custom
# Logging
LOG_LEVEL: str = os.getenv("LOG_LEVEL", "INFO")
LOG_FILE: str = os.getenv("LOG_FILE", "/var/log/depl0y/app.log")
class Config:
case_sensitive = True
env_file = ".env"
settings = Settings()
+34
View File
@@ -0,0 +1,34 @@
"""Database connection and session management"""
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from app.core.config import settings
# Create database engine
engine = create_engine(
settings.DATABASE_URL,
pool_pre_ping=True,
pool_size=10,
max_overflow=20,
)
# Create session factory
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
def get_db():
"""
Dependency to get database session
"""
db = SessionLocal()
try:
yield db
finally:
db.close()
def init_db():
"""
Initialize database tables
"""
from app.models.database import Base
Base.metadata.create_all(bind=engine)
+95
View File
@@ -0,0 +1,95 @@
"""Security utilities for authentication and encryption"""
from datetime import datetime, timedelta
from typing import Optional, Union
from jose import JWTError, jwt
import bcrypt
import pyotp
import secrets
from cryptography.fernet import Fernet
from app.core.config import settings
# Encryption for sensitive data
cipher_suite = None
if settings.ENCRYPTION_KEY:
cipher_suite = Fernet(settings.ENCRYPTION_KEY.encode())
def verify_password(plain_password: str, hashed_password: str) -> bool:
"""Verify a password against a hash"""
return bcrypt.checkpw(plain_password.encode('utf-8'), hashed_password.encode('utf-8'))
def get_password_hash(password: str) -> str:
"""Generate password hash"""
salt = bcrypt.gensalt(rounds=12)
hashed = bcrypt.hashpw(password.encode('utf-8'), salt)
return hashed.decode('utf-8')
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str:
"""Create JWT access token"""
to_encode = data.copy()
if expires_delta:
expire = datetime.utcnow() + expires_delta
else:
expire = datetime.utcnow() + timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
to_encode.update({"exp": expire, "type": "access"})
encoded_jwt = jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
return encoded_jwt
def create_refresh_token(data: dict) -> str:
"""Create JWT refresh token"""
to_encode = data.copy()
expire = datetime.utcnow() + timedelta(days=settings.REFRESH_TOKEN_EXPIRE_DAYS)
to_encode.update({"exp": expire, "type": "refresh"})
encoded_jwt = jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
return encoded_jwt
def decode_token(token: str) -> Optional[dict]:
"""Decode and verify JWT token"""
try:
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM])
return payload
except JWTError:
return None
# TOTP (2FA) functions
def generate_totp_secret() -> str:
"""Generate a new TOTP secret"""
return pyotp.random_base32()
def generate_totp_uri(secret: str, username: str, issuer: str = "Depl0y") -> str:
"""Generate TOTP provisioning URI for QR code"""
totp = pyotp.TOTP(secret)
return totp.provisioning_uri(name=username, issuer_name=issuer)
def verify_totp_code(secret: str, code: str) -> bool:
"""Verify TOTP code"""
totp = pyotp.TOTP(secret)
return totp.verify(code, valid_window=1)
# Encryption functions for sensitive data
def encrypt_data(data: str) -> str:
"""Encrypt sensitive data"""
if not cipher_suite:
raise ValueError("ENCRYPTION_KEY not configured")
return cipher_suite.encrypt(data.encode()).decode()
def decrypt_data(encrypted_data: str) -> str:
"""Decrypt sensitive data"""
if not cipher_suite:
raise ValueError("ENCRYPTION_KEY not configured")
return cipher_suite.decrypt(encrypted_data.encode()).decode()
def generate_api_key() -> str:
"""Generate a secure API key"""
return secrets.token_urlsafe(32)
+110
View File
@@ -0,0 +1,110 @@
"""Main FastAPI application"""
from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse
from app.core.config import settings
from app.core.database import init_db
from app.api import auth, users, proxmox, vms, isos, cloud_images, updates, dashboard, bug_report, logs, docs, setup, system_updates, ha, system
import logging
from logging.handlers import RotatingFileHandler
import os
# Configure logging
os.makedirs(os.path.dirname(settings.LOG_FILE), exist_ok=True)
logging.basicConfig(
level=settings.LOG_LEVEL,
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
handlers=[
RotatingFileHandler(settings.LOG_FILE, maxBytes=10485760, backupCount=5),
logging.StreamHandler(),
],
)
logger = logging.getLogger(__name__)
# Create FastAPI app
app = FastAPI(
title=settings.APP_NAME,
version=settings.APP_VERSION,
description="Automated VM Deployment Panel for Proxmox VE",
)
# Configure CORS
app.add_middleware(
CORSMiddleware,
allow_origins=settings.BACKEND_CORS_ORIGINS,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Add validation error handler for debugging
@app.exception_handler(RequestValidationError)
async def validation_exception_handler(request: Request, exc: RequestValidationError):
"""Log validation errors with details"""
logger.error(f"Validation error on {request.method} {request.url}")
logger.error(f"Validation errors: {exc.errors()}")
try:
body = await request.body()
logger.error(f"Request body: {body.decode()}")
except:
pass
return JSONResponse(
status_code=422,
content={"detail": exc.errors()},
)
@app.on_event("startup")
async def startup_event():
"""Initialize database on startup"""
logger.info(f"Starting {settings.APP_NAME} v{settings.APP_VERSION}")
init_db()
logger.info("Database initialized")
@app.get("/")
async def root():
"""Root endpoint"""
return {
"name": settings.APP_NAME,
"version": settings.APP_VERSION,
"status": "running",
}
@app.get("/health")
async def health_check():
"""Health check endpoint"""
return {"status": "healthy"}
# Include routers
app.include_router(auth.router, prefix=f"{settings.API_V1_PREFIX}/auth", tags=["Authentication"])
app.include_router(users.router, prefix=f"{settings.API_V1_PREFIX}/users", tags=["Users"])
app.include_router(proxmox.router, prefix=f"{settings.API_V1_PREFIX}/proxmox", tags=["Proxmox"])
app.include_router(vms.router, prefix=f"{settings.API_V1_PREFIX}/vms", tags=["Virtual Machines"])
app.include_router(isos.router, prefix=f"{settings.API_V1_PREFIX}/isos", tags=["ISO Images"])
app.include_router(cloud_images.router, prefix=f"{settings.API_V1_PREFIX}/cloud-images", tags=["Cloud Images"])
app.include_router(updates.router, prefix=f"{settings.API_V1_PREFIX}/updates", tags=["Updates"])
app.include_router(dashboard.router, prefix=f"{settings.API_V1_PREFIX}/dashboard", tags=["Dashboard"])
app.include_router(bug_report.router, prefix=f"{settings.API_V1_PREFIX}/bug-report", tags=["Bug Report"])
app.include_router(logs.router, prefix=f"{settings.API_V1_PREFIX}/logs", tags=["System Logs"])
app.include_router(docs.router, prefix=f"{settings.API_V1_PREFIX}/docs", tags=["Documentation"])
app.include_router(setup.router, prefix=f"{settings.API_V1_PREFIX}/setup", tags=["Setup"])
app.include_router(system_updates.router, prefix=f"{settings.API_V1_PREFIX}/system-updates", tags=["System Updates"])
app.include_router(ha.router, prefix=f"{settings.API_V1_PREFIX}/ha", tags=["High Availability"])
app.include_router(system.router, prefix=f"{settings.API_V1_PREFIX}/system", tags=["System"])
if __name__ == "__main__":
import uvicorn
uvicorn.run(
"app.main:app",
host="0.0.0.0",
port=8000,
reload=settings.DEBUG,
)
+30
View File
@@ -0,0 +1,30 @@
"""Database models package"""
from .database import (
Base,
User,
UserRole,
ProxmoxHost,
ProxmoxNode,
ISOImage,
CloudImage,
VirtualMachine,
VMStatus,
OSType,
UpdateLog,
AuditLog,
)
__all__ = [
"Base",
"User",
"UserRole",
"ProxmoxHost",
"ProxmoxNode",
"ISOImage",
"CloudImage",
"VirtualMachine",
"VMStatus",
"OSType",
"UpdateLog",
"AuditLog",
]
+300
View File
@@ -0,0 +1,300 @@
"""
Database models for Depl0y
"""
from datetime import datetime
from sqlalchemy import Boolean, Column, Integer, String, DateTime, ForeignKey, Text, Enum, JSON
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import relationship
import enum
Base = declarative_base()
class UserRole(str, enum.Enum):
"""User role enumeration"""
ADMIN = "admin"
OPERATOR = "operator"
VIEWER = "viewer"
class VMStatus(str, enum.Enum):
"""VM status enumeration"""
CREATING = "creating"
RUNNING = "running"
STOPPED = "stopped"
ERROR = "error"
DELETING = "deleting"
class OSType(str, enum.Enum):
"""Operating system type"""
# Linux Server
UBUNTU = "ubuntu"
DEBIAN = "debian"
CENTOS = "centos"
ROCKY = "rocky"
ALMA = "alma"
# Windows
WINDOWS_SERVER_2016 = "windows_server_2016"
WINDOWS_SERVER_2019 = "windows_server_2019"
WINDOWS_SERVER_2022 = "windows_server_2022"
WINDOWS_10 = "windows_10"
WINDOWS_11 = "windows_11"
# Firewalls
PFSENSE = "pfsense"
OPNSENSE = "opnsense"
SOPHOS = "sophos"
FORTINET = "fortinet"
VYOS = "vyos"
# Other
FREEBSD = "freebsd"
TRUENAS = "truenas"
PROXMOX = "proxmox"
ESXI = "esxi"
OTHER = "other"
class User(Base):
"""User model"""
__tablename__ = "users"
id = Column(Integer, primary_key=True, index=True)
username = Column(String(100), unique=True, index=True, nullable=False)
email = Column(String(255), unique=True, index=True, nullable=False)
hashed_password = Column(String(255), nullable=False)
role = Column(Enum(UserRole), default=UserRole.VIEWER, nullable=False)
is_active = Column(Boolean, default=True, nullable=False)
totp_secret = Column(String(32), nullable=True) # For 2FA
totp_enabled = Column(Boolean, default=False, nullable=False)
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
last_login = Column(DateTime, nullable=True)
# Relationships
vms = relationship("VirtualMachine", back_populates="created_by_user")
audit_logs = relationship("AuditLog", back_populates="user")
class ProxmoxHost(Base):
"""Proxmox VE host configuration"""
__tablename__ = "proxmox_hosts"
id = Column(Integer, primary_key=True, index=True)
name = Column(String(100), unique=True, nullable=False)
hostname = Column(String(255), nullable=False)
port = Column(Integer, default=8006, nullable=False)
username = Column(String(100), nullable=False)
password = Column(String(255), nullable=True) # Encrypted (optional if using token)
api_token_id = Column(String(100), nullable=True) # e.g., "root@pam!mytoken"
api_token_secret = Column(String(255), nullable=True) # Encrypted API token secret
verify_ssl = Column(Boolean, default=False, nullable=False)
is_active = Column(Boolean, default=True, nullable=False)
last_poll = Column(DateTime, nullable=True)
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
# Relationships
nodes = relationship("ProxmoxNode", back_populates="host", cascade="all, delete-orphan")
vms = relationship("VirtualMachine", back_populates="proxmox_host")
class ProxmoxNode(Base):
"""Proxmox node (hypervisor) information"""
__tablename__ = "proxmox_nodes"
id = Column(Integer, primary_key=True, index=True)
host_id = Column(Integer, ForeignKey("proxmox_hosts.id"), nullable=False)
node_name = Column(String(100), nullable=False)
status = Column(String(50), nullable=True)
cpu_cores = Column(Integer, nullable=True)
cpu_usage = Column(Integer, nullable=True) # Percentage
memory_total = Column(Integer, nullable=True) # Bytes
memory_used = Column(Integer, nullable=True) # Bytes
disk_total = Column(Integer, nullable=True) # Bytes
disk_used = Column(Integer, nullable=True) # Bytes
uptime = Column(Integer, nullable=True) # Seconds
last_updated = Column(DateTime, default=datetime.utcnow, nullable=False)
# Relationships
host = relationship("ProxmoxHost", back_populates="nodes")
vms = relationship("VirtualMachine", back_populates="node")
class ISOImage(Base):
"""ISO image storage"""
__tablename__ = "iso_images"
id = Column(Integer, primary_key=True, index=True)
name = Column(String(255), nullable=False)
filename = Column(String(255), unique=True, nullable=False)
os_type = Column(Enum(OSType), nullable=False)
version = Column(String(50), nullable=True)
architecture = Column(String(20), default="amd64", nullable=False)
file_size = Column(Integer, nullable=True) # Bytes
checksum = Column(String(64), nullable=True) # SHA256
storage_path = Column(String(500), nullable=False)
uploaded_by = Column(Integer, ForeignKey("users.id"), nullable=True)
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
is_available = Column(Boolean, default=True, nullable=False)
# Relationships
vms = relationship("VirtualMachine", back_populates="iso_image")
class CloudImage(Base):
"""Cloud image storage for pre-installed VM images"""
__tablename__ = "cloud_images"
id = Column(Integer, primary_key=True, index=True)
name = Column(String(255), nullable=False)
filename = Column(String(255), unique=True, nullable=False)
os_type = Column(String(50), nullable=False) # Store as string to avoid enum issues
version = Column(String(50), nullable=True)
architecture = Column(String(20), default="amd64", nullable=False)
file_size = Column(Integer, nullable=True) # Bytes
checksum = Column(String(64), nullable=True) # SHA256
download_url = Column(String(500), nullable=False) # Where to download from
storage_path = Column(String(500), nullable=True) # Local path once downloaded
is_downloaded = Column(Boolean, default=False, nullable=False)
download_progress = Column(Integer, default=0, nullable=False) # Percentage
download_status = Column(String(50), default="pending", nullable=False) # pending, downloading, completed, error
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
is_available = Column(Boolean, default=True, nullable=False)
# Relationships
vms = relationship("VirtualMachine", back_populates="cloud_image")
class VirtualMachine(Base):
"""Virtual machine deployment records"""
__tablename__ = "virtual_machines"
id = Column(Integer, primary_key=True, index=True)
vmid = Column(Integer, nullable=True) # Proxmox VMID
name = Column(String(255), nullable=False)
hostname = Column(String(255), nullable=False)
# Proxmox references
proxmox_host_id = Column(Integer, ForeignKey("proxmox_hosts.id"), nullable=False)
node_id = Column(Integer, ForeignKey("proxmox_nodes.id"), nullable=True)
iso_id = Column(Integer, ForeignKey("iso_images.id"), nullable=True)
cloud_image_id = Column(Integer, ForeignKey("cloud_images.id"), nullable=True)
# VM specifications
os_type = Column(Enum(OSType), nullable=False)
cpu_sockets = Column(Integer, default=1, nullable=False)
cpu_cores = Column(Integer, nullable=False)
cpu_type = Column(String(100), default="host", nullable=True) # CPU type: host, qemu64, kvm64, etc.
cpu_flags = Column(String(500), nullable=True) # Additional CPU flags
cpu_limit = Column(Integer, nullable=True) # CPU usage limit (0 = unlimited)
cpu_units = Column(Integer, default=1024, nullable=True) # CPU weight for scheduler
numa_enabled = Column(Boolean, default=False, nullable=False) # NUMA support
memory = Column(Integer, nullable=False) # MB
balloon = Column(Integer, nullable=True) # Balloon device (0 = disabled, MB)
shares = Column(Integer, nullable=True) # Memory shares for scheduler
disk_size = Column(Integer, nullable=False) # GB
storage = Column(String(100), nullable=True) # Storage pool name for VM disks
iso_storage = Column(String(100), nullable=True) # Storage pool name for ISO files
scsihw = Column(String(50), default="virtio-scsi-pci", nullable=True) # SCSI controller type
# Hardware options
bios_type = Column(String(20), default="seabios", nullable=False) # seabios or ovmf (UEFI)
machine_type = Column(String(50), default="pc", nullable=True) # pc, q35, etc.
vga_type = Column(String(50), default="std", nullable=False) # std, virtio, qxl, vmware, cirrus
boot_order = Column(String(100), default="cdn", nullable=False) # c=disk, d=cdrom, n=network
onboot = Column(Boolean, default=True, nullable=False) # Start VM at boot
tablet = Column(Boolean, default=True, nullable=False) # Enable tablet pointer device
hotplug = Column(String(200), nullable=True) # Hotplug options: disk,network,usb,memory,cpu
protection = Column(Boolean, default=False, nullable=False) # Prevent accidental deletion
startup_order = Column(Integer, nullable=True) # Startup order
startup_up = Column(Integer, nullable=True) # Startup delay in seconds
startup_down = Column(Integer, nullable=True) # Shutdown timeout in seconds
kvm = Column(Boolean, default=True, nullable=False) # Enable KVM hardware virtualization
acpi = Column(Boolean, default=True, nullable=False) # Enable ACPI
agent_enabled = Column(Boolean, default=True, nullable=False) # QEMU guest agent
description = Column(Text, nullable=True) # VM description
tags = Column(String(500), nullable=True) # VM tags (semicolon-separated)
# Network configuration
network_bridge = Column(String(100), nullable=True) # Primary network bridge
network_interfaces = Column(JSON, nullable=True) # Additional network interfaces as JSON array
ip_address = Column(String(45), nullable=True) # IPv4/IPv6
gateway = Column(String(45), nullable=True)
netmask = Column(String(45), nullable=True)
dns_servers = Column(String(255), nullable=True)
# Credentials (encrypted)
username = Column(String(100), nullable=False)
password = Column(String(255), nullable=False)
ssh_key = Column(Text, nullable=True)
# Status and metadata
status = Column(Enum(VMStatus), default=VMStatus.CREATING, nullable=False)
status_message = Column(String(500), nullable=True) # Real-time progress message
cloud_init_config = Column(JSON, nullable=True)
error_message = Column(Text, nullable=True)
# Timestamps
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
deployed_at = Column(DateTime, nullable=True)
last_updated = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
# User reference
created_by = Column(Integer, ForeignKey("users.id"), nullable=False)
# Relationships
created_by_user = relationship("User", back_populates="vms")
proxmox_host = relationship("ProxmoxHost", back_populates="vms")
node = relationship("ProxmoxNode", back_populates="vms")
iso_image = relationship("ISOImage", back_populates="vms")
cloud_image = relationship("CloudImage", back_populates="vms")
update_logs = relationship("UpdateLog", back_populates="vm", cascade="all, delete-orphan")
class UpdateLog(Base):
"""Update history for VMs"""
__tablename__ = "update_logs"
id = Column(Integer, primary_key=True, index=True)
vm_id = Column(Integer, ForeignKey("virtual_machines.id"), nullable=False)
initiated_by = Column(Integer, ForeignKey("users.id"), nullable=False)
status = Column(String(50), nullable=False) # pending, running, completed, failed
packages_updated = Column(Integer, default=0, nullable=False)
output = Column(Text, nullable=True)
error_message = Column(Text, nullable=True)
started_at = Column(DateTime, default=datetime.utcnow, nullable=False)
completed_at = Column(DateTime, nullable=True)
# Relationships
vm = relationship("VirtualMachine", back_populates="update_logs")
class AuditLog(Base):
"""Audit log for tracking user actions"""
__tablename__ = "audit_logs"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=True)
action = Column(String(100), nullable=False)
resource_type = Column(String(50), nullable=True)
resource_id = Column(Integer, nullable=True)
details = Column(JSON, nullable=True)
ip_address = Column(String(45), nullable=True)
user_agent = Column(String(255), nullable=True)
timestamp = Column(DateTime, default=datetime.utcnow, nullable=False)
# Relationships
user = relationship("User", back_populates="audit_logs")
class SystemSettings(Base):
"""System-wide settings and configuration"""
__tablename__ = "system_settings"
id = Column(Integer, primary_key=True, index=True)
key = Column(String(100), unique=True, nullable=False, index=True)
value = Column(Text, nullable=False)
description = Column(Text, nullable=True)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
+1
View File
@@ -0,0 +1 @@
"""Services package"""
+218
View File
@@ -0,0 +1,218 @@
"""Cloud Image Download Service"""
import os
import requests
import logging
from sqlalchemy.orm import Session
from app.models import CloudImage
from app.core.config import settings
logger = logging.getLogger(__name__)
# Directory to store downloaded cloud images
CLOUD_IMAGES_DIR = os.path.join(settings.UPLOAD_DIR, "cloud-images")
os.makedirs(CLOUD_IMAGES_DIR, exist_ok=True)
def download_cloud_image_task(image_id: int, db: Session):
"""Download a cloud image from URL with progress tracking"""
try:
image = db.query(CloudImage).filter(CloudImage.id == image_id).first()
if not image:
logger.error(f"Cloud image {image_id} not found")
return
if image.is_downloaded:
logger.info(f"Cloud image {image.id} already downloaded")
return
# Update status
image.download_status = "downloading"
image.download_progress = 0
db.commit()
logger.info(f"Starting download of {image.name} from {image.download_url}")
# Download the file
response = requests.get(image.download_url, stream=True, timeout=30)
response.raise_for_status()
# Get file size from headers
total_size = int(response.headers.get('content-length', 0))
image.file_size = total_size
db.commit()
# Determine file path
storage_path = os.path.join(CLOUD_IMAGES_DIR, image.filename)
image.storage_path = storage_path
# Download with progress tracking
downloaded = 0
chunk_size = 8192 # 8KB chunks
last_progress = 0
logger.info(f"Downloading {total_size / (1024 * 1024):.1f} MB to {storage_path}")
with open(storage_path, 'wb') as f:
for chunk in response.iter_content(chunk_size=chunk_size):
if chunk:
f.write(chunk)
downloaded += len(chunk)
# Update progress every 5%
if total_size > 0:
progress = int((downloaded / total_size) * 100)
if progress >= last_progress + 5 or progress == 100:
image.download_progress = progress
db.commit()
logger.info(f"Download progress: {progress}% ({downloaded / (1024 * 1024):.1f} MB)")
last_progress = progress
# Mark as completed
image.download_status = "completed"
image.download_progress = 100
image.is_downloaded = True
db.commit()
logger.info(f"Successfully downloaded cloud image: {image.name}")
except requests.exceptions.RequestException as e:
logger.error(f"Failed to download cloud image: {e}")
if image:
image.download_status = "error"
image.download_progress = 0
db.commit()
except Exception as e:
logger.error(f"Unexpected error during download: {e}")
if image:
image.download_status = "error"
image.download_progress = 0
db.commit()
def get_cloud_image_path(image_id: int, db: Session) -> str | None:
"""Get the local path of a downloaded cloud image"""
try:
image = db.query(CloudImage).filter(CloudImage.id == image_id).first()
if not image:
return None
if not image.is_downloaded or not image.storage_path:
return None
if not os.path.exists(image.storage_path):
logger.warning(f"Cloud image file not found at {image.storage_path}")
image.is_downloaded = False
db.commit()
return None
return image.storage_path
except Exception as e:
logger.error(f"Error getting cloud image path: {e}")
return None
def create_template_on_node(image_id: int, node_id: int, template_vmid: int, db: Session):
"""Create a cloud image template on a Proxmox node"""
try:
from app.models import CloudImage, ProxmoxNode, ProxmoxHost
from app.services.proxmox import ProxmoxService
import time
image = db.query(CloudImage).filter(CloudImage.id == image_id).first()
if not image:
logger.error(f"Cloud image {image_id} not found")
return
node = db.query(ProxmoxNode).filter(ProxmoxNode.id == node_id).first()
if not node:
logger.error(f"Node {node_id} not found")
return
host = db.query(ProxmoxHost).filter(ProxmoxHost.id == node.host_id).first()
if not host:
logger.error(f"Proxmox host not found for node {node_id}")
return
proxmox = ProxmoxService(host)
logger.info(f"Creating template {template_vmid} for {image.name} on node {node.node_name}")
# Check if template already exists
try:
proxmox.proxmox.nodes(node.node_name).qemu(template_vmid).status.current.get()
logger.info(f"Template {template_vmid} already exists on {node.node_name}")
return
except:
pass # Template doesn't exist, create it
# Step 1: Download cloud image if not already downloaded
if not image.is_downloaded or not image.storage_path:
logger.info(f"Downloading cloud image {image.name}")
download_cloud_image_task(image_id, db)
# Wait for download to complete
max_wait = 3600 # 1 hour max
waited = 0
while not image.is_downloaded and waited < max_wait:
time.sleep(5)
waited += 5
db.refresh(image)
if not image.is_downloaded:
raise Exception(f"Cloud image download timed out after {max_wait} seconds")
# Step 2: Upload cloud image to Proxmox
logger.info(f"Uploading cloud image to {node.node_name}")
# Use Proxmox API to upload to ISO storage
image_filename = image.filename
with open(image.storage_path, 'rb') as f:
# Upload via Proxmox API
upload_url = f"/nodes/{node.node_name}/storage/local/upload"
files = {'filename': (image_filename, f)}
data = {'content': 'iso'}
proxmox.proxmox.nodes(node.node_name).storage('local').upload.post(
content='iso',
filename=f
)
logger.info(f"Cloud image uploaded to {node.node_name}:local/iso/{image_filename}")
# Step 3: Create VM shell
logger.info(f"Creating VM shell {template_vmid}")
proxmox.proxmox.nodes(node.node_name).qemu.create(
vmid=template_vmid,
name=f"cloud-{image.os_type}-{image.version or 'latest'}",
memory=2048,
cores=2,
sockets=1,
net0='virtio,bridge=vmbr0',
scsihw='virtio-scsi-pci',
ostype='l26' if image.os_type != 'windows' else 'win10',
agent=1
)
time.sleep(2)
# Step 4: Import disk using qm importdisk (requires SSH/exec)
# For now, we'll add a note that manual import is needed
logger.warning(f"Template {template_vmid} created but requires manual disk import")
logger.warning(f"Run on Proxmox node: qm importdisk {template_vmid} /var/lib/vz/template/iso/{image_filename} local-lvm")
# Step 5: Configure disk and cloud-init
proxmox.proxmox.nodes(node.node_name).qemu(template_vmid).config.put(
scsi0='local-lvm:0,import-from=/var/lib/vz/template/iso/' + image_filename,
ide2='local-lvm:cloudinit',
boot='order=scsi0',
serial0='socket',
vga='serial0'
)
# Step 6: Convert to template
proxmox.proxmox.nodes(node.node_name).qemu(template_vmid).template.post()
logger.info(f"Successfully created template {template_vmid} on {node.node_name}")
except Exception as e:
logger.error(f"Failed to create template: {e}", exc_info=True)
+293
View File
@@ -0,0 +1,293 @@
"""Cloud-init configuration service"""
import yaml
from typing import Optional, List, Dict, Any
import logging
logger = logging.getLogger(__name__)
class CloudInitService:
"""Service for generating cloud-init configurations"""
@staticmethod
def generate_user_data(
hostname: str,
username: str,
password: Optional[str] = None,
ssh_keys: Optional[List[str]] = None,
packages: Optional[List[str]] = None,
install_qemu_agent: bool = True,
timezone: str = "UTC",
additional_commands: Optional[List[str]] = None,
) -> str:
"""
Generate cloud-init user-data configuration
Args:
hostname: VM hostname
username: Default user username
password: User password (will be hashed by cloud-init)
ssh_keys: List of SSH public keys
packages: Additional packages to install
install_qemu_agent: Install QEMU guest agent
timezone: System timezone
additional_commands: Additional commands to run
Returns:
YAML formatted user-data string
"""
config = {
"hostname": hostname,
"fqdn": f"{hostname}.local",
"manage_etc_hosts": True,
"timezone": timezone,
"users": [],
"ssh_pwauth": True if password else False,
"disable_root": True,
"package_update": True,
"package_upgrade": True,
"packages": packages or [],
"runcmd": additional_commands or [],
}
# Configure default user
user_config = {
"name": username,
"groups": ["sudo", "users", "admin"],
"shell": "/bin/bash",
"sudo": ["ALL=(ALL) NOPASSWD:ALL"],
}
if password:
user_config["password"] = password
user_config["lock_passwd"] = False
else:
user_config["lock_passwd"] = True
if ssh_keys:
user_config["ssh_authorized_keys"] = ssh_keys
config["users"].append(user_config)
# Add QEMU guest agent installation
if install_qemu_agent:
if "qemu-guest-agent" not in config["packages"]:
config["packages"].append("qemu-guest-agent")
# Ensure agent starts on boot
config["runcmd"].extend([
"systemctl enable qemu-guest-agent",
"systemctl start qemu-guest-agent",
])
# Add SSH server if not present
if "openssh-server" not in config["packages"]:
config["packages"].append("openssh-server")
# Ensure SSH is enabled
config["runcmd"].extend([
"systemctl enable ssh",
"systemctl start ssh",
])
# Generate YAML with cloud-config header
user_data = "#cloud-config\n" + yaml.dump(
config, default_flow_style=False, sort_keys=False
)
return user_data
@staticmethod
def generate_network_config(
use_dhcp: bool = True,
ip_address: Optional[str] = None,
netmask: Optional[str] = None,
gateway: Optional[str] = None,
nameservers: Optional[List[str]] = None,
interface: str = "eth0",
) -> str:
"""
Generate cloud-init network configuration
Args:
use_dhcp: Use DHCP for network configuration
ip_address: Static IP address
netmask: Network mask
gateway: Default gateway
nameservers: DNS nameservers
interface: Network interface name
Returns:
YAML formatted network config string
"""
if use_dhcp:
config = {
"version": 2,
"ethernets": {
interface: {
"dhcp4": True,
"dhcp6": False,
}
},
}
else:
if not all([ip_address, netmask, gateway]):
raise ValueError(
"Static IP configuration requires ip_address, netmask, and gateway"
)
config = {
"version": 2,
"ethernets": {
interface: {
"dhcp4": False,
"dhcp6": False,
"addresses": [f"{ip_address}/{netmask}"],
"gateway4": gateway,
}
},
}
if nameservers:
config["ethernets"][interface]["nameservers"] = {
"addresses": nameservers
}
return yaml.dump(config, default_flow_style=False, sort_keys=False)
@staticmethod
def generate_meta_data(
instance_id: str,
hostname: str,
) -> str:
"""
Generate cloud-init meta-data
Args:
instance_id: Unique instance identifier
hostname: VM hostname
Returns:
YAML formatted meta-data string
"""
config = {
"instance-id": instance_id,
"local-hostname": hostname,
}
return yaml.dump(config, default_flow_style=False, sort_keys=False)
@staticmethod
def generate_partition_config(
disk_device: str = "/dev/sda",
scheme: str = "single",
) -> Dict[str, Any]:
"""
Generate disk partition configuration for cloud-init
Args:
disk_device: Disk device path
scheme: Partitioning scheme (single or custom)
Returns:
Partition configuration dictionary
"""
if scheme == "single":
# Single large partition with necessary boot partitions
return {
"layout": {
"name": "direct",
"match": {
"serial": "*",
},
},
"partitions": [
{
"id": "boot-partition",
"type": "partition",
"device": disk_device,
"size": "512M",
"flag": "boot",
"grub_device": True,
},
{
"id": "root-partition",
"type": "partition",
"device": disk_device,
"size": "-1", # Use remaining space
},
],
"filesystems": [
{
"id": "boot-fs",
"type": "ext4",
"partition": "boot-partition",
},
{
"id": "root-fs",
"type": "ext4",
"partition": "root-partition",
},
],
"mounts": [
["boot-fs", "/boot"],
["root-fs", "/"],
],
}
else:
# Custom scheme - can be extended based on requirements
return {
"layout": "lvm",
"partitions": [],
}
@staticmethod
def create_complete_config(
hostname: str,
username: str,
password: Optional[str] = None,
ssh_keys: Optional[List[str]] = None,
use_dhcp: bool = True,
ip_address: Optional[str] = None,
netmask: Optional[str] = None,
gateway: Optional[str] = None,
nameservers: Optional[List[str]] = None,
packages: Optional[List[str]] = None,
install_qemu_agent: bool = True,
partition_scheme: str = "single",
) -> Dict[str, str]:
"""
Create complete cloud-init configuration with user-data, meta-data, and network-config
Returns:
Dictionary with 'user_data', 'meta_data', and 'network_config' keys
"""
instance_id = f"depl0y-{hostname}"
user_data = CloudInitService.generate_user_data(
hostname=hostname,
username=username,
password=password,
ssh_keys=ssh_keys,
packages=packages,
install_qemu_agent=install_qemu_agent,
)
meta_data = CloudInitService.generate_meta_data(
instance_id=instance_id,
hostname=hostname,
)
network_config = CloudInitService.generate_network_config(
use_dhcp=use_dhcp,
ip_address=ip_address,
netmask=netmask,
gateway=gateway,
nameservers=nameservers,
)
return {
"user_data": user_data,
"meta_data": meta_data,
"network_config": network_config,
}
File diff suppressed because it is too large Load Diff
+854
View File
@@ -0,0 +1,854 @@
"""Proxmox VE API integration service"""
from typing import List, Dict, Optional, Any
from proxmoxer import ProxmoxAPI
from proxmoxer.core import ResourceException
from sqlalchemy.orm import Session
from app.models import ProxmoxHost, ProxmoxNode
from app.core.security import decrypt_data
from datetime import datetime
import logging
logger = logging.getLogger(__name__)
class ProxmoxService:
"""Service for interacting with Proxmox VE"""
def __init__(self, host: ProxmoxHost):
"""Initialize Proxmox connection"""
self.host = host
# Check if using API token (preferred for 2FA-enabled Proxmox)
if host.api_token_id and host.api_token_secret:
try:
token_secret = decrypt_data(host.api_token_secret)
except Exception:
token_secret = host.api_token_secret
# Extract token name and user from token ID
# Token ID can be "tokenname" or "user@realm!tokenname"
if '!' in host.api_token_id:
token_parts = host.api_token_id.split('!')
token_user = token_parts[0] # e.g., "root@pam"
token_name = token_parts[1] # e.g., "depl0y"
else:
token_user = host.username
token_name = host.api_token_id
logger.info(f"Connecting to Proxmox {host.hostname} with token auth: user={token_user}, token_name={token_name}")
self.proxmox = ProxmoxAPI(
host.hostname,
user=token_user,
token_name=token_name,
token_value=token_secret,
port=host.port,
verify_ssl=host.verify_ssl,
timeout=30, # Increase timeout to 30 seconds for slow operations
)
else:
# Fall back to password authentication
try:
password = decrypt_data(host.password)
except Exception:
password = host.password
self.proxmox = ProxmoxAPI(
host.hostname,
user=host.username,
password=password,
port=host.port,
verify_ssl=host.verify_ssl,
timeout=30, # Increase timeout to 30 seconds for slow operations
)
def test_connection(self) -> bool:
"""Test connection to Proxmox host"""
try:
version = self.proxmox.version.get()
logger.info(f"Successfully connected to Proxmox {self.host.name}, version: {version.get('version', 'unknown')}")
return True
except Exception as e:
logger.error(f"Failed to connect to Proxmox host {self.host.name}: {str(e)}")
logger.error(f"Connection details: hostname={self.host.hostname}, port={self.host.port}, using_token={bool(self.host.api_token_id)}")
return False
def get_nodes(self) -> List[Dict[str, Any]]:
"""Get list of nodes from Proxmox cluster"""
try:
nodes = self.proxmox.nodes.get()
return nodes
except ResourceException as e:
logger.error(f"Failed to get nodes: {e}")
return []
def get_node_resources(self, node_name: str) -> Optional[Dict[str, Any]]:
"""Get resource information for a specific node"""
try:
node = self.proxmox.nodes(node_name)
status = node.status.get()
# Proxmox returns 'online' or 'offline' in the node list, not in status
# We can infer online if we get a successful response
node_status = "online"
return {
"node_name": node_name,
"status": node_status,
"cpu_cores": status.get("cpuinfo", {}).get("cpus", 0),
"cpu_usage": int((status.get("cpu", 0) * 100)),
"memory_total": status.get("memory", {}).get("total", 0),
"memory_used": status.get("memory", {}).get("used", 0),
"disk_total": status.get("rootfs", {}).get("total", 0),
"disk_used": status.get("rootfs", {}).get("used", 0),
"uptime": status.get("uptime", 0),
}
except ResourceException as e:
logger.error(f"Failed to get node resources for {node_name}: {e}")
return None
def get_next_vmid(self) -> int:
"""Get next available VMID"""
try:
return int(self.proxmox.cluster.nextid.get())
except ResourceException as e:
logger.error(f"Failed to get next VMID: {e}")
return 100
def get_storage_list(self, node_name: str) -> List[Dict[str, Any]]:
"""Get storage list for a node with detailed information"""
try:
storages = self.proxmox.nodes(node_name).storage.get()
detailed_storages = []
for storage in storages:
storage_name = storage.get('storage')
try:
# Get detailed storage info
storage_status = self.proxmox.nodes(node_name).storage(storage_name).status.get()
detailed_storages.append({
'storage': storage_name,
'type': storage.get('type'),
'content': storage.get('content', ''),
'active': storage.get('active', 0) == 1,
'enabled': storage.get('enabled', 0) == 1,
'total': storage_status.get('total', 0),
'used': storage_status.get('used', 0),
'available': storage_status.get('avail', 0),
'shared': storage.get('shared', 0) == 1,
})
except Exception as e:
logger.warning(f"Failed to get detailed info for storage {storage_name}: {e}")
detailed_storages.append({
'storage': storage_name,
'type': storage.get('type'),
'content': storage.get('content', ''),
'active': storage.get('active', 0) == 1,
'enabled': storage.get('enabled', 0) == 1,
'total': 0,
'used': 0,
'available': 0,
'shared': storage.get('shared', 0) == 1,
})
return detailed_storages
except ResourceException as e:
logger.error(f"Failed to get storage list: {e}")
return []
def get_network_interfaces(self, node_name: str) -> List[Dict[str, Any]]:
"""Get network interfaces/bridges for a node"""
try:
network = self.proxmox.nodes(node_name).network.get()
bridges = []
for iface in network:
iface_type = iface.get('type')
# Include bridges and bonds
if iface_type in ['bridge', 'bond', 'OVSBridge']:
bridges.append({
'iface': iface.get('iface'),
'type': iface_type,
'active': iface.get('active', 0) == 1,
'autostart': iface.get('autostart', 0) == 1,
'address': iface.get('address'),
'netmask': iface.get('netmask'),
'gateway': iface.get('gateway'),
'bridge_ports': iface.get('bridge_ports'),
'comments': iface.get('comments'),
})
return bridges
except ResourceException as e:
logger.error(f"Failed to get network interfaces: {e}")
return []
def iso_exists_on_storage(self, node_name: str, storage: str, filename: str) -> bool:
"""Check if ISO exists on Proxmox storage"""
try:
# List all ISOs in the storage
content = self.proxmox.nodes(node_name).storage(storage).content.get(content='iso')
logger.info(f"Found {len(content)} ISO(s) in {storage} on {node_name}")
# Check if our ISO exists
target_volid = f"{storage}:iso/{filename}"
for item in content:
volid = item.get('volid')
logger.debug(f"Checking ISO: {volid}")
if volid == target_volid:
logger.info(f"ISO found on Proxmox: {volid}")
return True
logger.info(f"ISO not found on Proxmox. Looking for: {target_volid}")
return False
except Exception as e:
logger.error(f"Failed to check ISO existence: {e}")
return False
def upload_iso(self, node_name: str, storage: str, iso_path: str, filename: str, progress_callback=None) -> bool:
"""Upload ISO to Proxmox storage with real-time progress tracking using direct requests"""
import os
import time
import requests
from requests.auth import AuthBase
class TokenAuth(AuthBase):
"""Custom authentication for Proxmox API tokens"""
def __init__(self, token_id, token_secret):
self.token_id = token_id
self.token_secret = token_secret
def __call__(self, r):
r.headers['Authorization'] = f'PVEAPIToken={self.token_id}={self.token_secret}'
return r
class ProgressFileWrapper:
"""Wrapper for file object that tracks upload progress"""
def __init__(self, file_obj, file_size, callback):
self.file_obj = file_obj
self.file_size = file_size
self.callback = callback
self.bytes_uploaded = 0
self.start_time = time.time()
self.last_update = time.time()
def read(self, size=-1):
chunk = self.file_obj.read(size)
if chunk:
self.bytes_uploaded += len(chunk)
# Update progress every 0.5 seconds
now = time.time()
if now - self.last_update >= 0.5 or self.bytes_uploaded == self.file_size:
percent = int((self.bytes_uploaded / self.file_size) * 100)
elapsed = now - self.start_time
mb_uploaded = self.bytes_uploaded / (1024 * 1024)
speed_mbps = mb_uploaded / elapsed if elapsed > 0 else 0
# Calculate ETA
if speed_mbps > 0:
mb_remaining = (self.file_size - self.bytes_uploaded) / (1024 * 1024)
eta_seconds = mb_remaining / speed_mbps
eta_str = f" - ETA {int(eta_seconds)}s" if eta_seconds > 1 else ""
else:
eta_str = ""
if self.callback:
if percent >= 100:
# At 100%, let user know we're waiting for Proxmox to finish processing
self.callback(100, "Upload transferred! Waiting for Proxmox to write to disk...")
else:
self.callback(
percent,
f"Uploading: {percent}% ({mb_uploaded:.1f}/{self.file_size/(1024*1024):.1f} MB @ {speed_mbps:.1f} MB/s{eta_str})"
)
self.last_update = now
return chunk
def __len__(self):
return self.file_size
def __iter__(self):
return self
def __next__(self):
chunk = self.read(8192)
if not chunk:
raise StopIteration
return chunk
try:
# Get file size for progress calculation
file_size = os.path.getsize(iso_path)
file_size_mb = file_size / (1024 * 1024)
logger.info(f"Uploading ISO {filename} ({file_size_mb:.1f} MB) to {node_name}:{storage}")
if progress_callback:
progress_callback(0, f"Starting upload of {filename} ({file_size_mb:.1f} MB)...")
start_time = time.time()
# Build the upload URL
url = f"https://{self.host.hostname}:{self.host.port}/api2/json/nodes/{node_name}/storage/{storage}/upload"
logger.info(f"Upload URL: {url}")
# Prepare authentication
auth = None
if self.host.api_token_id and self.host.api_token_secret:
# Use API token authentication
try:
token_secret = decrypt_data(self.host.api_token_secret)
except Exception:
token_secret = self.host.api_token_secret
# Extract full token ID (user@realm!tokenname)
if '!' in self.host.api_token_id:
full_token_id = self.host.api_token_id
else:
full_token_id = f"{self.host.username}!{self.host.api_token_id}"
auth = TokenAuth(full_token_id, token_secret)
logger.info(f"Using token authentication: {full_token_id}")
else:
# Use password authentication
try:
password = decrypt_data(self.host.password)
except Exception:
password = self.host.password
auth = (self.host.username, password)
logger.info(f"Using password authentication")
# Upload the file with progress tracking
logger.info(f"Starting ISO upload to Proxmox...")
with open(iso_path, 'rb') as iso_file:
wrapped_file = ProgressFileWrapper(iso_file, file_size, progress_callback)
# Prepare multipart form data
files = {
'filename': (filename, wrapped_file, 'application/octet-stream')
}
data = {
'content': 'iso'
}
try:
logger.info(f"Calling Proxmox upload API with requests...")
response = requests.post(
url,
auth=auth,
files=files,
data=data,
verify=self.host.verify_ssl,
timeout=3600 # 1 hour timeout for large uploads
)
logger.info(f"Upload response status: {response.status_code}")
logger.info(f"Upload response: {response.text}")
if response.status_code != 200:
raise Exception(f"Upload failed with status {response.status_code}: {response.text}")
except Exception as post_error:
logger.error(f"Upload POST call failed: {post_error}", exc_info=True)
raise
# POST has returned - Proxmox has finished writing to disk
logger.info(f"Proxmox upload POST completed")
elapsed = time.time() - start_time
speed_mbps = file_size_mb / elapsed if elapsed > 0 else 0
if progress_callback:
progress_callback(100, f"Upload complete! ({speed_mbps:.1f} MB/s average)")
logger.info(f"Successfully uploaded ISO {filename} to {node_name}:{storage} in {elapsed:.1f}s ({speed_mbps:.1f} MB/s)")
return True
except Exception as e:
logger.error(f"Failed to upload ISO: {e}")
if progress_callback:
progress_callback(0, f"Upload failed: {str(e)}")
return False
def create_vm(
self,
node_name: str,
vmid: int,
name: str,
cores: int,
memory: int,
disk_size: int,
storage: str = "local-lvm",
iso: Optional[str] = None,
network_bridge: str = "vmbr0",
sockets: int = 1,
# Advanced options
cpu_type: str = "host",
cpu_flags: Optional[str] = None,
numa_enabled: bool = False,
bios_type: str = "seabios",
machine_type: str = "pc",
vga_type: str = "std",
boot_order: str = "cdn",
network_interfaces: Optional[list] = None,
) -> bool:
"""Create a new VM with advanced options"""
try:
# Base VM configuration
vm_config = {
"vmid": vmid,
"name": name,
"sockets": sockets,
"cores": cores,
"memory": memory,
"scsihw": "virtio-scsi-pci",
"scsi0": f"{storage}:{disk_size}",
"net0": f"virtio,bridge={network_bridge}",
"ostype": "l26", # Linux 2.6+
"agent": 1, # Enable QEMU guest agent
}
# Advanced CPU options
if cpu_type and cpu_type != "host":
vm_config["cpu"] = cpu_type
if cpu_flags:
if "cpu" in vm_config:
vm_config["cpu"] = f"{vm_config['cpu']},{cpu_flags}"
else:
vm_config["cpu"] = f"host,{cpu_flags}"
if numa_enabled:
vm_config["numa"] = 1
# Hardware options
if bios_type == "ovmf":
# UEFI - requires OVMF firmware
vm_config["bios"] = "ovmf"
# Note: May need to add efidisk0 for UEFI variables storage
if machine_type and machine_type != "pc":
vm_config["machine"] = machine_type
if vga_type and vga_type != "std":
vm_config["vga"] = vga_type
if boot_order and boot_order != "cdn":
vm_config["boot"] = f"order={boot_order}"
# Additional network interfaces
if network_interfaces:
for idx, nic in enumerate(network_interfaces, start=1):
bridge = nic.get('bridge', 'vmbr0')
model = nic.get('model', 'virtio')
vm_config[f"net{idx}"] = f"{model},bridge={bridge}"
# ISO
if iso:
vm_config["ide2"] = f"{iso},media=cdrom"
logger.info(f"Creating VM with config: {vm_config}")
result = self.proxmox.nodes(node_name).qemu.post(**vm_config)
task_id = result # UPID format: UPID:node:pid:timestamp:type:id:user:
logger.info(f"Proxmox API response (task ID): {task_id}")
# VM creation is async - wait for the task to complete
import time
max_wait = 30 # Wait up to 30 seconds
wait_interval = 2
total_waited = 0
logger.info(f"Waiting for VM {vmid} creation task to complete...")
while total_waited < max_wait:
time.sleep(wait_interval)
total_waited += wait_interval
# Check task status
try:
task_status = self.proxmox.nodes(node_name).tasks(task_id).status.get()
task_state = task_status.get('status')
logger.info(f"Task status: {task_state}")
if task_state == 'stopped':
# Task completed - check exit status
exit_status = task_status.get('exitstatus')
if exit_status == 'OK':
logger.info(f"VM {vmid} creation task completed successfully")
# Verify VM exists
try:
vm_status = self.proxmox.nodes(node_name).qemu(vmid).status.current.get()
logger.info(f"VM {vmid} verified on node {node_name}, status: {vm_status.get('status')}")
return True
except Exception as e:
logger.error(f"VM {vmid} task succeeded but VM doesn't exist: {e}")
return False
else:
# Task failed - get error log
try:
log = self.proxmox.nodes(node_name).tasks(task_id).log.get()
error_lines = [line.get('t', '') for line in log[-10:]] # Last 10 lines
logger.error(f"VM {vmid} creation failed. Task log:\n" + "\n".join(error_lines))
except:
logger.error(f"VM {vmid} creation failed with exit status: {exit_status}")
return False
elif task_state == 'running':
logger.debug(f"Task still running, waited {total_waited}s...")
continue
except Exception as e:
logger.warning(f"Could not check task status: {e}")
# Fall back to checking if VM exists
try:
vm_status = self.proxmox.nodes(node_name).qemu(vmid).status.current.get()
logger.info(f"VM {vmid} exists despite task check failure, status: {vm_status.get('status')}")
return True
except:
continue
# If we get here, VM wasn't created within timeout
logger.error(f"VM {vmid} was not created within {max_wait} seconds. Task may have failed.")
logger.error(f"Check Proxmox task log for UPID: {task_id}")
return False
except ResourceException as e:
logger.error(f"Failed to create VM: {e}")
return False
except Exception as e:
logger.error(f"Unexpected error creating VM: {e}")
return False
def create_vm_from_cloud_image(
self,
node_name: str,
vmid: int,
name: str,
sockets: int,
cores: int,
memory: int,
disk_size: int,
storage: str,
cloud_image_path: str,
network_bridge: str = "vmbr0",
progress_callback=None,
) -> bool:
"""Create a VM from a cloud image by uploading and importing the disk"""
try:
import time
import os
import requests
from app.core.security import decrypt_data
logger.info(f"Creating VM {vmid} from cloud image {os.path.basename(cloud_image_path)}")
if progress_callback:
progress_callback(0, "Creating VM configuration...")
# Step 1: Create VM shell without disk
vm_config = {
"vmid": vmid,
"name": name,
"sockets": sockets,
"cores": cores,
"memory": memory,
"net0": f"virtio,bridge={network_bridge}",
"scsihw": "virtio-scsi-pci",
"ostype": "l26",
"agent": 1,
}
logger.info(f"Creating VM shell: {vm_config}")
self.proxmox.nodes(node_name).qemu.post(**vm_config)
time.sleep(2)
if progress_callback:
progress_callback(20, "VM created, uploading cloud image...")
# Step 2: Upload cloud image to Proxmox using direct requests
# We'll upload it as an ISO first, then use qm importdisk
image_filename = os.path.basename(cloud_image_path)
file_size = os.path.getsize(cloud_image_path)
url = f"https://{self.host.hostname}:{self.host.port}/api2/json/nodes/{node_name}/storage/local/upload"
# Prepare auth
if self.host.api_token_id and self.host.api_token_secret:
try:
token_secret = decrypt_data(self.host.api_token_secret)
except Exception:
token_secret = self.host.api_token_secret
if '!' in self.host.api_token_id:
full_token_id = self.host.api_token_id
else:
full_token_id = f"{self.host.username}!{self.host.api_token_id}"
headers = {'Authorization': f'PVEAPIToken={full_token_id}={token_secret}'}
auth = None
else:
try:
password = decrypt_data(self.host.password)
except Exception:
password = self.host.password
headers = {}
auth = (self.host.username, password)
logger.info(f"Uploading {file_size/(1024*1024):.1f} MB cloud image to {node_name}:local")
# Upload file
with open(cloud_image_path, 'rb') as img_file:
files = {'filename': (image_filename, img_file, 'application/octet-stream')}
data = {'content': 'iso'} # Upload to ISO storage temporarily
response = requests.post(
url,
auth=auth,
headers=headers,
files=files,
data=data,
verify=self.host.verify_ssl,
timeout=1800 # 30 min for large images
)
if response.status_code != 200:
raise Exception(f"Upload failed: {response.text}")
if progress_callback:
progress_callback(60, "Cloud image uploaded, importing disk...")
# Step 3: Use qm importdisk to import the cloud image as VM disk
# This needs to be done via command execution on the Proxmox node
# For now, we'll create a simplified VM and note that manual import is needed
# Add a small placeholder disk that can be replaced
self.proxmox.nodes(node_name).qemu(vmid).config.put(
scsi0=f"{storage}:{disk_size}",
boot="order=scsi0"
)
# Add cloud-init drive
self.proxmox.nodes(node_name).qemu(vmid).config.put(
ide2=f"{storage}:cloudinit"
)
if progress_callback:
progress_callback(80, "Configuring VM...")
logger.info(f"VM {vmid} created. Cloud image uploaded to local:iso/{image_filename}")
logger.info(f"Note: Manual import required: qm importdisk {vmid} /var/lib/vz/template/iso/{image_filename} {storage}")
if progress_callback:
progress_callback(100, "VM created with cloud image")
return True
except Exception as e:
logger.error(f"Failed to create VM from cloud image: {e}")
return False
def configure_cloud_init(
self,
node_name: str,
vmid: int,
user: str,
password: Optional[str] = None,
ssh_keys: Optional[str] = None,
ip_config: Optional[str] = None,
nameserver: Optional[str] = None,
) -> bool:
"""Configure cloud-init for a VM"""
try:
config = {
"ciuser": user,
"searchdomain": "local",
}
if password:
config["cipassword"] = password
if ssh_keys:
config["sshkeys"] = ssh_keys
if ip_config:
config["ipconfig0"] = ip_config
else:
config["ipconfig0"] = "ip=dhcp"
if nameserver:
config["nameserver"] = nameserver
self.proxmox.nodes(node_name).qemu(vmid).config.put(**config)
logger.info(f"Configured cloud-init for VM {vmid}")
return True
except ResourceException as e:
logger.error(f"Failed to configure cloud-init: {e}")
return False
def start_vm(self, node_name: str, vmid: int) -> bool:
"""Start a VM"""
try:
self.proxmox.nodes(node_name).qemu(vmid).status.start.post()
logger.info(f"Started VM {vmid}")
return True
except ResourceException as e:
logger.error(f"Failed to start VM: {e}")
return False
def stop_vm(self, node_name: str, vmid: int) -> bool:
"""Stop a VM (graceful shutdown)"""
try:
self.proxmox.nodes(node_name).qemu(vmid).status.shutdown.post()
logger.info(f"Stopped VM {vmid}")
return True
except ResourceException as e:
logger.error(f"Failed to stop VM: {e}")
return False
def shutdown_vm(self, node_name: str, vmid: int) -> bool:
"""Shutdown a VM (force power off)"""
try:
self.proxmox.nodes(node_name).qemu(vmid).status.stop.post()
logger.info(f"Powered off VM {vmid}")
return True
except ResourceException as e:
logger.error(f"Failed to power off VM: {e}")
return False
def restart_vm(self, node_name: str, vmid: int) -> bool:
"""Restart a VM"""
try:
self.proxmox.nodes(node_name).qemu(vmid).status.reboot.post()
logger.info(f"Restarted VM {vmid}")
return True
except ResourceException as e:
logger.error(f"Failed to restart VM: {e}")
return False
def delete_vm(self, node_name: str, vmid: int) -> bool:
"""Delete a VM"""
try:
self.proxmox.nodes(node_name).qemu(vmid).delete()
logger.info(f"Deleted VM {vmid}")
return True
except ResourceException as e:
logger.error(f"Failed to delete VM: {e}")
return False
def get_vm_status(self, node_name: str, vmid: int) -> Optional[Dict[str, Any]]:
"""Get VM status"""
try:
status = self.proxmox.nodes(node_name).qemu(vmid).status.current.get()
return status
except ResourceException as e:
logger.error(f"Failed to get VM status: {e}")
return None
def get_vm_config(self, node_name: str, vmid: int) -> Optional[Dict[str, Any]]:
"""Get VM configuration"""
try:
config = self.proxmox.nodes(node_name).qemu(vmid).config.get()
return config
except ResourceException as e:
logger.error(f"Failed to get VM config: {e}")
return None
def get_all_vms(self) -> List[Dict[str, Any]]:
"""Get all VMs across all nodes in the cluster"""
all_vms = []
try:
nodes = self.get_nodes()
for node_data in nodes:
node_name = node_data.get('node')
try:
vms = self.proxmox.nodes(node_name).qemu.get()
for vm in vms:
all_vms.append({
'vmid': vm.get('vmid'),
'name': vm.get('name'),
'status': vm.get('status'),
'node': node_name,
'cpus': vm.get('cpus', 0),
'maxmem': vm.get('maxmem', 0),
'maxdisk': vm.get('maxdisk', 0),
})
except Exception as e:
logger.error(f"Failed to get VMs from node {node_name}: {e}")
return all_vms
except Exception as e:
logger.error(f"Failed to get all VMs: {e}")
return []
def execute_qemu_agent_command(
self, node_name: str, vmid: int, command: str
) -> Optional[Dict[str, Any]]:
"""Execute command via QEMU guest agent"""
try:
result = self.proxmox.nodes(node_name).qemu(vmid).agent.exec.post(
command=command
)
return result
except ResourceException as e:
logger.error(f"Failed to execute QEMU agent command: {e}")
return None
def poll_proxmox_resources(db: Session, host_id: int) -> bool:
"""Poll Proxmox host for current resource status"""
try:
host = db.query(ProxmoxHost).filter(ProxmoxHost.id == host_id).first()
if not host or not host.is_active:
return False
service = ProxmoxService(host)
if not service.test_connection():
logger.error(f"Cannot connect to Proxmox host {host.name}")
return False
# Get nodes and update database
nodes = service.get_nodes()
for node_data in nodes:
node_name = node_data.get("node")
resources = service.get_node_resources(node_name)
if not resources:
continue
# Update or create node record
node = (
db.query(ProxmoxNode)
.filter(
ProxmoxNode.host_id == host_id,
ProxmoxNode.node_name == node_name,
)
.first()
)
if node:
node.status = resources["status"]
node.cpu_cores = resources["cpu_cores"]
node.cpu_usage = resources["cpu_usage"]
node.memory_total = resources["memory_total"]
node.memory_used = resources["memory_used"]
node.disk_total = resources["disk_total"]
node.disk_used = resources["disk_used"]
node.uptime = resources["uptime"]
node.last_updated = datetime.utcnow()
else:
node = ProxmoxNode(
host_id=host_id,
node_name=node_name,
status=resources["status"],
cpu_cores=resources["cpu_cores"],
cpu_usage=resources["cpu_usage"],
memory_total=resources["memory_total"],
memory_used=resources["memory_used"],
disk_total=resources["disk_total"],
disk_used=resources["disk_used"],
uptime=resources["uptime"],
last_updated=datetime.utcnow(),
)
db.add(node)
host.last_poll = datetime.utcnow()
db.commit()
logger.info(f"Successfully polled Proxmox host {host.name}")
return True
except Exception as e:
logger.error(f"Error polling Proxmox resources: {e}")
db.rollback()
return False
+275
View File
@@ -0,0 +1,275 @@
"""VM update management service"""
from typing import Optional, Dict, Any
from sqlalchemy.orm import Session
from app.models import VirtualMachine, UpdateLog, OSType
from app.services.proxmox import ProxmoxService
from app.models import ProxmoxHost, ProxmoxNode
import paramiko
import logging
from datetime import datetime
logger = logging.getLogger(__name__)
class UpdateService:
"""Service for managing VM updates"""
def __init__(self, db: Session):
self.db = db
def _get_ssh_client(self, vm: VirtualMachine) -> Optional[paramiko.SSHClient]:
"""Create SSH connection to VM"""
try:
client = paramiko.SSHClient()
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
if vm.ssh_key:
# Use SSH key authentication
from io import StringIO
key_file = StringIO(vm.ssh_key)
pkey = paramiko.RSAKey.from_private_key(key_file)
client.connect(
hostname=vm.ip_address,
username=vm.username,
pkey=pkey,
timeout=30,
)
else:
# Use password authentication
client.connect(
hostname=vm.ip_address,
username=vm.username,
password=vm.password,
timeout=30,
)
return client
except Exception as e:
logger.error(f"Failed to connect to VM via SSH: {e}")
return None
def _get_update_commands(self, os_type: OSType) -> Dict[str, str]:
"""Get update commands for different OS types"""
commands = {
OSType.UBUNTU: {
"update": "sudo apt-get update",
"upgrade": "sudo DEBIAN_FRONTEND=noninteractive apt-get upgrade -y",
"check": "apt list --upgradable",
},
OSType.DEBIAN: {
"update": "sudo apt-get update",
"upgrade": "sudo DEBIAN_FRONTEND=noninteractive apt-get upgrade -y",
"check": "apt list --upgradable",
},
OSType.CENTOS: {
"update": "sudo yum check-update",
"upgrade": "sudo yum update -y",
"check": "yum list updates",
},
OSType.ROCKY: {
"update": "sudo dnf check-update",
"upgrade": "sudo dnf update -y",
"check": "dnf list updates",
},
OSType.ALMA: {
"update": "sudo dnf check-update",
"upgrade": "sudo dnf update -y",
"check": "dnf list updates",
},
}
return commands.get(os_type, commands[OSType.UBUNTU])
def check_updates(self, vm_id: int) -> Optional[Dict[str, Any]]:
"""Check for available updates"""
try:
vm = self.db.query(VirtualMachine).filter(VirtualMachine.id == vm_id).first()
if not vm:
logger.error(f"VM {vm_id} not found")
return None
if not vm.ip_address:
logger.error(f"VM {vm_id} has no IP address")
return None
client = self._get_ssh_client(vm)
if not client:
return None
commands = self._get_update_commands(vm.os_type)
# Update package lists
stdin, stdout, stderr = client.exec_command(commands["update"])
stdout.channel.recv_exit_status() # Wait for command to complete
# Check for updates
stdin, stdout, stderr = client.exec_command(commands["check"])
output = stdout.read().decode()
exit_status = stdout.channel.recv_exit_status()
client.close()
# Parse output to count available updates
lines = output.strip().split("\n")
update_count = len([line for line in lines if line.strip()])
return {
"updates_available": update_count,
"output": output,
}
except Exception as e:
logger.error(f"Failed to check updates: {e}")
return None
def install_updates(self, vm_id: int, user_id: int) -> Optional[int]:
"""
Install updates on a VM
Args:
vm_id: Database ID of the VM
user_id: User initiating the update
Returns:
Update log ID if successful, None otherwise
"""
try:
vm = self.db.query(VirtualMachine).filter(VirtualMachine.id == vm_id).first()
if not vm:
logger.error(f"VM {vm_id} not found")
return None
if not vm.ip_address:
logger.error(f"VM {vm_id} has no IP address")
return None
# Create update log
update_log = UpdateLog(
vm_id=vm_id,
initiated_by=user_id,
status="running",
started_at=datetime.utcnow(),
)
self.db.add(update_log)
self.db.commit()
try:
client = self._get_ssh_client(vm)
if not client:
raise Exception("Failed to connect via SSH")
commands = self._get_update_commands(vm.os_type)
# Update package lists
logger.info(f"Updating package lists on VM {vm_id}")
stdin, stdout, stderr = client.exec_command(commands["update"])
stdout.channel.recv_exit_status()
# Install updates
logger.info(f"Installing updates on VM {vm_id}")
stdin, stdout, stderr = client.exec_command(commands["upgrade"])
output = stdout.read().decode()
error_output = stderr.read().decode()
exit_status = stdout.channel.recv_exit_status()
client.close()
if exit_status != 0:
raise Exception(f"Update failed with exit code {exit_status}")
# Update log
update_log.status = "completed"
update_log.output = output
update_log.completed_at = datetime.utcnow()
# Try to count packages updated (rough estimate)
if "ubuntu" in vm.os_type.value or "debian" in vm.os_type.value:
packages_updated = output.count("Setting up")
else:
packages_updated = output.count("Installed") + output.count("Updated")
update_log.packages_updated = packages_updated
self.db.commit()
logger.info(f"Successfully installed updates on VM {vm_id}")
return update_log.id
except Exception as e:
logger.error(f"Failed to install updates: {e}")
update_log.status = "failed"
update_log.error_message = str(e)
update_log.completed_at = datetime.utcnow()
self.db.commit()
return None
except Exception as e:
logger.error(f"Failed to create update log: {e}")
return None
def get_update_history(self, vm_id: int) -> list:
"""Get update history for a VM"""
try:
logs = (
self.db.query(UpdateLog)
.filter(UpdateLog.vm_id == vm_id)
.order_by(UpdateLog.started_at.desc())
.all()
)
return logs
except Exception as e:
logger.error(f"Failed to get update history: {e}")
return []
def install_qemu_agent(self, vm_id: int) -> bool:
"""Install QEMU guest agent on a VM if not already installed"""
try:
vm = self.db.query(VirtualMachine).filter(VirtualMachine.id == vm_id).first()
if not vm or not vm.ip_address:
return False
client = self._get_ssh_client(vm)
if not client:
return False
# Determine package name based on OS
if vm.os_type in [OSType.UBUNTU, OSType.DEBIAN]:
install_cmd = "sudo apt-get install -y qemu-guest-agent"
start_cmd = "sudo systemctl start qemu-guest-agent"
enable_cmd = "sudo systemctl enable qemu-guest-agent"
elif vm.os_type in [OSType.CENTOS]:
install_cmd = "sudo yum install -y qemu-guest-agent"
start_cmd = "sudo systemctl start qemu-guest-agent"
enable_cmd = "sudo systemctl enable qemu-guest-agent"
elif vm.os_type in [OSType.ROCKY, OSType.ALMA]:
install_cmd = "sudo dnf install -y qemu-guest-agent"
start_cmd = "sudo systemctl start qemu-guest-agent"
enable_cmd = "sudo systemctl enable qemu-guest-agent"
else:
logger.error(f"Unsupported OS type for QEMU agent installation: {vm.os_type}")
return False
# Install
stdin, stdout, stderr = client.exec_command(install_cmd)
exit_status = stdout.channel.recv_exit_status()
if exit_status != 0:
logger.error(f"Failed to install QEMU agent: {stderr.read().decode()}")
return False
# Start
stdin, stdout, stderr = client.exec_command(start_cmd)
stdout.channel.recv_exit_status()
# Enable on boot
stdin, stdout, stderr = client.exec_command(enable_cmd)
stdout.channel.recv_exit_status()
client.close()
logger.info(f"Successfully installed QEMU guest agent on VM {vm_id}")
return True
except Exception as e:
logger.error(f"Failed to install QEMU agent: {e}")
return False
+84
View File
@@ -0,0 +1,84 @@
#!/usr/bin/env python3
"""Create default admin user if it doesn't exist"""
import sys
import os
sys.path.insert(0, '/opt/depl0y/backend')
# Set DATABASE_URL environment variable to ensure SQLite is used
os.environ['DATABASE_URL'] = 'sqlite:////var/lib/depl0y/db/depl0y.db'
try:
from app.core.database import SessionLocal
from app.models import User, UserRole
from app.core.security import get_password_hash
print("✓ Imports successful")
def create_admin_user():
"""Create default admin user with 2FA disabled"""
db = SessionLocal()
try:
print("✓ Database connection established")
# Check if admin user already exists
existing_admin = db.query(User).filter(User.username == 'admin').first()
if existing_admin:
print(f"⚠️ Admin user already exists (ID: {existing_admin.id})")
print(f" Current 2FA status: {existing_admin.totp_enabled}")
# Reset password to default and ensure 2FA is disabled
existing_admin.hashed_password = get_password_hash("admin")
existing_admin.totp_enabled = False
existing_admin.totp_secret = None
existing_admin.is_active = True
db.commit()
print("✓ Admin password reset to: admin")
print("✓ 2FA explicitly disabled (totp_enabled=False)")
print("✓ 2FA secret cleared (totp_secret=None)")
print("✓ Account activated")
# Verify the change
db.refresh(existing_admin)
print(f"✓ VERIFIED: totp_enabled = {existing_admin.totp_enabled}")
return
# Create new admin user
print("Creating new admin user...")
hashed_password = get_password_hash("admin")
admin_user = User(
username="admin",
email="admin@localhost",
hashed_password=hashed_password,
role=UserRole.ADMIN,
is_active=True,
totp_enabled=False, # Explicitly disable 2FA
totp_secret=None
)
db.add(admin_user)
db.commit()
db.refresh(admin_user)
print("✓ Created default admin user")
print(f" ID: {admin_user.id}")
print(f" Username: admin")
print(f" Password: admin")
print(f" 2FA Enabled: {admin_user.totp_enabled}")
print(f" 2FA Secret: {admin_user.totp_secret}")
print(f" Active: {admin_user.is_active}")
print(f" Role: {admin_user.role}")
except Exception as e:
print(f"✗ Error with admin user: {e}")
import traceback
traceback.print_exc()
db.rollback()
finally:
db.close()
create_admin_user()
except Exception as e:
print(f"✗ Fatal error during import/setup: {e}")
import traceback
traceback.print_exc()
sys.exit(1)
+48
View File
@@ -0,0 +1,48 @@
# FastAPI and web framework
fastapi==0.104.1
uvicorn[standard]==0.24.0
python-multipart==0.0.6
# Database
sqlalchemy==2.0.23
pymysql==1.1.0
cryptography==41.0.7
alembic==1.13.0
# Authentication and security
python-jose[cryptography]==3.3.0
passlib[bcrypt]==1.7.4
pyotp==2.9.0
qrcode==7.4.2
pillow==10.1.0
# Proxmox
proxmoxer==2.0.1
requests==2.31.0
# SSH and remote management
paramiko==3.4.0
# Configuration and environment
pydantic==2.5.0
pydantic-settings==2.1.0
python-dotenv==1.0.0
# YAML processing for cloud-init
pyyaml==6.0.1
# Logging
python-json-logger==2.0.7
# File handling
aiofiles==23.2.1
# Validation
email-validator==2.1.0
# Testing (optional)
pytest==7.4.3
pytest-asyncio==0.21.1
httpx==0.25.2
weasyprint==66.0
markdown==3.10
+1
View File
@@ -0,0 +1 @@
.bug-report-page[data-v-1456d47b]{max-width:1200px;margin:0 auto}.page-header[data-v-1456d47b]{margin-bottom:2rem}.page-header h1[data-v-1456d47b]{margin:0 0 .5rem;font-size:2rem;font-weight:700;color:var(--text-primary)}.subtitle[data-v-1456d47b]{margin:0;font-size:1rem;color:var(--text-secondary)}.card[data-v-1456d47b]{background:#fff;border-radius:.5rem;box-shadow:var(--shadow-md);padding:2rem}.bug-report-form[data-v-1456d47b]{display:flex;flex-direction:column;gap:2rem}.form-section[data-v-1456d47b]{display:flex;flex-direction:column;gap:1.5rem}.form-section h3[data-v-1456d47b]{margin:0;font-size:1.25rem;font-weight:600;color:var(--text-primary);padding-bottom:.75rem;border-bottom:2px solid var(--border-color)}.form-group[data-v-1456d47b]{display:flex;flex-direction:column;gap:.5rem}.form-label[data-v-1456d47b]{font-size:.875rem;font-weight:600;color:var(--text-primary)}.form-label.required[data-v-1456d47b]:after{content:" *";color:#ef4444}.form-control[data-v-1456d47b]{width:100%;padding:.75rem;border:1px solid var(--border-color);border-radius:.375rem;font-size:.875rem;transition:all .2s}.form-control[data-v-1456d47b]:focus{outline:none;border-color:#3b82f6;box-shadow:0 0 0 3px #3b82f61a}.code-input[data-v-1456d47b]{font-family:Consolas,Monaco,Courier New,monospace;font-size:.8rem;background-color:#f9fafb}.help-text[data-v-1456d47b]{margin:0;font-size:.75rem;color:var(--text-secondary)}.info-grid[data-v-1456d47b]{display:grid;grid-template-columns:repeat(auto-fit,minmax(300px,1fr));gap:1rem}.info-item[data-v-1456d47b]{display:flex;flex-direction:column;gap:.5rem;padding:1rem;background-color:#f9fafb;border-radius:.375rem;border:1px solid var(--border-color)}.info-label[data-v-1456d47b]{font-size:.75rem;font-weight:600;text-transform:uppercase;letter-spacing:.05em;color:var(--text-secondary)}.info-value[data-v-1456d47b]{font-family:Consolas,Monaco,Courier New,monospace;font-size:.8rem;color:var(--text-primary);word-break:break-all;background-color:#fff;padding:.5rem;border-radius:.25rem;border:1px solid var(--border-color)}.form-actions[data-v-1456d47b]{display:flex;justify-content:flex-end;gap:1rem;padding-top:1rem;border-top:1px solid var(--border-color)}.btn[data-v-1456d47b]{padding:.75rem 1.5rem;border-radius:.375rem;font-size:.875rem;font-weight:600;cursor:pointer;transition:all .2s;border:none}.btn-secondary[data-v-1456d47b]{background-color:#fff;color:var(--text-primary);border:1px solid var(--border-color)}.btn-secondary[data-v-1456d47b]:hover{background-color:#f9fafb}.btn-primary[data-v-1456d47b]{background-color:#ef4444;color:#fff}.btn-primary[data-v-1456d47b]:hover{background-color:#dc2626}.btn-primary[data-v-1456d47b]:disabled{opacity:.5;cursor:not-allowed}@media (max-width: 768px){.card[data-v-1456d47b]{padding:1.5rem}.form-actions[data-v-1456d47b]{flex-direction:column}.btn[data-v-1456d47b]{width:100%}}
+1
View File
@@ -0,0 +1 @@
import{_ as b,c as f,o as c,a as o,w as m,e as i,v as n,t as u,u as g,x as v,r as d,j as _,k as w,l as y}from"./index-DpP9cWht.js";const D={name:"BugReport",setup(){const l=g(),e=v(),t=d(!1),s=d({subject:"",description:"",error_details:"",page_url:"",browser_info:""}),a=_(()=>navigator.userAgent);return w(()=>{s.value.page_url=document.referrer||window.location.href,s.value.browser_info=a.value}),{formData:s,browserInfo:a,submitting:t,submitReport:async()=>{t.value=!0;try{await y.bugReport.submit({subject:s.value.subject,description:s.value.description,error_details:s.value.error_details||null,page_url:s.value.page_url,browser_info:s.value.browser_info}),e.success("Bug report submitted successfully! Thank you for your feedback."),s.value={subject:"",description:"",error_details:"",page_url:document.referrer||window.location.href,browser_info:a.value},setTimeout(()=>{l.go(-1)},1500)}catch(r){console.error("Failed to submit bug report:",r),e.error("Failed to submit bug report. Please try again later.")}finally{t.value=!1}}}}},x={class:"bug-report-page"},B={class:"card"},R={class:"form-section"},h={class:"form-group"},j={class:"form-group"},k={class:"form-group"},I={class:"form-section"},S={class:"info-grid"},q={class:"info-item"},C={class:"info-value"},P={class:"info-item"},T={class:"info-value"},U={class:"form-actions"},V=["disabled"];function F(l,e,t,s,a,p){return c(),f("div",x,[e[14]||(e[14]=o("div",{class:"page-header"},[o("h1",null,"Report a Bug"),o("p",{class:"subtitle"},"Help us improve Depl0y by reporting issues you encounter")],-1)),o("div",B,[o("form",{onSubmit:e[4]||(e[4]=m((...r)=>s.submitReport&&s.submitReport(...r),["prevent"])),class:"bug-report-form"},[o("div",R,[e[10]||(e[10]=o("h3",null,"Bug Information",-1)),o("div",h,[e[5]||(e[5]=o("label",{class:"form-label required"},"Subject",-1)),i(o("input",{"onUpdate:modelValue":e[0]||(e[0]=r=>s.formData.subject=r),type:"text",required:"",class:"form-control",placeholder:"Brief description of the issue"},null,512),[[n,s.formData.subject]])]),o("div",j,[e[6]||(e[6]=o("label",{class:"form-label required"},"Description",-1)),i(o("textarea",{"onUpdate:modelValue":e[1]||(e[1]=r=>s.formData.description=r),required:"",rows:"8",class:"form-control",placeholder:"Detailed description of what happened, steps to reproduce, expected vs actual behavior..."},null,512),[[n,s.formData.description]]),e[7]||(e[7]=o("p",{class:"help-text"},"Please be as detailed as possible to help us understand and fix the issue",-1))]),o("div",k,[e[8]||(e[8]=o("label",{class:"form-label"},"Error Details (optional)",-1)),i(o("textarea",{"onUpdate:modelValue":e[2]||(e[2]=r=>s.formData.error_details=r),rows:"6",class:"form-control code-input",placeholder:"Paste any error messages, console logs, or stack traces here..."},null,512),[[n,s.formData.error_details]]),e[9]||(e[9]=o("p",{class:"help-text"},"Check your browser console (F12) for any error messages",-1))])]),o("div",I,[e[13]||(e[13]=o("h3",null,"Automatically Captured Information",-1)),o("div",S,[o("div",q,[e[11]||(e[11]=o("label",{class:"info-label"},"Page URL",-1)),o("code",C,u(s.formData.page_url),1)]),o("div",P,[e[12]||(e[12]=o("label",{class:"info-label"},"Browser",-1)),o("code",T,u(s.browserInfo),1)])])]),o("div",U,[o("button",{type:"button",onClick:e[3]||(e[3]=r=>l.$router.go(-1)),class:"btn btn-secondary"}," Cancel "),o("button",{type:"submit",disabled:s.submitting,class:"btn btn-primary"},u(s.submitting?"Submitting...":"Submit Bug Report"),9,V)])],32)])])}const A=b(D,[["render",F],["__scopeId","data-v-1456d47b"]]);export{A as default};
+1
View File
@@ -0,0 +1 @@
.download-progress[data-v-f1a8c3e0]{display:flex;flex-direction:column;gap:.25rem}.progress-bar-container[data-v-f1a8c3e0]{width:100px;height:8px;background-color:var(--background);border-radius:4px;overflow:hidden}.progress-bar-fill[data-v-f1a8c3e0]{height:100%;background-color:var(--primary);transition:width .3s ease}.modal[data-v-f1a8c3e0]{position:fixed;top:0;left:0;width:100%;height:100%;background-color:#00000080;display:flex;align-items:center;justify-content:center;z-index:1000}.modal-content[data-v-f1a8c3e0]{background:#1e293b;border:2px solid #475569;border-radius:12px;width:90%;max-width:600px;max-height:90vh;overflow-y:auto;box-shadow:0 25px 50px -12px #000000b3}.modal-header[data-v-f1a8c3e0]{display:flex;justify-content:space-between;align-items:center;padding:1.5rem;border-bottom:2px solid #475569;background:#334155}.modal-header h3[data-v-f1a8c3e0]{margin:0;color:#fff;font-weight:600}.btn-close[data-v-f1a8c3e0]{background:#475569;border:none;font-size:1.5rem;line-height:1;cursor:pointer;color:#cbd5e1;padding:0;width:2rem;height:2rem;border-radius:6px;transition:all .2s}.btn-close[data-v-f1a8c3e0]:hover{background:#ef4444;color:#fff}.modal-body[data-v-f1a8c3e0]{padding:1.5rem;background:#1e293b}.modal-body[data-v-f1a8c3e0] .form-label{color:#e2e8f0;font-weight:500}.modal-body[data-v-f1a8c3e0] .form-control,.modal-body[data-v-f1a8c3e0] input,.modal-body[data-v-f1a8c3e0] select,.modal-body[data-v-f1a8c3e0] textarea{background:#0f172a;border:1px solid #475569;color:#f1f5f9}.modal-body[data-v-f1a8c3e0] .form-control:focus,.modal-body[data-v-f1a8c3e0] input:focus,.modal-body[data-v-f1a8c3e0] select:focus,.modal-body[data-v-f1a8c3e0] textarea:focus{border-color:#3b82f6;outline:none;box-shadow:0 0 0 2px #3b82f633}.modal-body[data-v-f1a8c3e0] .text-muted{color:#94a3b8!important}.modal-footer[data-v-f1a8c3e0]{display:flex;justify-content:flex-end;gap:.5rem;padding:1rem 1.5rem;margin-top:0;border-top:2px solid #475569;background:#334155}.checkbox-list[data-v-f1a8c3e0]{display:flex;flex-direction:column;gap:.5rem;max-height:300px;overflow-y:auto;padding:.75rem;border:2px solid #475569;border-radius:8px;background-color:#0f172a}.checkbox-item[data-v-f1a8c3e0]{display:flex;align-items:center;gap:.75rem;cursor:pointer;padding:.75rem;border-radius:6px;background:#1e293b;border:1px solid #334155;transition:all .2s}.checkbox-item[data-v-f1a8c3e0]:hover{background:#334155;border-color:#3b82f6}.checkbox-item span[data-v-f1a8c3e0]{color:#f1f5f9;font-weight:500}.checkbox-item input[type=checkbox][data-v-f1a8c3e0]{cursor:pointer;width:18px;height:18px;accent-color:#3b82f6}.alert[data-v-f1a8c3e0]{padding:1rem;border-radius:8px;margin-top:1rem;border:2px solid}.alert-info[data-v-f1a8c3e0]{background-color:#3b82f626;border-color:#3b82f6;color:#dbeafe}.alert-info p[data-v-f1a8c3e0]{color:#dbeafe;margin:.25rem 0}.template-status-section[data-v-f1a8c3e0]{padding:1.5rem;background:#ffffff05;border-bottom:2px solid #475569}.template-status-title[data-v-f1a8c3e0]{color:#f1f5f9;margin-bottom:1rem;font-size:1.25rem;font-weight:600}.template-status-grid[data-v-f1a8c3e0]{display:grid;grid-template-columns:repeat(auto-fill,minmax(300px,1fr));gap:1rem}.node-status-card[data-v-f1a8c3e0]{background:#1e293b;border:2px solid #475569;border-radius:8px;padding:1rem}.node-status-header[data-v-f1a8c3e0]{display:flex;justify-content:space-between;align-items:center;margin-bottom:.75rem;padding-bottom:.75rem;border-bottom:1px solid #475569}.node-name[data-v-f1a8c3e0]{color:#f1f5f9;font-weight:600;font-size:1.1rem}.template-count[data-v-f1a8c3e0]{color:#94a3b8;font-size:.875rem}.template-list[data-v-f1a8c3e0]{display:flex;flex-direction:column;gap:.5rem}.template-item[data-v-f1a8c3e0]{display:flex;justify-content:space-between;align-items:center;padding:.5rem;background:#0f172a;border-radius:4px}.template-vmid[data-v-f1a8c3e0]{color:#e2e8f0;font-weight:500}.template-badge[data-v-f1a8c3e0]{padding:.25rem .75rem;border-radius:4px;font-size:.875rem;font-weight:500}.template-badge.exists[data-v-f1a8c3e0]{background:#22c55e33;color:#4ade80;border:1px solid #22c55e}.template-badge.missing[data-v-f1a8c3e0]{background:#ef444433;color:#f87171;border:1px solid #ef4444}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1
View File
@@ -0,0 +1 @@
import{_ as f,g as y,c as v,o as p,a as s,h as l,i as r,t as e,d as k,n as d,r as m,j as c,k as h,l as g}from"./index-DpP9cWht.js";const x={name:"Dashboard",setup(){const n=m({total_vms:0,running_vms:0,stopped_vms:0,paused_vms:0,datacenters:0,total_nodes:0}),t=m(null),i=c(()=>t.value?Math.round(t.value.used_cpu_cores/t.value.total_cpu_cores*100):0),a=c(()=>t.value?Math.round(t.value.used_memory_gb/t.value.total_memory_gb*100):0),u=c(()=>t.value?Math.round(t.value.used_disk_gb/t.value.total_disk_gb*100):0),_=async()=>{try{const[o,b]=await Promise.all([g.dashboard.getStats(),g.dashboard.getResources()]);n.value=o.data,t.value=b.data}catch(o){console.error("Failed to fetch dashboard data:",o)}};return h(()=>{_()}),{stats:n,resources:t,cpuPercentage:i,memoryPercentage:a,diskPercentage:u}}},w={class:"dashboard"},P={class:"stats-row mb-2"},M={class:"stat-info"},B={class:"stat-value"},C={class:"stat-info"},D={class:"stat-value"},S={class:"stat-info"},N={class:"stat-value"},R={class:"stat-info"},V={class:"stat-value"},G={class:"stat-info"},I={class:"stat-value"},U={class:"grid grid-cols-2 gap-2"},A={class:"card"},O={key:0,class:"resources"},j={class:"resource-item"},q={class:"resource-bar"},z={class:"resource-value"},E={class:"resource-item"},F={class:"resource-bar"},H={class:"resource-value"},Q={class:"resource-item"},J={class:"resource-bar"},K={class:"resource-value"},L={class:"card"},T={class:"quick-actions"};function W(n,t,i,a,u,_){const o=y("router-link");return p(),v("div",w,[s("div",P,[l(o,{to:"/vms",class:"stat-card card"},{default:r(()=>[t[1]||(t[1]=s("div",{class:"stat-icon"},"🖥️",-1)),s("div",M,[s("p",B,e(a.stats.total_vms),1),t[0]||(t[0]=s("p",{class:"stat-label"},"VMs",-1))])]),_:1}),l(o,{to:"/vms?status=running",class:"stat-card card"},{default:r(()=>[t[3]||(t[3]=s("div",{class:"stat-icon success"},"▶️",-1)),s("div",C,[s("p",D,e(a.stats.running_vms),1),t[2]||(t[2]=s("p",{class:"stat-label"},"Running",-1))])]),_:1}),l(o,{to:"/vms?status=stopped",class:"stat-card card"},{default:r(()=>[t[5]||(t[5]=s("div",{class:"stat-icon danger"},"⏹️",-1)),s("div",S,[s("p",N,e(a.stats.stopped_vms),1),t[4]||(t[4]=s("p",{class:"stat-label"},"Stopped",-1))])]),_:1}),l(o,{to:"/proxmox",class:"stat-card card"},{default:r(()=>[t[7]||(t[7]=s("div",{class:"stat-icon"},"🏢",-1)),s("div",R,[s("p",V,e(a.stats.datacenters),1),t[6]||(t[6]=s("p",{class:"stat-label"},"Datacenters",-1))])]),_:1}),l(o,{to:"/proxmox",class:"stat-card card"},{default:r(()=>[t[9]||(t[9]=s("div",{class:"stat-icon"},"🖧",-1)),s("div",G,[s("p",I,e(a.stats.total_nodes),1),t[8]||(t[8]=s("p",{class:"stat-label"},"Nodes",-1))])]),_:1})]),s("div",U,[s("div",A,[t[13]||(t[13]=s("div",{class:"card-header"},[s("h3",null,"Resource Usage")],-1)),a.resources?(p(),v("div",O,[s("div",j,[t[10]||(t[10]=s("span",{class:"resource-label"},"CPU Cores",-1)),s("div",q,[s("div",{class:"resource-fill",style:d({width:a.cpuPercentage+"%"})},null,4)]),s("span",z,e(a.resources.used_cpu_cores)+" / "+e(a.resources.total_cpu_cores),1)]),s("div",E,[t[11]||(t[11]=s("span",{class:"resource-label"},"Memory",-1)),s("div",F,[s("div",{class:"resource-fill",style:d({width:a.memoryPercentage+"%"})},null,4)]),s("span",H,e(a.resources.used_memory_gb)+"GB / "+e(a.resources.total_memory_gb)+"GB ",1)]),s("div",Q,[t[12]||(t[12]=s("span",{class:"resource-label"},"Disk",-1)),s("div",J,[s("div",{class:"resource-fill",style:d({width:a.diskPercentage+"%"})},null,4)]),s("span",K,e(a.resources.used_disk_gb)+"GB / "+e(a.resources.total_disk_gb)+"GB ",1)])])):k("",!0)]),s("div",L,[t[17]||(t[17]=s("div",{class:"card-header"},[s("h3",null,"Quick Actions")],-1)),s("div",T,[l(o,{to:"/vms/create",class:"action-button"},{default:r(()=>[...t[14]||(t[14]=[s("span",{class:"action-icon"},"",-1),s("div",null,[s("p",{class:"action-title"},"Deploy New VM"),s("p",{class:"action-desc"},"Create and deploy a new virtual machine")],-1)])]),_:1}),l(o,{to:"/proxmox",class:"action-button"},{default:r(()=>[...t[15]||(t[15]=[s("span",{class:"action-icon"},"🌐",-1),s("div",null,[s("p",{class:"action-title"},"Manage Hosts"),s("p",{class:"action-desc"},"Configure Proxmox hosts")],-1)])]),_:1}),l(o,{to:"/isos",class:"action-button"},{default:r(()=>[...t[16]||(t[16]=[s("span",{class:"action-icon"},"💿",-1),s("div",null,[s("p",{class:"action-title"},"Upload ISO"),s("p",{class:"action-desc"},"Add new ISO images")],-1)])]),_:1})])])])])}const Y=f(x,[["render",W],["__scopeId","data-v-9e9a0d16"]]);export{Y as default};
+1
View File
@@ -0,0 +1 @@
.stats-row[data-v-9e9a0d16]{display:flex;gap:.75rem;justify-content:space-between}.stat-card[data-v-9e9a0d16]{display:flex;align-items:center;gap:.5rem;padding:.5rem .75rem!important;flex:1;min-width:0;cursor:pointer;transition:all .2s;text-decoration:none}.stat-card[data-v-9e9a0d16]:hover{transform:translateY(-2px);box-shadow:0 4px 8px #0000001a;border-color:var(--primary-color)}.stat-card[data-v-9e9a0d16]:active{transform:translateY(0)}.stat-icon[data-v-9e9a0d16]{font-size:1.5rem;opacity:.8;flex-shrink:0}.stat-icon.success[data-v-9e9a0d16]{color:var(--secondary-color)}.stat-icon.danger[data-v-9e9a0d16]{color:var(--danger-color)}.stat-info[data-v-9e9a0d16]{flex:1;min-width:0}.stat-value[data-v-9e9a0d16]{font-size:1.25rem;font-weight:700;color:var(--text-primary);margin:0;white-space:nowrap}.stat-label[data-v-9e9a0d16]{font-size:.7rem;color:var(--text-secondary);margin:0;white-space:nowrap}.resources[data-v-9e9a0d16]{display:flex;flex-direction:column;gap:.75rem}.resource-item[data-v-9e9a0d16]{display:flex;flex-direction:column;gap:.25rem}.resource-label[data-v-9e9a0d16]{font-weight:500;color:var(--text-secondary);font-size:.75rem}.resource-bar[data-v-9e9a0d16]{height:.375rem;background-color:var(--background);border-radius:9999px;overflow:hidden}.resource-fill[data-v-9e9a0d16]{height:100%;background:linear-gradient(90deg,var(--primary-color),var(--secondary-color));transition:width .3s ease}.resource-value[data-v-9e9a0d16]{font-size:.7rem;color:var(--text-secondary)}.quick-actions[data-v-9e9a0d16]{display:flex;flex-direction:column;gap:.5rem}.action-button[data-v-9e9a0d16]{display:flex;align-items:center;gap:.5rem;padding:.5rem;border:1px solid var(--border-color);border-radius:.375rem;text-decoration:none;transition:all .2s}.action-button[data-v-9e9a0d16]:hover{background-color:var(--background);border-color:var(--primary-color)}.action-icon[data-v-9e9a0d16]{font-size:1.25rem}.action-title[data-v-9e9a0d16]{font-weight:600;color:var(--text-primary);margin:0;font-size:.85rem}.action-desc[data-v-9e9a0d16]{font-size:.7rem;color:var(--text-secondary);margin:0}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1
View File
@@ -0,0 +1 @@
.btn-sm[data-v-295eeb95]{padding:.25rem .5rem;font-size:.875rem}.modal[data-v-295eeb95]{position:fixed;top:0;left:0;right:0;bottom:0;background-color:#00000080;display:flex;align-items:center;justify-content:center;z-index:1000}.modal-content[data-v-295eeb95]{background:#fff;border-radius:.5rem;max-width:600px;width:90%;max-height:90vh;overflow-y:auto;box-shadow:var(--shadow-lg)}.modal-header[data-v-295eeb95]{padding:1.5rem;border-bottom:1px solid var(--border-color);display:flex;justify-content:space-between;align-items:center}.modal-header h3[data-v-295eeb95]{margin:0}.btn-close[data-v-295eeb95]{background:none;border:none;font-size:2rem;cursor:pointer;color:var(--text-secondary);line-height:1}.modal-body[data-v-295eeb95]{padding:1.5rem}.upload-progress[data-v-295eeb95]{margin-top:1rem;padding:1rem;background-color:var(--background);border-radius:.5rem;border:1px solid var(--border-color)}.upload-stats[data-v-295eeb95]{margin-bottom:1rem}.stat-row[data-v-295eeb95]{display:flex;justify-content:space-between;align-items:center;padding:.5rem 0;border-bottom:1px solid var(--border-color)}.stat-row[data-v-295eeb95]:last-child{border-bottom:none}.stat-label[data-v-295eeb95]{font-size:.875rem;color:var(--text-secondary);font-weight:500}.stat-value[data-v-295eeb95]{font-size:.875rem;color:var(--text-primary);font-weight:600;font-family:monospace}.progress-bar[data-v-295eeb95]{height:1.25rem;background-color:var(--border-color);border-radius:9999px;overflow:hidden;position:relative;box-shadow:inset 0 2px 4px #0000001a}.progress-fill[data-v-295eeb95]{height:100%;background:linear-gradient(90deg,var(--primary-color),var(--secondary-color));transition:width .2s ease;box-shadow:0 0 10px #2563eb80}
File diff suppressed because one or more lines are too long
+1
View File
@@ -0,0 +1 @@
import{_ as f,c,o as u,a as e,b as m,w as g,d as b,e as n,v as a,t as v,u as w,f as y,r as i}from"./index-DpP9cWht.js";const x={name:"Login",setup(){const d=w(),o=y(),r=i({username:"",password:"",totp_code:""}),s=i(!1),l=i(!1);return{credentials:r,loading:s,show2FA:l,handleLogin:async()=>{s.value=!0;try{const t=await o.login(r.value);t.success?d.push("/"):t.error==="2FA code required"&&(l.value=!0)}catch(t){console.error("Login error:",t)}finally{s.value=!1}}}}},L={class:"login-container"},V={class:"login-card"},_={class:"form-group"},h={class:"form-group"},A={key:0,class:"form-group"},D=["disabled"];function S(d,o,r,s,l,p){return u(),c("div",L,[e("div",V,[o[7]||(o[7]=e("div",{class:"login-header"},[e("h1",{class:"login-logo"},[m("Depl"),e("span",{class:"logo-zero"},"0"),m("y")]),e("p",{class:"login-subtitle"},"VM Deployment Panel")],-1)),e("form",{onSubmit:o[3]||(o[3]=g((...t)=>s.handleLogin&&s.handleLogin(...t),["prevent"])),class:"login-form"},[e("div",_,[o[4]||(o[4]=e("label",{for:"username",class:"form-label"},"Username",-1)),n(e("input",{id:"username","onUpdate:modelValue":o[0]||(o[0]=t=>s.credentials.username=t),type:"text",class:"form-control",required:"",autofocus:""},null,512),[[a,s.credentials.username]])]),e("div",h,[o[5]||(o[5]=e("label",{for:"password",class:"form-label"},"Password",-1)),n(e("input",{id:"password","onUpdate:modelValue":o[1]||(o[1]=t=>s.credentials.password=t),type:"password",class:"form-control",required:""},null,512),[[a,s.credentials.password]])]),s.show2FA?(u(),c("div",A,[o[6]||(o[6]=e("label",{for:"totp",class:"form-label"},"2FA Code",-1)),n(e("input",{id:"totp","onUpdate:modelValue":o[2]||(o[2]=t=>s.credentials.totp_code=t),type:"text",class:"form-control",placeholder:"000000",maxlength:"6"},null,512),[[a,s.credentials.totp_code]])])):b("",!0),e("button",{type:"submit",class:"btn btn-primary btn-block",disabled:s.loading},v(s.loading?"Logging in...":"Login"),9,D)],32),o[8]||(o[8]=e("div",{class:"login-footer"},[e("p",{class:"text-muted text-sm text-center"}," Open Source VM Deployment Platform ")],-1))])])}const F=f(x,[["render",S],["__scopeId","data-v-bf325ce1"]]);export{F as default};
+1
View File
@@ -0,0 +1 @@
.login-container[data-v-bf325ce1]{display:flex;align-items:center;justify-content:center;min-height:100vh;background:linear-gradient(135deg,#1e3a8a,#1e40af,#1d4ed8);padding:2rem}.login-card[data-v-bf325ce1]{background:#fff;border-radius:1rem;box-shadow:0 20px 25px -5px #0000001a;width:100%;max-width:400px;padding:2rem}.login-header[data-v-bf325ce1]{text-align:center;margin-bottom:2rem}.login-logo[data-v-bf325ce1]{font-size:3rem;font-weight:700;margin:0;color:var(--text-primary)}.logo-zero[data-v-bf325ce1]{color:#3b82f6}.login-subtitle[data-v-bf325ce1]{color:var(--text-secondary);margin-top:.5rem}.login-form[data-v-bf325ce1]{margin-bottom:1.5rem}.btn-block[data-v-bf325ce1]{width:100%;margin-top:1rem}.login-footer[data-v-bf325ce1]{margin-top:2rem;padding-top:1rem;border-top:1px solid var(--border-color)}
+1
View File
@@ -0,0 +1 @@
import{_ as t,c as s,o as a,a as e}from"./index-DpP9cWht.js";const n={name:"NotFound"},d={class:"notfound-page"};function c(r,o,l,p,u,i){return a(),s("div",d,[...o[0]||(o[0]=[e("div",{class:"card"},[e("div",{class:"card-header"},[e("h3",null,"NotFound")]),e("p",{class:"text-muted"},"NotFound implementation goes here")],-1)])])}const m=t(n,[["render",c]]);export{m as default};
File diff suppressed because one or more lines are too long
+1
View File
@@ -0,0 +1 @@
.page-header[data-v-04139eed]{margin-bottom:1.5rem}.page-header h2[data-v-04139eed]{margin:0 0 .5rem;font-size:1.75rem;font-weight:600;color:var(--text-primary)}.page-header .text-muted[data-v-04139eed]{font-size:.95rem}.btn-sm[data-v-04139eed]{padding:.25rem .5rem;font-size:.875rem}.nodes-section[data-v-04139eed]{padding:0}.datacenter-section[data-v-04139eed]{margin-bottom:2rem;padding-bottom:2rem;border-bottom:1px solid var(--border-color)}.datacenter-section[data-v-04139eed]:last-child{border-bottom:none;margin-bottom:0;padding-bottom:0}.datacenter-title[data-v-04139eed]{font-size:1.25rem;font-weight:600;margin-bottom:1rem;color:var(--primary-color);padding-left:.5rem;border-left:3px solid var(--primary-color)}.modal[data-v-04139eed]{position:fixed;top:0;left:0;right:0;bottom:0;background-color:#00000080;display:flex;align-items:center;justify-content:center;z-index:1000}.modal-content[data-v-04139eed]{background:#fff;border-radius:.5rem;max-width:600px;width:90%;max-height:90vh;overflow-y:auto;box-shadow:var(--shadow-lg)}.modal-header[data-v-04139eed]{padding:1.5rem;border-bottom:1px solid var(--border-color);display:flex;justify-content:space-between;align-items:center}.modal-header h3[data-v-04139eed]{margin:0}.btn-close[data-v-04139eed]{background:none;border:none;font-size:2rem;cursor:pointer;color:var(--text-secondary);line-height:1}.modal-body[data-v-04139eed]{padding:1.5rem}.auth-section[data-v-04139eed]{margin:1rem 0;padding:1rem;background-color:var(--background);border-radius:.5rem;border:1px solid var(--border-color)}.section-subtitle[data-v-04139eed]{font-size:1rem;font-weight:600;margin-bottom:.5rem;color:var(--text-primary)}.warning-box[data-v-04139eed]{margin-top:1rem;padding:1rem;background-color:#fff3cd;border:1px solid #ffc107;border-radius:.375rem;color:#856404}.warning-box strong[data-v-04139eed]{display:block;margin-bottom:.5rem;font-size:.875rem}.warning-box p[data-v-04139eed]{margin:.25rem 0;font-size:.875rem}.nodes-grid[data-v-04139eed]{display:grid;grid-template-columns:repeat(auto-fill,minmax(300px,1fr));gap:1rem}.node-card[data-v-04139eed]{border:1px solid var(--border-color);border-radius:.5rem;padding:1rem;background:var(--background)}.node-header[data-v-04139eed]{display:flex;justify-content:space-between;align-items:center;margin-bottom:1rem;padding-bottom:.75rem;border-bottom:1px solid var(--border-color)}.node-header h5[data-v-04139eed]{margin:0;font-size:1.125rem;font-weight:600}.node-stats[data-v-04139eed]{display:flex;flex-direction:column;gap:.5rem}.stat[data-v-04139eed]{display:flex;justify-content:space-between;font-size:.875rem}.stat-label[data-v-04139eed]{color:var(--text-secondary);font-weight:500}.stat-value[data-v-04139eed]{color:var(--text-primary);font-family:monospace}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1
View File
@@ -0,0 +1 @@
.modal-overlay[data-v-ddf646d5]{position:fixed;top:0;left:0;right:0;bottom:0;background-color:#00000080;display:flex;align-items:center;justify-content:center;z-index:1000}.modal-content[data-v-ddf646d5]{background-color:var(--card-bg);border-radius:.5rem;width:90%;max-width:500px;max-height:90vh;overflow-y:auto}.modal-header[data-v-ddf646d5]{display:flex;justify-content:space-between;align-items:center;padding:1.5rem;border-bottom:1px solid var(--border-color)}.modal-header h3[data-v-ddf646d5]{margin:0;color:var(--text-primary)}.modal-close[data-v-ddf646d5]{background:none;border:none;font-size:1.5rem;cursor:pointer;color:var(--text-secondary);padding:0;width:2rem;height:2rem;display:flex;align-items:center;justify-content:center}.modal-close[data-v-ddf646d5]:hover{color:var(--text-primary)}.modal-body[data-v-ddf646d5]{padding:1.5rem}.modal-footer[data-v-ddf646d5]{display:flex;justify-content:flex-end;gap:.5rem;margin-top:1.5rem}.form-group[data-v-ddf646d5]{margin-bottom:1rem}.form-label[data-v-ddf646d5]{display:block;margin-bottom:.5rem;font-weight:500;color:var(--text-secondary)}.form-control[data-v-ddf646d5]{width:100%;padding:.5rem;border:1px solid var(--border-color);border-radius:.25rem;background-color:var(--background);color:var(--text-primary)}.form-control[data-v-ddf646d5]:focus{outline:none;border-color:var(--primary-color)}.btn-sm[data-v-ddf646d5]{padding:.25rem .5rem;font-size:.875rem}
+1
View File
@@ -0,0 +1 @@
import{_ as a,c as t,o,a as e}from"./index-DpP9cWht.js";const c={name:"VMDetails"},r={class:"vmdetails-page"};function l(n,s,i,d,p,m){return o(),t("div",r,[...s[0]||(s[0]=[e("div",{class:"card"},[e("div",{class:"card-header"},[e("h3",null,"VMDetails")]),e("p",{class:"text-muted"},"VMDetails implementation goes here")],-1)])])}const f=a(c,[["render",l]]);export{f as default};
File diff suppressed because one or more lines are too long
+1
View File
@@ -0,0 +1 @@
.btn-sm[data-v-872f930a]{padding:.25rem .5rem;font-size:.875rem}.sortable[data-v-872f930a]{cursor:pointer;-webkit-user-select:none;user-select:none}.sortable[data-v-872f930a]:hover{background-color:var(--background)}.sort-indicator[data-v-872f930a]{margin-left:.25rem;font-size:.75rem;opacity:.7}.filter-bar[data-v-872f930a]{display:flex;align-items:center;justify-content:space-between;padding:.75rem 1rem;background-color:var(--background);border-bottom:1px solid var(--border)}.filter-info[data-v-872f930a]{display:flex;align-items:center;gap:.5rem}.filter-label[data-v-872f930a]{font-size:.875rem;color:var(--text-muted)}.filter-count[data-v-872f930a]{font-size:.875rem;color:var(--text-muted);margin-left:.25rem}.modal-overlay[data-v-872f930a]{position:fixed;top:0;left:0;right:0;bottom:0;background-color:#00000080;display:flex;align-items:center;justify-content:center;z-index:1000}.modal-content[data-v-872f930a]{background-color:var(--surface);border-radius:.5rem;box-shadow:0 10px 25px #0003;max-width:500px;width:90%;max-height:90vh;overflow-y:auto}.modal-header[data-v-872f930a]{display:flex;justify-content:space-between;align-items:center;padding:1rem 1.5rem;border-bottom:1px solid var(--border-color)}.modal-header h3[data-v-872f930a]{margin:0;font-size:1.25rem;color:var(--text-primary)}.btn-close[data-v-872f930a]{background:none;border:none;font-size:2rem;line-height:1;color:var(--text-secondary);cursor:pointer;padding:0;width:2rem;height:2rem;display:flex;align-items:center;justify-content:center}.btn-close[data-v-872f930a]:hover{color:var(--text-primary)}.modal-body[data-v-872f930a]{padding:1.5rem}.modal-footer[data-v-872f930a]{display:flex;justify-content:flex-end;gap:.5rem;padding:1rem 1.5rem;border-top:1px solid var(--border-color)}.text-danger[data-v-872f930a]{color:#991b1b;font-weight:500}.text-muted[data-v-872f930a]{color:#475569}.mb-1[data-v-872f930a]{margin-bottom:.5rem}.mb-2[data-v-872f930a]{margin-bottom:1rem}.form-group[data-v-872f930a]{margin-top:1rem}.form-group label[data-v-872f930a]{display:block;margin-bottom:.5rem;font-weight:500;color:var(--text-primary)}.form-control[data-v-872f930a]{width:100%;padding:.5rem;border:1px solid var(--border-color);border-radius:.375rem;background-color:var(--background);color:var(--text-primary);font-size:1rem}.form-control[data-v-872f930a]:focus{outline:none;border-color:var(--primary-color)}.btn-secondary[data-v-872f930a]{background-color:var(--secondary-color);color:#fff}.btn-secondary[data-v-872f930a]:hover{opacity:.9}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<rect fill="#3b82f6" width="32" height="32" rx="4"/>
<text x="16" y="24" font-family="Arial,sans-serif" font-size="20" font-weight="bold" fill="#fff" text-anchor="middle">D</text>
</svg>

After

Width:  |  Height:  |  Size: 252 B

+14
View File
@@ -0,0 +1,14 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<link rel="icon" href="/favicon.ico">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Depl0y - VM Deployment Panel</title>
<script type="module" crossorigin src="/assets/index-DpP9cWht.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-54kXT93y.css">
</head>
<body>
<div id="app"></div>
</body>
</html>