mirror of
https://github.com/Grace-Solutions/Depl0y-Custom.git
synced 2026-07-26 11:28:19 +00:00
Initial Commit
This commit is contained in:
@@ -1 +1,341 @@
|
||||
# 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.
|
||||
|
||||

|
||||

|
||||

|
||||
|
||||
## 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!** ⭐
|
||||
|
||||
@@ -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!** 🚀
|
||||
@@ -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`
|
||||
@@ -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.
|
||||
@@ -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
@@ -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
|
||||
@@ -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.
|
||||
@@ -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 "========================================="
|
||||
@@ -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 ""
|
||||
@@ -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 "========================================="
|
||||
@@ -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 "========================================="
|
||||
@@ -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 "========================================="
|
||||
@@ -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 ""
|
||||
@@ -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
|
||||
@@ -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"]
|
||||
@@ -0,0 +1 @@
|
||||
"""Main application package"""
|
||||
@@ -0,0 +1 @@
|
||||
"""API routes package"""
|
||||
@@ -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"}
|
||||
@@ -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.")
|
||||
@@ -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))
|
||||
|
||||
|
||||
@@ -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))
|
||||
@@ -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
|
||||
],
|
||||
}
|
||||
@@ -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)}"
|
||||
)
|
||||
@@ -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)}")
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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))
|
||||
@@ -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)}")
|
||||
@@ -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)}"
|
||||
)
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)}"
|
||||
)
|
||||
|
||||
|
||||
@@ -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")
|
||||
@@ -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"}
|
||||
@@ -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")
|
||||
@@ -0,0 +1 @@
|
||||
"""Core application package"""
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
"""Services package"""
|
||||
@@ -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)
|
||||
@@ -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
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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%}}
|
||||
@@ -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};
|
||||
@@ -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
@@ -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};
|
||||
@@ -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
@@ -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
@@ -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
@@ -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)}
|
||||
@@ -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
@@ -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
+1
File diff suppressed because one or more lines are too long
+1
@@ -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}
|
||||
@@ -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
@@ -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}
|
||||
+1
File diff suppressed because one or more lines are too long
+35
File diff suppressed because one or more lines are too long
Vendored
+4
@@ -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 |
Vendored
+14
@@ -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>
|
||||
Reference in New Issue
Block a user