initial
This commit is contained in:
0
manager/__init__.py
Normal file
0
manager/__init__.py
Normal file
262
manager/admin.py
Normal file
262
manager/admin.py
Normal file
@@ -0,0 +1,262 @@
|
||||
import logging
|
||||
|
||||
from django import forms
|
||||
from django.contrib import admin
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db.models import Q
|
||||
|
||||
from lib.decorators import readonly
|
||||
from manager.models import CloneContainer, DevContainer
|
||||
from mikrotik.models import DNSStatic
|
||||
from proxmox.models import Lxc
|
||||
|
||||
|
||||
@readonly
|
||||
def resync_all(*args, **kwargs):
|
||||
from proxmox.models import Lxc
|
||||
from lib.proxmox import Proxmox
|
||||
from mikrotik.models import DNSStatic, IPDHCPLease
|
||||
from manager.models import DevContainer
|
||||
from mikrotik.admin import sync_ipaddress_from_mikrotik, sync_ipdhcplease_from_mikrotik, sync_dns_static_from_mikrotik
|
||||
from proxmox.admin import sync_all_lxc_templates_from_proxmox, sync_all_lxc_from_proxmox
|
||||
|
||||
fulls = []
|
||||
pm = Proxmox()
|
||||
lxcs = {}
|
||||
leases = {}
|
||||
dnse = {}
|
||||
|
||||
sync_all_lxc_templates_from_proxmox()
|
||||
sync_all_lxc_from_proxmox()
|
||||
sync_ipaddress_from_mikrotik()
|
||||
sync_ipdhcplease_from_mikrotik()
|
||||
sync_dns_static_from_mikrotik()
|
||||
|
||||
for lxc in Lxc.objects.all():
|
||||
lxcs[lxc.hwaddr.upper()] = lxc
|
||||
for lease in IPDHCPLease.objects.all():
|
||||
leases[lease.mac_address.upper()] = lease
|
||||
for dns in DNSStatic.objects.all():
|
||||
dnse[dns.address.upper()] = dns
|
||||
|
||||
container = {c.hwaddr.upper():c for c in DevContainer.objects.all()}
|
||||
|
||||
for hwaddr, lxc in lxcs.items():
|
||||
if hwaddr not in leases:
|
||||
logging.warning(f'LXC {lxc} has no DHCP lease')
|
||||
continue
|
||||
lease = leases[hwaddr]
|
||||
if lease.address not in dnse:
|
||||
logging.warning(f'DHCP lease {lease} for {lxc} has no DNS entry')
|
||||
continue
|
||||
dns = dnse[lease.address]
|
||||
|
||||
if hwaddr in container:
|
||||
container[hwaddr].dns = dns
|
||||
container[hwaddr].lease = lease
|
||||
container[hwaddr].save()
|
||||
elif lxc:
|
||||
DevContainer.objects.create(
|
||||
dns=dns,
|
||||
lease=lease,
|
||||
lxc=lxc,
|
||||
)
|
||||
# Now remove the non lxc devcontainers
|
||||
DevContainer.objects.filter(lxc__isnull=True).delete()
|
||||
|
||||
def shell_baseimport():
|
||||
from proxmox.models import Lxc
|
||||
from lib.proxmox import Proxmox
|
||||
from mikrotik.models import DNSStatic, IPDHCPLease, IPAddress
|
||||
from manager.models import DevContainer
|
||||
from lib.mikrotik import MikrotikModelMixin
|
||||
|
||||
fulls = []
|
||||
for empt in (DevContainer, Lxc, IPAddress, IPDHCPLease, DNSStatic):
|
||||
if empt.objects.count() != 0:
|
||||
fulls.append(empt)
|
||||
if fulls:
|
||||
msg = []
|
||||
queries = []
|
||||
for f in fulls:
|
||||
logging.error(f'{f.__name__} is not empty.')
|
||||
queries.append(f'DELETE FROM {f._meta.db_table};')
|
||||
msg.append(
|
||||
f"\n\nPlease delete all objects and try again.\n"
|
||||
f"\nThis can only be done with a raw query\n"
|
||||
)
|
||||
msg.append('\n'.join(queries))
|
||||
logging.error('\n'.join(msg) + '\n\n')
|
||||
raise ValidationError("Some tables not empty - see above output")
|
||||
|
||||
pm = Proxmox()
|
||||
lxcs = {}
|
||||
|
||||
for lxc in pm.get_all_lxc(as_dict=False):
|
||||
lxc.save()
|
||||
lxcs[lxc.hwaddr.upper()] = lxc
|
||||
|
||||
addresses = IPAddress.get_all_as_object()
|
||||
for a in addresses:
|
||||
a.save()
|
||||
|
||||
leases = {}
|
||||
for lease in IPDHCPLease.get_all_as_object():
|
||||
if isinstance(lease, MikrotikModelMixin):
|
||||
lease.save()
|
||||
if lease.mac_address in lxcs:
|
||||
leases[lease.address.upper()] = lease
|
||||
else:
|
||||
logging.warning(f'IPDHCPLease {lease} is not a MikrotikModelMixin')
|
||||
|
||||
dnse = {}
|
||||
for dns in DNSStatic.get_all_as_object():
|
||||
if isinstance(dns, MikrotikModelMixin):
|
||||
dns.save()
|
||||
if dns.address in leases:
|
||||
dnse[dns.address.upper()] = dns
|
||||
else:
|
||||
logging.warning(f'DNSStatic {dns} is not a MikrotikModelMixin')
|
||||
|
||||
for _a, dns in dnse.items():
|
||||
lease = leases[_a]
|
||||
lxc = lxcs[lease.mac_address.upper()]
|
||||
DevContainer.objects.get_or_create(
|
||||
lxc=lxc,
|
||||
dns=dns,
|
||||
lease=lease,
|
||||
)
|
||||
|
||||
|
||||
class DevContainerAdminForm(forms.ModelForm):
|
||||
disksize = forms.IntegerField(
|
||||
required=False,
|
||||
min_value=1,
|
||||
max_value=100,
|
||||
help_text="Disk Size of rootfs - can not be shrinked"
|
||||
)
|
||||
|
||||
cores = forms.IntegerField(
|
||||
required=False,
|
||||
min_value=1,
|
||||
help_text="Number of Cores"
|
||||
)
|
||||
|
||||
memory = forms.CharField(
|
||||
required=False,
|
||||
help_text="Memory in MB"
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = DevContainer
|
||||
fields = '__all__'
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
instance = kwargs.get('instance')
|
||||
|
||||
if instance and hasattr(instance, 'lxc') and instance.lxc:
|
||||
self.fields['disksize'].initial = instance.lxc.disksize
|
||||
self.fields['disksize'].min_value = instance.lxc.disksize
|
||||
self.fields['cores'].initial = instance.lxc.cores
|
||||
self.fields['memory'].initial = instance.lxc.memory
|
||||
|
||||
def save(self, commit=True):
|
||||
instance = super().save(commit=False)
|
||||
|
||||
if hasattr(instance, 'lxc') and instance.lxc:
|
||||
lxc = instance.lxc
|
||||
|
||||
if 'disksize' in self.cleaned_data and self.cleaned_data['disksize']:
|
||||
lxc.disksize = self.cleaned_data['disksize']
|
||||
|
||||
if 'cores' in self.cleaned_data and self.cleaned_data['cores']:
|
||||
lxc.cores = self.cleaned_data['cores']
|
||||
|
||||
if 'memory' in self.cleaned_data and self.cleaned_data['memory']:
|
||||
lxc.memory = self.cleaned_data['memory']
|
||||
|
||||
lxc.save()
|
||||
|
||||
if commit:
|
||||
instance.save()
|
||||
|
||||
return instance
|
||||
|
||||
|
||||
def clone_selected_containers(modeladmin, request, queryset):
|
||||
for container in queryset:
|
||||
container.execute()
|
||||
|
||||
|
||||
class CloneContainerAdminForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model = CloneContainer
|
||||
fields = '__all__'
|
||||
|
||||
def clean_name(self):
|
||||
name = self.cleaned_data.get('name')
|
||||
if not name:
|
||||
return name
|
||||
|
||||
dns_domain = Q(name=name) | Q(name__endswith=name) | Q(name__endswith=name.replace('.', r'\.'))
|
||||
if DNSStatic.objects.filter(dns_domain).exists():
|
||||
raise ValidationError(f"Ein DNS-Eintrag mit dem Namen '{name}' existiert bereits.")
|
||||
|
||||
if Lxc.objects.filter(hostname=name).exists():
|
||||
raise ValidationError(f"A LXC with name or regex '{name}' exists.")
|
||||
# Prüfe, ob der Name bereits als CloneContainer-Name existiert
|
||||
# Ausschluss der aktuellen Instanz bei Updates
|
||||
existing_clone_query = CloneContainer.objects.filter(name=name)
|
||||
if self.instance.pk:
|
||||
existing_clone_query = existing_clone_query.exclude(pk=self.instance.pk)
|
||||
|
||||
if existing_clone_query.exists():
|
||||
raise ValidationError(f"A CloneContainer with name '{name}' exists.")
|
||||
|
||||
return name
|
||||
|
||||
def clean(self):
|
||||
cleaned_data = super().clean()
|
||||
|
||||
if not cleaned_data.get('template') and not cleaned_data.get('vm'):
|
||||
raise ValidationError("Please select a template or a VM.")
|
||||
|
||||
return cleaned_data
|
||||
|
||||
|
||||
@admin.register(CloneContainer)
|
||||
class CloneContainerAdmin(admin.ModelAdmin):
|
||||
autocomplete_fields = ['vm', 'network']
|
||||
list_display = ('hostname', 'cloned_from', 'network', 'memory', 'cores', 'disksize', 'status',)
|
||||
search_fields = ('hostname', 'vm__name', 'vm__vmid',)
|
||||
actions = [clone_selected_containers]
|
||||
form = CloneContainerAdminForm
|
||||
|
||||
def cloned_from(self, obj):
|
||||
return obj.vm
|
||||
|
||||
cloned_from.short_description = 'Cloned from'
|
||||
cloned_from.admin_order_field = 'vm__name'
|
||||
|
||||
|
||||
@admin.register(DevContainer)
|
||||
class DevContainerAdmin(admin.ModelAdmin):
|
||||
form = DevContainerAdminForm
|
||||
autocomplete_fields = ['dns', 'lxc', 'lease']
|
||||
# list_display = ('name', 'address', 'hostname_or_regexp', 'lxc__disksize', 'hwaddr', 'status',)
|
||||
list_filter = (
|
||||
'lxc__status',
|
||||
'lease__status',
|
||||
('lxc', admin.EmptyFieldListFilter),
|
||||
('lease', admin.EmptyFieldListFilter),
|
||||
('dns', admin.EmptyFieldListFilter),
|
||||
)
|
||||
search_fields = ('dns__name', 'dns__address', 'lease__address', 'dns__regexp')
|
||||
actions = [resync_all]
|
||||
|
||||
def hostname_or_regexp(self, obj):
|
||||
return obj.hostname
|
||||
|
||||
hostname_or_regexp.short_description = 'Hostname or Regexp'
|
||||
hostname_or_regexp.admin_order_field = 'dns__name'
|
||||
6
manager/apps.py
Normal file
6
manager/apps.py
Normal file
@@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class ManagerConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'manager'
|
||||
48
manager/migrations/0001_initial.py
Normal file
48
manager/migrations/0001_initial.py
Normal file
@@ -0,0 +1,48 @@
|
||||
# Generated by Django 5.2.4 on 2025-07-07 11:19
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
('mikrotik', '0001_initial'),
|
||||
('proxmox', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='CloneContainer',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(help_text='Will be used as hostname', max_length=150)),
|
||||
('cpus', models.IntegerField(default=1)),
|
||||
('memory', models.IntegerField(default=1024)),
|
||||
('disksize', models.IntegerField(default=10, help_text='in GB')),
|
||||
('as_regexp', models.BooleanField(choices=[(False, 'No'), (True, 'Yes')], default=True, help_text='Add a ".*<hostname>" instead of a hostname')),
|
||||
('is_active', models.BooleanField(choices=[(False, 'No'), (True, 'Yes')], default=False)),
|
||||
('status', models.CharField(blank=True, choices=[('pending', 'Pending'), ('running', 'Running'), ('success', 'Success'), ('error', 'Error')], default='pending', max_length=150, null=True)),
|
||||
('network', models.ForeignKey(on_delete=django.db.models.deletion.RESTRICT, related_name='clone_network', to='mikrotik.ipaddress')),
|
||||
('template', models.ForeignKey(null=True, on_delete=django.db.models.deletion.RESTRICT, related_name='clone_template', to='proxmox.lxctemplate')),
|
||||
('vm', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='clone_lxc', to='proxmox.lxc')),
|
||||
],
|
||||
options={
|
||||
'abstract': False,
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='DevContainer',
|
||||
fields=[
|
||||
('internal_id', models.BigAutoField(primary_key=True, serialize=False)),
|
||||
('dns', models.OneToOneField(on_delete=django.db.models.deletion.RESTRICT, related_name='devcontainer_dns', to='mikrotik.dnsstatic')),
|
||||
('lease', models.OneToOneField(on_delete=django.db.models.deletion.RESTRICT, related_name='devcontainer_lease', to='mikrotik.ipdhcplease')),
|
||||
('lxc', models.OneToOneField(on_delete=django.db.models.deletion.RESTRICT, related_name='devcontainer_lxc', to='proxmox.lxc')),
|
||||
],
|
||||
options={
|
||||
'abstract': False,
|
||||
},
|
||||
),
|
||||
]
|
||||
40
manager/migrations/0002_clonecontainer_hostname_and_more.py
Normal file
40
manager/migrations/0002_clonecontainer_hostname_and_more.py
Normal file
@@ -0,0 +1,40 @@
|
||||
# Generated by Django 5.2.4 on 2025-07-09 06:31
|
||||
|
||||
import manager.models
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('manager', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='clonecontainer',
|
||||
name='hostname',
|
||||
field=models.CharField(default=0, help_text='Will be used as hostname', max_length=150),
|
||||
preserve_default=False,
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='clonecontainer',
|
||||
name='as_regexp',
|
||||
field=models.BooleanField(choices=[(False, 'No'), (True, 'Yes')], default=True, help_text='Add a ".*<hostname>.replace(".",r"\\.")$" instead of a hostname'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='clonecontainer',
|
||||
name='cpus',
|
||||
field=models.IntegerField(default=1, validators=[manager.models.MinValueValidatorExtended(1), manager.models.MaxValueValidatorExtended(8)]),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='clonecontainer',
|
||||
name='disksize',
|
||||
field=models.IntegerField(default=10, help_text='in GB', validators=[manager.models.MinValueValidatorExtended(10), manager.models.MaxValueValidatorExtended(100)]),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='clonecontainer',
|
||||
name='memory',
|
||||
field=models.IntegerField(default=1024, help_text='in MB', validators=[manager.models.MinValueValidatorExtended(256), manager.models.MaxValueValidatorExtended(8192)]),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,27 @@
|
||||
# Generated by Django 5.2.4 on 2025-07-09 08:08
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('manager', '0002_clonecontainer_hostname_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RenameField(
|
||||
model_name='clonecontainer',
|
||||
old_name='cpus',
|
||||
new_name='cores',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='clonecontainer',
|
||||
name='name',
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='clonecontainer',
|
||||
name='node',
|
||||
field=models.CharField(default='proxmox', editable=False, max_length=150),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,31 @@
|
||||
# Generated by Django 5.2.4 on 2025-07-10 09:30
|
||||
|
||||
import django.db.models.deletion
|
||||
import manager.models
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('manager', '0003_rename_cpus_clonecontainer_cores_and_more'),
|
||||
('proxmox', '0005_alter_lxc_lxc'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='clonecontainer',
|
||||
name='memory',
|
||||
field=models.IntegerField(default=1024, help_text='in MB', validators=[manager.models.MinValueValidatorExtended(256), manager.models.MaxValueValidatorExtended(8092)]),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='clonecontainer',
|
||||
name='template',
|
||||
field=models.ForeignKey(blank=True, help_text='If set, will use this template instead of a VM', null=True, on_delete=django.db.models.deletion.RESTRICT, related_name='clone_template', to='proxmox.lxctemplate'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='clonecontainer',
|
||||
name='vm',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='clone_lxc', to='proxmox.lxc'),
|
||||
),
|
||||
]
|
||||
20
manager/migrations/0005_alter_clonecontainer_vm.py
Normal file
20
manager/migrations/0005_alter_clonecontainer_vm.py
Normal file
@@ -0,0 +1,20 @@
|
||||
# Generated by Django 5.2.4 on 2025-07-14 12:46
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('manager', '0004_alter_clonecontainer_memory_and_more'),
|
||||
('proxmox', '0005_alter_lxc_lxc'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='clonecontainer',
|
||||
name='vm',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='clone_lxc', to='proxmox.lxc', verbose_name='LXC Container'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,31 @@
|
||||
# Generated by Django 5.2.4 on 2025-07-14 13:22
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('manager', '0005_alter_clonecontainer_vm'),
|
||||
('mikrotik', '0002_alter_dnsstatic_comment_alter_ipaddress_comment_and_more'),
|
||||
('proxmox', '0005_alter_lxc_lxc'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='devcontainer',
|
||||
name='dns',
|
||||
field=models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='devcontainer_dns', to='mikrotik.dnsstatic'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='devcontainer',
|
||||
name='lease',
|
||||
field=models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='devcontainer_lease', to='mikrotik.ipdhcplease'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='devcontainer',
|
||||
name='lxc',
|
||||
field=models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='devcontainer_lxc', to='proxmox.lxc'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,38 @@
|
||||
# Generated by Django 5.2.4 on 2025-07-21 11:03
|
||||
|
||||
import django.utils.timezone
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('manager', '0006_alter_devcontainer_dns_alter_devcontainer_lease_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='TaskStatusLog',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('task_id', models.CharField(help_text='The task ID', max_length=150)),
|
||||
('task_result', models.JSONField(help_text='The task result')),
|
||||
],
|
||||
options={
|
||||
'abstract': False,
|
||||
},
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='devcontainer',
|
||||
name='created_at',
|
||||
field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now),
|
||||
preserve_default=False,
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='devcontainer',
|
||||
name='updated_at',
|
||||
field=models.DateTimeField(auto_now=True),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,31 @@
|
||||
# Generated by Django 5.2.4 on 2025-07-23 11:16
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('manager', '0007_taskstatuslog_devcontainer_created_at_and_more'),
|
||||
('mikrotik', '0002_alter_dnsstatic_comment_alter_ipaddress_comment_and_more'),
|
||||
('proxmox', '0007_lxctemplate_net0_alter_lxc_net0'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='devcontainer',
|
||||
name='dns',
|
||||
field=models.OneToOneField(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='devcontainer_dns', to='mikrotik.dnsstatic'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='devcontainer',
|
||||
name='lease',
|
||||
field=models.OneToOneField(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='devcontainer_lease', to='mikrotik.ipdhcplease'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='devcontainer',
|
||||
name='lxc',
|
||||
field=models.OneToOneField(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='devcontainer_lxc', to='proxmox.lxc'),
|
||||
),
|
||||
]
|
||||
18
manager/migrations/0009_add_task_id.py
Normal file
18
manager/migrations/0009_add_task_id.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# Generated manually for task_id field
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('manager', '0008_alter_devcontainer_dns_alter_devcontainer_lease_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='clonecontainer',
|
||||
name='task_id',
|
||||
field=models.CharField(blank=True, help_text='UUID for tracking live status', max_length=36, null=True),
|
||||
),
|
||||
]
|
||||
19
manager/migrations/0010_alter_clonecontainer_task_id.py
Normal file
19
manager/migrations/0010_alter_clonecontainer_task_id.py
Normal file
@@ -0,0 +1,19 @@
|
||||
# Generated manually for task_id default
|
||||
|
||||
import uuid
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('manager', '0009_add_task_id'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='clonecontainer',
|
||||
name='task_id',
|
||||
field=models.CharField(default=uuid.uuid4, help_text='UUID for tracking live status', max_length=36),
|
||||
),
|
||||
]
|
||||
0
manager/migrations/__init__.py
Normal file
0
manager/migrations/__init__.py
Normal file
454
manager/models.py
Normal file
454
manager/models.py
Normal file
@@ -0,0 +1,454 @@
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
import uuid
|
||||
|
||||
from django.core.validators import MaxValueValidator, MinValueValidator
|
||||
from django.db import models
|
||||
from django.db.models import Q
|
||||
from django.dispatch import receiver
|
||||
|
||||
from django_proxmox_mikrotik.settings import ProxmoxConfig
|
||||
from lib.db import (
|
||||
BOOLEAN_CHOICES,
|
||||
BaseModel,
|
||||
DateAwareMixin,
|
||||
JOB_STATUS_CHOICES,
|
||||
SearchableMixin,
|
||||
TaskAwareModelMixin
|
||||
)
|
||||
from lib.proxmox import Proxmox
|
||||
from mikrotik.models import DNSStatic, IPAddress, IPDHCPLease
|
||||
|
||||
|
||||
class MinValueValidatorExtended(MinValueValidator):
|
||||
message = 'Ensure this value is greater than or equal to %(limit_value)s. You gave %(value)s.'
|
||||
|
||||
|
||||
class MaxValueValidatorExtended(MaxValueValidator):
|
||||
message = 'Ensure this value is less than or equal to %(limit_value)s. You gave %(value)s.'
|
||||
|
||||
|
||||
def minmaxvalidators(min_value, max_value):
|
||||
return (
|
||||
MinValueValidatorExtended(min_value),
|
||||
MaxValueValidatorExtended(max_value),
|
||||
)
|
||||
|
||||
|
||||
class CloneAbstract(models.Model):
|
||||
class Meta:
|
||||
abstract = True
|
||||
|
||||
hostname = models.CharField(max_length=150, help_text='Will be used as hostname')
|
||||
network = models.ForeignKey(IPAddress, on_delete=models.RESTRICT, related_name='clone_network')
|
||||
cores = models.IntegerField(default=1, validators=minmaxvalidators(1, ProxmoxConfig.MAX_CORES))
|
||||
memory = models.IntegerField(default=1024, validators=minmaxvalidators(256, ProxmoxConfig.MAX_MEM),
|
||||
help_text='in MB')
|
||||
disksize = models.IntegerField(default=10, help_text='in GB',
|
||||
validators=minmaxvalidators(10, ProxmoxConfig.MAX_DISK))
|
||||
as_regexp = models.BooleanField(default=True, choices=BOOLEAN_CHOICES,
|
||||
help_text=r'Add a ".*<hostname>.replace(".",r"\.")$" instead of a hostname')
|
||||
is_active = models.BooleanField(default=False, choices=BOOLEAN_CHOICES)
|
||||
status = models.CharField(max_length=150, null=True, blank=True, default='pending', choices=JOB_STATUS_CHOICES)
|
||||
task_id = models.CharField(max_length=36, default=uuid.uuid4, help_text='UUID for tracking live status')
|
||||
# Lxcs have no Node - FIXME
|
||||
node = models.CharField(max_length=150, default=ProxmoxConfig.NODE, editable=False)
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.hostname} from {self.vm} "
|
||||
|
||||
@property
|
||||
def regexp(self):
|
||||
return f".*{self.hostname.replace('.', r'\.')}" if self.as_regexp else None
|
||||
|
||||
@property
|
||||
def next_vmid(self):
|
||||
raise NotImplemented('Not implemented')
|
||||
|
||||
@property
|
||||
def proxmox_data(self):
|
||||
raise NotImplemented('Not implemented')
|
||||
|
||||
def execute(self):
|
||||
raise NotImplemented('Not implemented')
|
||||
|
||||
|
||||
class CloneContainer(CloneAbstract):
|
||||
_old_active = None
|
||||
vm = models.ForeignKey('proxmox.Lxc', on_delete=models.CASCADE,
|
||||
related_name='clone_lxc', null=True, blank=True,
|
||||
verbose_name='LXC Container')
|
||||
template = models.ForeignKey('proxmox.LxcTemplate', on_delete=models.RESTRICT, related_name='clone_template',
|
||||
null=True, blank=True,
|
||||
help_text='If set, will use this template instead of a VM')
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self._old_active = self.is_active
|
||||
|
||||
def execute(self, task_uuid_override=None, *args, **kwargs):
|
||||
from proxmox.models import Lxc
|
||||
from tasklogger.models import TaskFactory
|
||||
request = kwargs.pop('request', None)
|
||||
|
||||
# Use override task_uuid if provided (from form), otherwise create new task
|
||||
task = TaskFactory(task_uuid=task_uuid_override, request=request)
|
||||
|
||||
task.add_entry(f"Creating container '{self.hostname}'")
|
||||
|
||||
try:
|
||||
data = self.proxmox_data
|
||||
pm = Proxmox()
|
||||
route = data.pop('route')
|
||||
vmdata = data.pop('vm')
|
||||
dnsdata = data.pop('dns')
|
||||
leasedata = data.pop('lease')
|
||||
|
||||
task.add_entry(f"Prepared container data: VM-ID {vmdata['newid']}")
|
||||
|
||||
vmparams = {
|
||||
'hostname': vmdata['hostname'],
|
||||
'description': vmdata['description'],
|
||||
'cores': vmdata['cores'],
|
||||
'memory': vmdata['memory'],
|
||||
}
|
||||
if vmdata['template']:
|
||||
vmparams |= {
|
||||
'vmid': vmdata['newid'],
|
||||
'ostemplate': self.template.volid,
|
||||
'net0': vmdata['net0'],
|
||||
'features': 'nesting=1',
|
||||
'rootfs': f'local-lvm:{vmdata["disksize"]}',
|
||||
}
|
||||
task.add_entry(f"Creating new container from template {self.template.volid}")
|
||||
else:
|
||||
vmparams |= {
|
||||
'newid': vmdata['newid'],
|
||||
}
|
||||
task.add_entry(f"Cloning container {self.vm.vmid}")
|
||||
|
||||
success = False
|
||||
lxc = None
|
||||
lease = None
|
||||
dns = None
|
||||
|
||||
self.status = 'running'
|
||||
super().save()
|
||||
|
||||
# Step 1: Create/Clone Container (wrapper nur um lxc_post)
|
||||
task.add_entry("Starting Proxmox container creation...")
|
||||
|
||||
def _lxc_post():
|
||||
return pm.lxc_post(route, **vmparams)
|
||||
|
||||
# Wrap the proxmox function - this handles UPID monitoring synchronously
|
||||
task.wrap_proxmox_function(_lxc_post)
|
||||
task.add_entry("Container creation completed")
|
||||
success = True
|
||||
|
||||
if success:
|
||||
task.add_entry("Waiting for container to be ready...")
|
||||
time.sleep(3)
|
||||
|
||||
# Step 2: Get container info and update settings
|
||||
task.add_entry("Retrieving container information...")
|
||||
new_vm_data = pm.get_all_lxc(as_dict=True, vmid=vmdata['newid'])
|
||||
if new_vm_data:
|
||||
new_vm_data = new_vm_data[0]
|
||||
lxc = Lxc().from_proxmox(**new_vm_data)
|
||||
lxc.save()
|
||||
task.add_entry(f"Container retrieved: {lxc.name} ({lxc.vmid})")
|
||||
|
||||
# Update container resources if needed
|
||||
changed = False
|
||||
changes = []
|
||||
if self.disksize > lxc.disksize:
|
||||
lxc.disksize = self.disksize
|
||||
changes.append(f"disk: {lxc.disksize}GB")
|
||||
changed = True
|
||||
if self.memory != lxc.memory:
|
||||
lxc.memory = self.memory
|
||||
changes.append(f"memory: {lxc.memory}MB")
|
||||
changed = True
|
||||
if self.cores != lxc.cores:
|
||||
lxc.cores = self.cores
|
||||
changes.append(f"cores: {lxc.cores}")
|
||||
changed = True
|
||||
|
||||
if changed:
|
||||
lxc.save()
|
||||
task.add_entry(f"Updated container resources: {', '.join(changes)}")
|
||||
|
||||
# Step 3: Create DHCP Lease (Mikrotik query)
|
||||
task.add_entry("Creating Mikrotik DHCP lease...")
|
||||
leaseargs = {
|
||||
'mac_address': lxc.hwaddr,
|
||||
'address': self.network.get_next_ip,
|
||||
'comment': f'Container - {self.hostname}',
|
||||
'dynamic': False,
|
||||
}
|
||||
lease = IPDHCPLease.objects.create(**leaseargs)
|
||||
task.add_entry(f"DHCP lease created: {lease.address} → {lease.mac_address}")
|
||||
|
||||
# Step 4: Create DNS Entry (Mikrotik query)
|
||||
task.add_entry("Creating Mikrotik DNS entry...")
|
||||
dnsargs = {}
|
||||
if self.as_regexp:
|
||||
dnsargs['regexp'] = self.regexp
|
||||
task.add_entry(f"Using DNS regexp: {self.regexp}")
|
||||
else:
|
||||
dnsargs['name'] = self.hostname
|
||||
task.add_entry(f"Using DNS hostname: {self.hostname}")
|
||||
|
||||
dns = DNSStatic.objects.create(address=lease.address, **dnsargs)
|
||||
task.add_entry(f"DNS entry created: {dns.name or dns.regexp} → {dns.address}")
|
||||
|
||||
# Step 5: Create DevContainer link
|
||||
task.add_entry("Creating container management entry...")
|
||||
devcontainer = DevContainer.objects.create(
|
||||
lxc=lxc,
|
||||
lease=lease,
|
||||
dns=dns,
|
||||
)
|
||||
task.add_entry("Container management entry created")
|
||||
|
||||
# Step 6: Start Container
|
||||
task.add_entry("Starting container...")
|
||||
lxc.start()
|
||||
task.add_entry("Container started successfully!")
|
||||
|
||||
self.status = 'success'
|
||||
task.status = 'completed'
|
||||
task.save()
|
||||
|
||||
else:
|
||||
error_msg = f"Error retrieving container data for VM {vmdata['newid']}"
|
||||
task.add_entry(error_msg)
|
||||
self.status = 'error'
|
||||
else:
|
||||
self.status = 'error'
|
||||
task.add_entry("Container creation failed")
|
||||
|
||||
except Exception as e:
|
||||
task.add_entry(f"Exception during container creation: {str(e)}")
|
||||
self.status = 'error'
|
||||
logging.exception(e)
|
||||
|
||||
# Cleanup on error
|
||||
task.add_entry("Cleaning up failed resources...")
|
||||
cleanup_errors = []
|
||||
|
||||
try:
|
||||
if isinstance(lease, IPDHCPLease):
|
||||
lease.delete(task=task)
|
||||
task.add_entry("Cleaned up DHCP lease")
|
||||
except Exception as a:
|
||||
cleanup_errors.append(f"DHCP lease: {a}")
|
||||
|
||||
try:
|
||||
if isinstance(dns, DNSStatic):
|
||||
dns.delete(task=task)
|
||||
task.add_entry("Cleaned up DNS entry")
|
||||
except Exception as s:
|
||||
cleanup_errors.append(f"DNS entry: {s}")
|
||||
|
||||
try:
|
||||
if isinstance(lxc, Lxc):
|
||||
lxc.delete(task=task)
|
||||
task.add_entry("Cleaned up container")
|
||||
except Exception as l:
|
||||
cleanup_errors.append(f"Container: {l}")
|
||||
|
||||
if cleanup_errors:
|
||||
task.add_entry(f"Cleanup errors: {', '.join(cleanup_errors)}")
|
||||
else:
|
||||
task.add_entry("Cleanup completed")
|
||||
|
||||
task.status = 'error'
|
||||
task.save()
|
||||
raise e
|
||||
|
||||
finally:
|
||||
# Keep task logs for debugging, cleanup CloneContainer
|
||||
task.unset_as_current()
|
||||
self.delete()
|
||||
|
||||
@property
|
||||
def proxmox_data(self):
|
||||
data = {
|
||||
# https://pve.proxmox.com/pve-docs/api-viewer/#/nodes/{node}/lxc/{vmid}/clone
|
||||
# if template, the just a post to lxc - empty route
|
||||
'route': f'{self.vm.vmid}/clone' if self.vm else '',
|
||||
'vm': {
|
||||
'hostname': self.hostname,
|
||||
'cores': self.cores,
|
||||
'cpus': self.cores,
|
||||
'memory': self.memory,
|
||||
'net0': self.vm.net0 if self.vm else self.template.net0,
|
||||
'disksize': self.disksize,
|
||||
'description': f'Container - {self.hostname} ',
|
||||
'newid': Proxmox().next_vmid,
|
||||
'template': self.template.volid if self.template else None,
|
||||
},
|
||||
'lease': {
|
||||
'comment': f'Container - {self.hostname}'
|
||||
},
|
||||
'dns': {
|
||||
# this is changed if regexp
|
||||
'name': self.hostname,
|
||||
'comment': f'Container - {self.hostname}'
|
||||
},
|
||||
}
|
||||
if regexp := self.regexp:
|
||||
data['dns'].pop('name', None)
|
||||
data['dns']['regexp'] = regexp
|
||||
|
||||
return data
|
||||
|
||||
def clean(self):
|
||||
super().clean()
|
||||
assert self.vm or self.template, "Either vm or template must be set"
|
||||
|
||||
"""
|
||||
class CloneVM(CloneAbstract):
|
||||
vm = models.ForeignKey(VM, on_delete=models.CASCADE, related_name='clone_lxc')
|
||||
"""
|
||||
|
||||
|
||||
class DevContainer(BaseModel, SearchableMixin, TaskAwareModelMixin):
|
||||
lxc = models.OneToOneField('proxmox.Lxc', on_delete=models.SET_NULL, related_name='devcontainer_lxc', null=True)
|
||||
lease = models.OneToOneField(IPDHCPLease, on_delete=models.SET_NULL, related_name='devcontainer_lease', null=True)
|
||||
|
||||
dns = models.OneToOneField(DNSStatic, on_delete=models.SET_NULL, related_name='devcontainer_dns', null=True)
|
||||
|
||||
statuscache_data = models.JSONField(default=dict, editable=False)
|
||||
|
||||
@property
|
||||
def statuscache(self):
|
||||
if self.statuscache_data and self.statuscache_data['last_sync'] > time.time() - 300:
|
||||
logging.debug(f"Return cached status for {self.name} - {self.statuscache_data['last_sync']}")
|
||||
return self.statuscache_data
|
||||
else:
|
||||
return self.sync_statuscache()
|
||||
|
||||
|
||||
|
||||
def sync_statuscache(self):
|
||||
logging.debug(f"No cached status for {self.name} - sync from proxmox")
|
||||
try:
|
||||
self.lxc.sync_from_proxmox()
|
||||
except Exception as e:
|
||||
logging.error(e)
|
||||
try:
|
||||
with Proxmox() as pm:
|
||||
status_data = pm.lxc_get(f"{self.lxc.vmid}/status/current")
|
||||
except Exception as e:
|
||||
logging.error(e)
|
||||
return None
|
||||
try:
|
||||
self.lease.sync_from_router()
|
||||
except Exception as e:
|
||||
logging.error(e)
|
||||
try:
|
||||
status_data['lease_status'] = self.lease.status
|
||||
status_data['last_sync'] = time.time()
|
||||
self.statuscache_data = status_data
|
||||
super().save(update_fields=['statuscache_data'])
|
||||
return status_data
|
||||
except Exception as e:
|
||||
logging.error(e)
|
||||
return None
|
||||
|
||||
def set_statuscache_value(self, key, value):
|
||||
cachedata = self.statuscache
|
||||
cachedata[key]= value
|
||||
with open(self.status_cachefile, 'w') as f:
|
||||
json.dump(cachedata, f)
|
||||
return self
|
||||
|
||||
|
||||
|
||||
|
||||
@classmethod
|
||||
def term_filter(cls, search_string):
|
||||
q = (
|
||||
# Q(lxc__comment__icontains=search_string) |
|
||||
Q(lxc__hostname__icontains=search_string) |
|
||||
Q(lease__comment__icontains=search_string) |
|
||||
Q(dns__name__icontains=search_string) |
|
||||
Q(dns__regexp__icontains=search_string) |
|
||||
Q(lease__address__icontains=search_string)
|
||||
)
|
||||
if str(search_string).isdigit():
|
||||
q |= Q(lxc__vmid=search_string)
|
||||
return q
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
try:
|
||||
return self.lxc.name
|
||||
except Exception as e:
|
||||
logging.error(e)
|
||||
return f"{self.internal_id} - {self.lease} - {self.dns} - {self.lxc}"
|
||||
|
||||
|
||||
@property
|
||||
def hostname(self):
|
||||
try:
|
||||
return self.dns.name or self.dns.regexp
|
||||
except Exception as e:
|
||||
logging.error(e)
|
||||
return f"{self.pk} hostname"
|
||||
|
||||
@property
|
||||
def hwaddr(self):
|
||||
try:
|
||||
return self.lxc.hwaddr
|
||||
except Exception as e:
|
||||
logging.error(e)
|
||||
return f"{self.pk} hwaddr"
|
||||
|
||||
@property
|
||||
def address(self):
|
||||
try:
|
||||
return self.lease.address
|
||||
except Exception as e:
|
||||
logging.error(e)
|
||||
return f"{self.pk} address"
|
||||
|
||||
@property
|
||||
def status(self):
|
||||
try:
|
||||
return self.lease.status
|
||||
except Exception as e:
|
||||
logging.error(e)
|
||||
return f"{self.pk} status"
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
if self.dns.address != self.lease.address:
|
||||
raise ValueError(f"{self.dns.address} != {self.lease.address}")
|
||||
if self.lease.mac_address.upper() != self.lxc.hwaddr.upper():
|
||||
logging.error(f"{self.lease.mac_address} != {self.lxc.hwaddr} - try to change it")
|
||||
self.lease.mac_address = self.lxc.hwaddr
|
||||
self.lease.save()
|
||||
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.name} ({self.address})"
|
||||
|
||||
|
||||
@receiver(models.signals.pre_delete, sender=DevContainer)
|
||||
def before_delete_devcontainer(sender, instance, **kwargs):
|
||||
from tasklogger.models import TaskFactory
|
||||
task = TaskFactory()
|
||||
|
||||
if instance.dns:
|
||||
instance.dns.delete(task=task)
|
||||
if instance.lease:
|
||||
instance.lease.delete(task=task)
|
||||
if instance.lxc:
|
||||
instance.lxc.delete(task=task)
|
||||
|
||||
|
||||
3
manager/tests.py
Normal file
3
manager/tests.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from django.test import TestCase
|
||||
|
||||
# Create your tests here.
|
||||
9
manager/urls.py
Normal file
9
manager/urls.py
Normal file
@@ -0,0 +1,9 @@
|
||||
from django.urls import path
|
||||
from manager import views
|
||||
|
||||
app_name = 'manager'
|
||||
|
||||
urlpatterns = [
|
||||
path('resync-all/', views.sync_all, name='sync_all'),
|
||||
path('test/mikrotik/', views.test_mikrotik, name='test_mikrotik'),
|
||||
]
|
||||
46
manager/views.py
Normal file
46
manager/views.py
Normal file
@@ -0,0 +1,46 @@
|
||||
import json
|
||||
import logging
|
||||
|
||||
from django.contrib import messages
|
||||
from django.db.transaction import atomic
|
||||
from django.forms import model_to_dict
|
||||
from django.shortcuts import HttpResponse, redirect
|
||||
from django_auth_ldap.backend import LDAPBackend
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.http import JsonResponse
|
||||
from django.views.decorators.http import require_http_methods
|
||||
|
||||
from django_proxmox_mikrotik.configs import MikrotikConfig
|
||||
from lib.decorators import force_write
|
||||
from manager.models import CloneContainer
|
||||
|
||||
|
||||
@atomic
|
||||
@force_write
|
||||
def test_mikrotik(request):
|
||||
from mikrotik.models import DNSStatic
|
||||
|
||||
dns = DNSStatic.objects.create(name='test.test1', address='192.168.1.254')
|
||||
logging.debug(model_to_dict(dns))
|
||||
response = dns.sync_to_router()
|
||||
|
||||
return HttpResponse(json.dumps([model_to_dict(dns), response], indent=4, default=lambda x: str(x)),
|
||||
content_type="application/json")
|
||||
|
||||
|
||||
|
||||
|
||||
def sync_all(request):
|
||||
"""TODO - just get the user and check on superuser
|
||||
settings via groups does not work at the moment"""
|
||||
from manager.admin import resync_all
|
||||
|
||||
backend = LDAPBackend()
|
||||
user = backend.authenticate(request, request.user.username, request.POST.get('password'))
|
||||
if user and user.is_superuser:
|
||||
resync_all()
|
||||
messages.success(request, 'Sync all initiated')
|
||||
else:
|
||||
messages.error(request, 'Not authorized')
|
||||
|
||||
return redirect('frontend:dashboard')
|
||||
Reference in New Issue
Block a user