Compare commits

...
4 Commits
Author SHA1 Message Date
ponas d3b6024cd8 ci: prek, and deploy to sega from main
deploy / checks (push) Skipped
deploy / prek (push) Skipped
deploy / deploy-sega (push) Skipped
prek / prek (push) Successful in 16s
checks / checks (push) Successful in 33s
2026-11-02 01:05:58 +01:00
ponas 3ccce3849e feat: production settings from MDB_SETTINGS, DEBUG off, whitenoise 2026-11-02 01:05:58 +01:00
ponas be0e621b28 style: apply ruff and django-upgrade 2026-11-02 01:05:51 +01:00
ponas 1e128e8d9b chore: remove the Python 2 scripts and the fabric deploy 2026-11-02 01:05:38 +01:00
34 changed files with 335 additions and 881 deletions

No files matched your search

+2 -1
View File
@@ -1,9 +1,10 @@
name: checks
# Checks only, nothing here deploys
# Checks only, nothing here deploys. deploy.yaml calls it before deploying.
on:
push:
pull_request:
workflow_call:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
+33
View File
@@ -0,0 +1,33 @@
name: deploy
on:
push:
branches: [main]
jobs:
checks:
if: vars.DEPLOY_SEGA == 'true'
uses: ./.gitea/workflows/checks.yaml
prek:
if: vars.DEPLOY_SEGA == 'true'
uses: ./.gitea/workflows/prek.yaml
# sega's authorized_keys sends this key to mdb-deploy, which reads the sha from the ssh
# command line, builds that commit on the host and switches to it (edb-infra's
# roles/mdb.md).
deploy-sega:
if: vars.DEPLOY_SEGA == 'true'
runs-on: ubuntu-latest
needs:
- checks
- prek
steps:
- name: Deploy
run: |
command -v ssh-agent >/dev/null || ( apt-get update -y && apt-get install openssh-client -y )
eval $(ssh-agent -s)
echo "${{ secrets.SSH_PRIVATE_KEY }}" | tr -d '\r' | ssh-add -
mkdir -p ~/.ssh
chmod 700 ~/.ssh
echo "${{ secrets.SSH_KNOWN_HOSTS }}" >> ~/.ssh/known_hosts
ssh mdb-deploy@sega.neuf.no "${{ github.sha }}"
+14
View File
@@ -0,0 +1,14 @@
name: prek
# The hooks in prek.toml. deploy.yaml calls it before deploying.
on:
push:
pull_request:
workflow_call:
jobs:
prek:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: j178/prek-action@v1
+12
View File
@@ -20,6 +20,18 @@ Run migrations and start the development server:
uv run python manage.py migrate
uv run python manage.py runserver
`mise.toml` sets `DJANGO_DEBUG=1`; without it `DEBUG` is off. Local overrides go in
`mdbsite/local_settings.py`.
## Production
MDB runs on sega under edb-infra's `mdb` role (`roles/mdb.md` there): gunicorn from a
release directory per commit, its settings in the file `MDB_SETTINGS` names
(`/etc/mdb/settings.py`), which `mdbsite/settings.py` executes last. A push to `main` that
passes the checks deploys itself while the repository variable `DEPLOY_SEGA` is `true`
(`.gitea/workflows/deploy.yaml`). The DNS and DHCP sync runs as root on sega:
`sudo mdb-sync`.
## Development tasks
Dump test data:
Vendored
-29
View File
@@ -1,29 +0,0 @@
import os
from fabric import Connection
from invoke import task
@task()
def deploy(c):
"""Make sure proxy_user is set to your neuf username."""
project_path = "/opt/mdb"
proxy_user = os.getenv("DEPLOY_USER", os.getenv("USER"))
c = Connection(
host="gitdeploy@sega.neuf.no",
gateway=Connection("login.neuf.no", user=proxy_user),
)
with (
c.cd(project_path),
c.prefix("source {}/.venv/bin/activate".format(project_path)),
):
c.run("git pull") # Get source
c.run("pip install -U uv")
c.run("uv sync") # install deps in virtualenv
c.run("uv run manage.py collectstatic --noinput") # Collect static
c.run("uv run manage.py migrate") # Run DB migrations
# Reload gunicorn
c.sudo("/usr/bin/supervisorctl pid mdb.neuf.no | xargs kill -HUP", shell=False)
-1
View File
@@ -1 +0,0 @@
default_app_config = "mdb.apps.MdbAppConfig"
+24 -32
View File
@@ -54,9 +54,10 @@ class InterfaceInline(admin.TabularInline):
form = InterfaceForm
def get_queryset(self, request):
return super(InterfaceInline, self).get_queryset(request).select_related()
return super().get_queryset(request).select_related()
@admin.register(Host)
class HostAdmin(admin.ModelAdmin):
ordering = ("hostname",)
inlines = [InterfaceInline]
@@ -114,28 +115,26 @@ class HostAdmin(admin.ModelAdmin):
)
return ", ".join(addresses)
@admin.display(description="in domains")
def in_domain(self, host):
domains = host.interface_set.values_list("domain__domain_name", flat=True)
return ",".join(domains)
in_domain.short_description = "in domains"
@admin.display(boolean=True)
def ipv6_enabled(self, host):
ipv6_ifs = host.interface_set.annotate(num_ipv6=Count("ip6address")).filter(
num_ipv6__gt=0
)
return ipv6_ifs.exists()
ipv6_enabled.boolean = True
# FIXME: custom filter to sort custom field
@staticmethod
def _get_host_warning_message(host, ifs):
ifs_str = ", ".join([_if.name for _if in ifs])
return "Host {} might not be installable since interface(s) {} has no IP set.".format(
host, ifs_str
)
return f"Host {host} might not be installable since interface(s) {ifs_str} has no IP set."
@admin.action(description="Mark selected hosts as PXE installable")
def set_installable(self, request, queryset):
invalid_ifs_per_host = []
@@ -153,10 +152,8 @@ class HostAdmin(admin.ModelAdmin):
request, message="\n".join(invalid_ifs_per_host), level=messages.WARNING
)
set_installable.short_description = "Mark selected hosts as PXE installable"
def get_queryset(self, request):
return super(HostAdmin, self).get_queryset(request).select_related()
return super().get_queryset(request).select_related()
class Ip4AddressInline(admin.TabularInline):
@@ -164,11 +161,10 @@ class Ip4AddressInline(admin.TabularInline):
extra = 0
readonly_fields = ["address", "assigned_to_host"]
@admin.display(description="Assigned to Host")
def assigned_to_host(self, addr):
return addr.interface.host
assigned_to_host.short_description = "Assigned to Host"
class DhcpOptionInline(admin.TabularInline):
model = DhcpOption
@@ -180,6 +176,7 @@ class DhcpCustomFieldInline(admin.TabularInline):
extra = 0
@admin.register(Ip4Subnet)
class Ip4SubnetAdmin(SerialFieldAdminMixin, admin.ModelAdmin):
list_display = [
"name",
@@ -194,32 +191,32 @@ class Ip4SubnetAdmin(SerialFieldAdminMixin, admin.ModelAdmin):
readonly_fields = ["domain_name"]
inlines = [DhcpOptionInline, DhcpCustomFieldInline, Ip4AddressInline]
@admin.display(description="# addresses")
def num_addresses(self, ip4subnet):
subnet = ipaddress.IPv4Network(ip4subnet.network + "/" + ip4subnet.netmask)
return subnet.num_addresses
@admin.display(description="broadcast")
def broadcast_address(self, ip4subnet):
subnet = ipaddress.IPv4Network(ip4subnet.network + "/" + ip4subnet.netmask)
return subnet.broadcast_address
@admin.display(description="first address")
def first_address(self, ip4subnet):
subnet = ipaddress.IPv4Network(ip4subnet.network + "/" + ip4subnet.netmask)
return next(subnet.hosts())
@admin.display(description="last address")
def last_address(self, ip4subnet):
subnet = ipaddress.IPv4Network(ip4subnet.network + "/" + ip4subnet.netmask)
return list(subnet.hosts())[-1]
broadcast_address.short_description = "broadcast"
num_addresses.short_description = "# addresses"
first_address.short_description = "first address"
last_address.short_description = "last address"
def get_queryset(self, request):
# TODO: optimize
return super(Ip4SubnetAdmin, self).get_queryset(request)
return super().get_queryset(request)
@admin.register(Ip6Subnet)
class Ip6SubnetAdmin(SerialFieldAdminMixin, admin.ModelAdmin):
list_display = [
"name",
@@ -234,23 +231,22 @@ class Ip6SubnetAdmin(SerialFieldAdminMixin, admin.ModelAdmin):
def _network(self, ip6subnet):
return ipaddress.IPv6Network(
"%s::/%d" % (ip6subnet.network, ip6subnet.netmask), strict=False
f"{ip6subnet.network}::/{ip6subnet.netmask:d}", strict=False
)
@admin.display(description="# addresses")
def num_addresses(self, ip6subnet):
return self._network(ip6subnet).num_addresses
@admin.display(description="first address")
def first_address(self, ip6subnet):
return self._network(ip6subnet).network_address
@admin.display(description="last address")
def last_address(self, ip6subnet):
# subnet[-1] is O(1); never materialize hosts() for a /64.
return self._network(ip6subnet)[-1]
num_addresses.short_description = "# addresses"
first_address.short_description = "first address"
last_address.short_description = "last address"
class DomainSrvRecordInline(admin.TabularInline):
model = DomainSrvRecord
@@ -277,6 +273,7 @@ class DomainAAAARecordInline(admin.TabularInline):
extra = 0
@admin.register(Domain)
class DomainAdmin(SerialFieldAdminMixin, admin.ModelAdmin):
inlines = [
DomainSrvRecordInline,
@@ -298,6 +295,7 @@ class DomainAdmin(SerialFieldAdminMixin, admin.ModelAdmin):
models.IntegerField: {"widget": NumberInput(attrs={"style": "width: 10em"})},
}
@admin.display(description="Num Records")
def num_records(self, domain):
host_a_records = Host.objects.filter(interface__domain=domain).count()
size = {
@@ -306,15 +304,15 @@ class DomainAdmin(SerialFieldAdminMixin, admin.ModelAdmin):
"TXT": domain.domaintxtrecord_set.count(),
"A": domain.domainarecord_set.count() + host_a_records,
}
return ", ".join(["{}: {}".format(k, v) for k, v in size.items()])
num_records.short_description = "Num Records"
return ", ".join([f"{k}: {v}" for k, v in size.items()])
@admin.register(HostType)
class HostTypeAdmin(admin.ModelAdmin):
list_display = ["host_type", "description", "num_members"]
@admin.register(OperatingSystem)
class OperatingSystemAdmin(admin.ModelAdmin):
list_display = [
"name",
@@ -326,14 +324,8 @@ class OperatingSystemAdmin(admin.ModelAdmin):
]
admin.site.register(Domain, DomainAdmin)
admin.site.register(Host, HostAdmin)
admin.site.register(Ip4Subnet, Ip4SubnetAdmin)
admin.site.register(Ip6Subnet, Ip6SubnetAdmin)
admin.site.register(Ip6Address)
admin.site.register(Nameserver)
admin.site.register(MailExchange)
admin.site.register(OperatingSystem, OperatingSystemAdmin)
admin.site.register(HostType, HostTypeAdmin)
admin.site.register(DhcpConfig)
admin.site.register(DhcpOption)
+4 -6
View File
@@ -22,7 +22,7 @@ class SerialNumberInput(NumberInput):
def render(self, name, value, attrs=None, renderer=None):
field = super().render(name, value, attrs, renderer)
field_id = (attrs or {}).get("id", "id_%s" % name)
field_id = (attrs or {}).get("id", f"id_{name}")
extra = """
<button type="button" class="button" style="margin-left: 0.5em;"
onclick="mdbBumpSerial('__ID__')">Today + next serial</button>
@@ -44,9 +44,7 @@ if (typeof window.mdbBumpSerial === 'undefined') {
};
}
</script>
""".replace(
"__ID__", field_id
)
""".replace("__ID__", field_id)
return mark_safe(field + extra)
@@ -55,7 +53,7 @@ class Ip4ModelChoiceField(ModelChoiceField):
if not hasattr(obj, "interface"):
return obj.address
return "{} ({})".format(obj.address, obj.interface.host.hostname)
return f"{obj.address} ({obj.interface.host.hostname})"
class InterfaceForm(forms.ModelForm):
@@ -71,7 +69,7 @@ class InterfaceForm(forms.ModelForm):
raise ValidationError("Macaddr already in this subnet")
def __init__(self, *args, **kwargs):
super(InterfaceForm, self).__init__(*args, **kwargs)
super().__init__(*args, **kwargs)
# Join Ip4Address with tables interface and host,
# before printing the select labels that walk those two relations.
+5 -6
View File
@@ -42,7 +42,7 @@ class Command(BaseCommand):
config = service.get_config()
if not service.needs_update(config):
self.stdout.write("Nothing to do, serial is the same: %s\n" % config.serial)
self.stdout.write(f"Nothing to do, serial is the same: {config.serial}\n")
return
if output is None:
@@ -64,20 +64,19 @@ class Command(BaseCommand):
self.stdout.write("fail\n")
message = (
"Failed to restart isc-dhcp-server, please inspect...\n\n"
"%s@%s$ %s\n%s"
% (os.getlogin(), os.uname()[1], service.restart_command, output)
f"{os.getlogin()}@{os.uname()[1]}$ {service.restart_command}\n{output}"
)
discord.notify(
subject="⚠️ FAIL: dhcpd %s -> %s" % (old_serial, config.active_serial),
subject=f"⚠️ FAIL: dhcpd {old_serial} -> {config.active_serial}",
message=message,
preformatted=diff or None,
)
raise SystemExit(1)
self.stdout.write("ok\n")
message = "Updated dhcpd configuration to serial %d" % config.active_serial
message = f"Updated dhcpd configuration to serial {config.active_serial:d}"
discord.notify(
subject="✅ Success: dhcpd %s -> %s" % (old_serial, config.active_serial),
subject=f"✅ Success: dhcpd {old_serial} -> {config.active_serial}",
message=message,
preformatted=diff or None,
)
+3 -10
View File
@@ -48,7 +48,7 @@ class Command(BaseCommand):
if zone_name:
zone = service.find_zone(zone_name)
if zone is None:
self.stderr.write("error: zone '%s' not found\n" % zone_name)
self.stderr.write(f"error: zone '{zone_name}' not found\n")
raise SystemExit(1)
zones = [zone]
else:
@@ -107,7 +107,7 @@ class Command(BaseCommand):
self.stdout.write("ok\n")
for zone, info in changes.items():
message = "Successfully updated bind zone file for `%s`" % zone
message = f"Successfully updated bind zone file for `{zone}`"
if info["errors"]:
message += "\n\n" + info["errors"]
discord.notify(
@@ -120,14 +120,7 @@ class Command(BaseCommand):
self, summary, failed_command, output, zones, errors="", diffs=""
):
zone_list = "\n".join("- " + z for z in zones)
message = "%s, please inspect...\n\n%s@%s$ %s\n%s\n%s" % (
summary,
os.getlogin(),
os.uname()[1],
failed_command,
output,
zone_list,
)
message = f"{summary}, please inspect...\n\n{os.getlogin()}@{os.uname()[1]}$ {failed_command}\n{output}\n{zone_list}"
if errors:
message += "\n\n" + errors
discord.notify(
+75 -102
View File
@@ -54,33 +54,33 @@ class Domain(models.Model):
return self.domain_name == other.domain_name
def zone_file_contents(self):
content = "; serial:%d\n" % self.domain_serial
content += "; zone file for %s\n" % self.domain_name
content += "; %s\n" % datetime.datetime.now()
content += "; filename: %s\n" % self.domain_filename
content += "$TTL %s\n" % self.domain_ttl
content += "@ IN SOA %s. %s. (\n" % (
content = f"; serial:{self.domain_serial:d}\n"
content += f"; zone file for {self.domain_name}\n"
content += f"; {datetime.datetime.now()}\n"
content += f"; filename: {self.domain_filename}\n"
content += f"$TTL {self.domain_ttl}\n"
content += "@ IN SOA {}. {}. (\n".format(
self.domain_soa,
self.domain_admin.replace("@", "."),
)
content += "\t%d\t; serial\n" % self.domain_serial
content += "\t%d\t; refresh\n" % self.domain_refresh
content += "\t%d\t; retry\n" % self.domain_retry
content += "\t%d\t; expire\n" % self.domain_expire
content += "\t%d )\t; minimum ttl\n" % self.domain_minimum_ttl
content += f"\t{self.domain_serial:d}\t; serial\n"
content += f"\t{self.domain_refresh:d}\t; refresh\n"
content += f"\t{self.domain_retry:d}\t; retry\n"
content += f"\t{self.domain_expire:d}\t; expire\n"
content += f"\t{self.domain_minimum_ttl:d} )\t; minimum ttl\n"
content += ";\n"
for nameserver in self.domain_nameservers.all():
content += "@\tIN\tNS\t%s.\n" % nameserver.hostname
content += f"@\tIN\tNS\t{nameserver.hostname}.\n"
for mx in self.domain_mailexchanges.all():
content += "@\t\tMX\t%d %s.\n" % (mx.priority, mx.hostname)
content += f"@\t\tMX\t{mx.priority:d} {mx.hostname}.\n"
if self.domain_ipaddr is not None:
content += "@\tIN\tA\t%s\n" % self.domain_ipaddr
content += f"@\tIN\tA\t{self.domain_ipaddr}\n"
if self.domain_ip6addr:
content += "@\tIN\tAAAA\t%s\n" % self.domain_ip6addr
content += f"@\tIN\tAAAA\t{self.domain_ip6addr}\n"
content += "; SRV records\n"
@@ -100,7 +100,7 @@ class Domain(models.Model):
content += "; CNAME records \n"
for cname in self.domaincnamerecord_set.all():
content += "%s\tIN\tCNAME\t%s\n" % (cname.name, cname.target)
content += f"{cname.name}\tIN\tCNAME\t{cname.target}\n"
content += "; TXT records \n"
@@ -112,16 +112,12 @@ class Domain(models.Model):
for interface in self.interface_set.all():
host = interface.host
if interface.ip4address:
content += "%-20s\tIN\tA\t%s\n" % (
host.hostname,
interface.ip4address.address,
content += (
f"{host.hostname:<20}\tIN\tA\t{interface.ip4address.address}\n"
)
for ipv6addr in interface.ip6address_set.all():
content += "%-20s\tIN\tAAAA\t%s\n" % (
host.hostname,
ipv6addr.full_address(),
)
content += f"{host.hostname:<20}\tIN\tAAAA\t{ipv6addr.full_address()}\n"
if host.allow_email and interface.ip4address:
content += f'{host.hostname}\tTXT\t"v=spf1 mx ip4:{interface.ip4address.address} -all"\n'
@@ -143,15 +139,7 @@ class DomainSrvRecord(models.Model):
created_date = models.DateTimeField(auto_now_add=True)
def as_record(self):
return "{}.{}.{}. IN SRV {} {} {} {}.".format(
self.srvce,
self.prot,
self.name,
self.priority,
self.weight,
self.port,
self.target,
)
return f"{self.srvce}.{self.prot}.{self.name}. IN SRV {self.priority} {self.weight} {self.port} {self.target}."
def __str__(self):
return self.as_record()
@@ -167,7 +155,7 @@ class DomainTxtRecord(models.Model):
created_date = models.DateTimeField(auto_now_add=True)
def as_record(self):
return "{} TXT {}".format(self.name, self.target)
return f"{self.name} TXT {self.target}"
def __str__(self):
return self.as_record()
@@ -180,7 +168,7 @@ class DomainCnameRecord(models.Model):
created_date = models.DateTimeField(auto_now_add=True)
def as_record(self):
return "{} IN CNAME {}".format(self.name, self.target)
return f"{self.name} IN CNAME {self.target}"
def __str__(self):
return self.as_record()
@@ -193,7 +181,7 @@ class DomainARecord(models.Model):
created_date = models.DateTimeField(auto_now_add=True)
def as_record(self):
return "{} IN A {}".format(self.name, self.target)
return f"{self.name} IN A {self.target}"
def __str__(self):
return self.as_record()
@@ -206,7 +194,7 @@ class DomainAAAARecord(models.Model):
created_date = models.DateTimeField(auto_now_add=True)
def as_record(self):
return "{} IN AAAA {}".format(self.name, self.target)
return f"{self.name} IN AAAA {self.target}"
def __str__(self):
return self.as_record()
@@ -223,29 +211,28 @@ class DhcpConfig(models.Model):
log_facility = models.CharField(max_length=255)
def dhcpd_configuration(self):
content = "# Autogenerated configuration %s\n" % datetime.datetime.now()
content = f"# Autogenerated configuration {datetime.datetime.now()}\n"
if self.authoritative:
content += "authoritative;\n"
content += "default-lease-time %d;\n" % self.default_lease_time
content += "max-lease-time %d;\n" % self.max_lease_time
content += "log-facility %s;\n" % self.log_facility
content += "ddns-update-style %s;\n" % self.ddns_update_style
content += f"default-lease-time {self.default_lease_time:d};\n"
content += f"max-lease-time {self.max_lease_time:d};\n"
content += f"log-facility {self.log_facility};\n"
content += f"ddns-update-style {self.ddns_update_style};\n"
# time to write the subnet definitions
for subnet in self.ip4subnet_set.all():
content += "\n# %s\n" % subnet.name
content += "subnet %s netmask %s {\n" % (subnet.network, subnet.netmask)
content += f"\n# {subnet.name}\n"
content += f"subnet {subnet.network} netmask {subnet.netmask} {{\n"
for option in subnet.dhcpoption_set.all():
content += "\toption %s %s;\n" % (option.key, option.value)
content += f"\toption {option.key} {option.value};\n"
for option in subnet.dhcpcustomfield_set.all():
content += "\t%s;\n" % option.value
content += f"\t{option.value};\n"
if subnet.dhcp_dynamic:
content += "\trange %s %s;\n" % (
subnet.dhcp_dynamic_start,
subnet.dhcp_dynamic_end,
content += (
f"\trange {subnet.dhcp_dynamic_start} {subnet.dhcp_dynamic_end};\n"
)
content += "}\n"
@@ -259,14 +246,13 @@ class DhcpConfig(models.Model):
):
continue
_if = ip4address.interface
content += "\nhost %s {\n" % _if.host.hostname
content += "\thardware ethernet %s;\n" % _if.macaddr
content += "\tfixed-address %s.%s;\n" % (
_if.host.hostname,
_if.domain.domain_name,
content += f"\nhost {_if.host.hostname} {{\n"
content += f"\thardware ethernet {_if.macaddr};\n"
content += (
f"\tfixed-address {_if.host.hostname}.{_if.domain.domain_name};\n"
)
if len(_if.pxe_filename) > 0:
content += '\tfilename "%s";\n' % _if.pxe_filename
content += f'\tfilename "{_if.pxe_filename}";\n'
content += "}\n"
return content
@@ -307,30 +293,30 @@ class Ip6Subnet(models.Model):
Delegation in ip6.arpa happens on nibble (4-bit) boundaries, so the
prefix is truncated down to whole nibbles.
"""
network = ipaddress.IPv6Address("%s::" % self.network)
network = ipaddress.IPv6Address(f"{self.network}::")
nibbles = self.netmask // 4
return ".".join(network.exploded.replace(":", "")[:nibbles])[::-1] + ".ip6.arpa"
def zone_file_contents(self, generate_unassigned=False):
content = ""
content += "; zone file for %s\n" % self.domain_name
content += "; %s\n" % datetime.datetime.now()
content += "; filename: %s\n" % self.domain_filename
content += "$TTL %s\n" % self.domain_ttl
content += "@ IN SOA %s. %s. (\n" % (
content += f"; zone file for {self.domain_name}\n"
content += f"; {datetime.datetime.now()}\n"
content += f"; filename: {self.domain_filename}\n"
content += f"$TTL {self.domain_ttl}\n"
content += "@ IN SOA {}. {}. (\n".format(
self.domain_soa,
self.domain_admin.replace("@", "."),
)
content += "\t%d\t; serial\n" % self.domain_serial
content += "\t%d\t; refresh\n" % self.domain_refresh
content += "\t%d\t; retry\n" % self.domain_retry
content += "\t%d\t; expire\n" % self.domain_expire
content += "\t%d )\t; minimum ttl\n" % self.domain_minimum_ttl
content += f"\t{self.domain_serial:d}\t; serial\n"
content += f"\t{self.domain_refresh:d}\t; refresh\n"
content += f"\t{self.domain_retry:d}\t; retry\n"
content += f"\t{self.domain_expire:d}\t; expire\n"
content += f"\t{self.domain_minimum_ttl:d} )\t; minimum ttl\n"
content += ";\n"
content += ";\n"
for nameserver in self.domain_nameservers.all():
content += "@\tIN\tNS\t%s.\n" % nameserver.hostname
content += f"@\tIN\tNS\t{nameserver.hostname}.\n"
content += ";\n"
@@ -343,14 +329,13 @@ class Ip6Subnet(models.Model):
if addr.interface.domain is None:
continue
hostname = "%s.%s" % (
addr.interface.host.hostname,
addr.interface.domain.domain_name,
hostname = (
f"{addr.interface.host.hostname}.{addr.interface.domain.domain_name}"
)
ip = ipaddress.IPv6Address(self.network + addr.address)
ip = ".".join(ip.exploded.replace(":", "")[16:])[::-1]
content += "%s\tPTR\t%s.\n" % (ip, hostname)
content += f"{ip}\tPTR\t{hostname}.\n"
# addr = ipaddr.IPv6Address("%s%s" % (interface.
# content += "%20s\tIN\tPTR\t%s.\n" % (addr.address.split(".")[3], hostname)
@@ -401,51 +386,43 @@ class Ip4Subnet(models.Model):
This is the apex BIND expects -- it must match the zone name in
named.conf or rndc refuses to load the file.
"""
net = ipaddress.IPv4Network(
"%s/%s" % (self.network, self.netmask), strict=False
)
net = ipaddress.IPv4Network(f"{self.network}/{self.netmask}", strict=False)
o1, o2, o3, o4 = str(net.network_address).split(".")
rev = "%s.%s.%s" % (o3, o2, o1)
rev = f"{o3}.{o2}.{o1}"
if net.prefixlen > 24:
return "%s-%d.%s.in-addr.arpa" % (o4, net.prefixlen, rev)
return "%s.in-addr.arpa" % rev
return f"{o4}-{net.prefixlen:d}.{rev}.in-addr.arpa"
return f"{rev}.in-addr.arpa"
def zone_file_contents(self, generate_unassigned=False):
content = ""
content += "; zone file for %s\n" % self.domain_name
content += "; %s\n" % datetime.datetime.now()
content += "; filename: %s\n" % self.domain_filename
content += "$TTL %s\n" % self.domain_ttl
content += "@ IN SOA %s. %s. (\n" % (
content += f"; zone file for {self.domain_name}\n"
content += f"; {datetime.datetime.now()}\n"
content += f"; filename: {self.domain_filename}\n"
content += f"$TTL {self.domain_ttl}\n"
content += "@ IN SOA {}. {}. (\n".format(
self.domain_soa,
self.domain_admin.replace("@", "."),
)
content += "\t%d\t; serial\n" % self.domain_serial
content += "\t%d\t; refresh\n" % self.domain_refresh
content += "\t%d\t; retry\n" % self.domain_retry
content += "\t%d\t; expire\n" % self.domain_expire
content += "\t%d )\t; minimum ttl\n" % self.domain_minimum_ttl
content += f"\t{self.domain_serial:d}\t; serial\n"
content += f"\t{self.domain_refresh:d}\t; refresh\n"
content += f"\t{self.domain_retry:d}\t; retry\n"
content += f"\t{self.domain_expire:d}\t; expire\n"
content += f"\t{self.domain_minimum_ttl:d} )\t; minimum ttl\n"
content += ";\n"
content += ";\n"
for nameserver in self.domain_nameservers.all():
content += "@\tIN\tNS\t%s.\n" % nameserver.hostname
content += f"@\tIN\tNS\t{nameserver.hostname}.\n"
content += ";\n"
for addr in self.ip4address_set.all():
""" Get PTR records for each v4 address in subnet """
if hasattr(addr, "interface") and addr.interface.domain:
hostname = "%s.%s" % (
addr.interface.host.hostname,
addr.interface.domain.domain_name,
)
content += "%-20s\tIN\tPTR\t%s.\n" % (
addr.address.split(".")[3],
hostname,
)
hostname = f"{addr.interface.host.hostname}.{addr.interface.domain.domain_name}"
content += f"{addr.address.split('.')[3]:<20}\tIN\tPTR\t{hostname}.\n"
elif not hasattr(addr, "interface") and generate_unassigned:
content += "%s\tIN\tPTR\t%s.%s\n" % (
content += "{}\tIN\tPTR\t{}.{}\n".format(
addr.address,
addr.address.split(".")[3],
"dhcp.neuf.no.",
@@ -580,7 +557,7 @@ class Interface(models.Model):
domain = models.ForeignKey(Domain, models.CASCADE)
def __str__(self):
return "%s (%s on %s)" % (self.macaddr, self.name, self.host.hostname)
return f"{self.macaddr} ({self.name} on {self.host.hostname})"
def ipv6_enabled(self):
return self.ip6address_set.exists()
@@ -595,11 +572,7 @@ class Ip6Address(models.Model):
return self.subnet.network + self.address
def __str__(self):
return "%s (%s on %s)" % (
self.full_address(),
self.interface.name,
self.interface.host.hostname,
)
return f"{self.full_address()} ({self.interface.name} on {self.interface.host.hostname})"
class Meta:
verbose_name = "IPv6 address"
+3 -4
View File
@@ -1,6 +1,7 @@
import ipaddress
from django.conf import settings
from django.template.loader import render_to_string
import ipaddress
def render_pxelinux_cfg(_if, host):
@@ -35,9 +36,7 @@ def get_pxe_filename(interface, filename_format="mac_addr"):
Mac or IP address based PXE filename
Ref: http://www.syslinux.org/wiki/index.php/PXELINUX#Examples
"""
ip_addr_in_hex = "{:02X}".format(
int(ipaddress.IPv4Address(interface.ip4address.address))
)
ip_addr_in_hex = f"{int(ipaddress.IPv4Address(interface.ip4address.address)):02X}"
formats = {
"ip_addr": ip_addr_in_hex,
"mac_addr": "01-{}".format(interface.macaddr.lower().replace(":", "-")),
+2 -2
View File
@@ -33,9 +33,9 @@ class DhcpSyncService:
Returns (old_serial, diff).
"""
try:
with open(output_path, "r") as f:
with open(output_path) as f:
old = f.read()
except IOError:
except OSError:
old = ""
new = config.dhcpd_configuration()
+10 -21
View File
@@ -12,9 +12,9 @@ from mdb.models import Domain, Ip4Subnet, Ip6Subnet
class ZoneSyncService:
rndc_bin = shutil.which("rndc") or "/usr/sbin/rndc"
freeze_command = "%s freeze" % rndc_bin
reload_command = "%s reload" % rndc_bin
thaw_command = "%s thaw" % rndc_bin
freeze_command = f"{rndc_bin} freeze"
reload_command = f"{rndc_bin} reload"
thaw_command = f"{rndc_bin} thaw"
checkzone_bin = shutil.which("named-checkzone") or "/usr/sbin/named-checkzone"
compilezone_bin = shutil.which("named-compilezone") or "/usr/sbin/named-compilezone"
checkzone_dir = "/tmp/mdb-checkzone"
@@ -28,7 +28,7 @@ class ZoneSyncService:
def validate_binaries_exist(self):
for f in (self.checkzone_bin, self.compilezone_bin, self.rndc_bin):
if not os.path.isfile(f) and not self.debug:
print("ERROR: no such file %s, exiting..." % f)
print(f"ERROR: no such file {f}, exiting...")
sys.exit(1)
def run_commands(self, commands):
@@ -76,9 +76,9 @@ class ZoneSyncService:
zonetype = getattr(zone._meta, "verbose_name", None) or zone._meta.model_name
try:
with open(zone.domain_filename, "r") as f:
with open(zone.domain_filename) as f:
old = f.read()
except IOError:
except OSError:
old = ""
# Dynamic updates (ACME dns-01 challenges) raise the serial in the
@@ -97,13 +97,8 @@ class ZoneSyncService:
}
print(
"updating %s %s [%d -> %d]"
% (
zonetype,
zone.domain_name,
zone.domain_active_serial,
zone.domain_serial,
)
f"updating {zonetype} {zone.domain_name} "
f"[{zone.domain_active_serial:d} -> {zone.domain_serial:d}]"
)
# Normalize to BIND's canonical format so the on-disk file and our
@@ -161,11 +156,7 @@ class ZoneSyncService:
tmpfile = f.name
try:
cmd = "%s -f text -F text -s relative -i none -o - %s %s" % (
self.compilezone_bin,
zone.domain_name,
tmpfile,
)
cmd = f"{self.compilezone_bin} -f text -F text -s relative -i none -o - {zone.domain_name} {tmpfile}"
status, output = getstatusoutput(cmd + " 2>/dev/null")
if status == 0:
return output
@@ -189,6 +180,4 @@ class ZoneSyncService:
with open(zonefile, "w") as f:
f.write(contents)
return getstatusoutput(
"%s %s %s" % (self.checkzone_bin, zone.domain_name, zonefile)
)
return getstatusoutput(f"{self.checkzone_bin} {zone.domain_name} {zonefile}")
+8 -7
View File
@@ -1,12 +1,13 @@
import ipaddress
import logging
import os
import uuid
from django.conf import settings
from django.db.models.signals import pre_delete, post_save, pre_save
from django.dispatch import receiver
import ipaddress
from mdb.models import Interface, Host, Ip6Subnet, Ip4Subnet, Ip4Address
from django.conf import settings
from django.db.models.signals import post_save, pre_delete, pre_save
from django.dispatch import receiver
from mdb.models import Host, Interface, Ip4Address, Ip4Subnet, Ip6Subnet
from mdb.utils import format_domain_serial_and_add_one
logger = logging.getLogger(__name__)
@@ -106,7 +107,7 @@ def create_pxe_key_and_write_pxe_files_when_host_changes(
# Write pxeconfig file to disk
with open(path, "w+") as f:
f.write(pxe_file)
logger.info("Created or updated {}".format(path))
logger.info(f"Created or updated {path}")
os.chmod(path, 0o644) # Not writeable for all
# Also set necessary fields for pxe installation
@@ -128,7 +129,7 @@ def create_pxe_key_and_write_pxe_files_when_host_changes(
if os.path.exists(path):
# Delete pxeconfig file from disk
os.unlink(path)
logger.info("deleted {}".format(path))
logger.info(f"deleted {path}")
@receiver(pre_delete, sender=Interface)
+1 -1
View File
@@ -4,4 +4,4 @@ timeout 0
label ninja
kernel {{kernel}}
append ramdisk_size=14984 locale=en_US console-setup/ask_detect=false keyboard-configuration/layoutcode=no netcfg/wireless_wep= netcfg_choose_interface=eth0 netcfg/get_hostname= preseed/url={{preseed_config_url}} vga=normal snop={{host.pxe_key}} initrd={{initrd}} -- quiet
append ramdisk_size=14984 locale=en_US console-setup/ask_detect=false keyboard-configuration/layoutcode=no netcfg/wireless_wep= netcfg_choose_interface=eth0 netcfg/get_hostname= preseed/url={{preseed_config_url}} vga=normal snop={{host.pxe_key}} initrd={{initrd}} -- quiet
+4 -4
View File
@@ -5,7 +5,7 @@ import re
def format_domain_serial_and_add_one(serial):
today = datetime.datetime.now()
res = re.findall(
r"^%4d%02d%02d(\d\d)$" % (today.year, today.month, today.day),
rf"^{today.year:4d}{today.month:02d}{today.day:02d}(\d\d)$",
str(serial),
re.DOTALL,
)
@@ -14,7 +14,7 @@ def format_domain_serial_and_add_one(serial):
""" This probably means that the serial is malformed
or the date is wrong. We assume that if the date is wrong,
it is in the past. Just create a new serial starting from 1."""
return "%4d%02d%02d%02d" % (today.year, today.month, today.day, 1)
return f"{today.year:4d}{today.month:02d}{today.day:02d}01"
elif len(res) == 1:
""" The serial contains todays date, just update it. """
try:
@@ -28,7 +28,7 @@ def format_domain_serial_and_add_one(serial):
else:
number += 1
return "%4d%02d%02d%02d" % (today.year, today.month, today.day, number)
return f"{today.year:4d}{today.month:02d}{today.day:02d}{number:02d}"
else:
""" Just return the first serial for today. """
return "%4d%02d%02d%02d" % (today.year, today.month, today.day, 1)
return f"{today.year:4d}{today.month:02d}{today.day:02d}01"
+2 -1
View File
@@ -1,6 +1,7 @@
from django.core.validators import RegexValidator
import re
from django.core.validators import RegexValidator
# http://en.wikipedia.org/wiki/Hostname#Restrictions_on_valid_host_names
hostname_re = re.compile(r"^(?!-)[-a-z0-9]+(?<!-)$", re.IGNORECASE)
validate_hostname = RegexValidator(
+19 -7
View File
@@ -2,9 +2,10 @@ import os
BASE_DIR = os.path.dirname(os.path.dirname(__file__))
DEBUG = os.getenv("DJANGO_DEBUG", True)
# Off unless DJANGO_DEBUG says otherwise; mise.toml turns it on for development.
DEBUG = os.getenv("DJANGO_DEBUG", "").lower() in ("1", "true", "yes")
ADMINS = (("System Administrator", "sysadmin@example.com"),)
ADMINS = ("sysadmin@example.com",)
MANAGERS = ADMINS
@@ -35,10 +36,12 @@ STATICFILES_FINDERS = (
"django.contrib.staticfiles.finders.AppDirectoriesFinder",
)
# Make this unique, and don't share it with anybody.
# For development only: the repository is public. Production sets its own (MDB_SETTINGS).
SECRET_KEY = "@4ps=ebofcpv8sb#y7gm896=l&l*u0u-pgo7bcdj%+(k+2lqnl"
MIDDLEWARE = (
# Serves STATIC_ROOT, so production needs nothing in front but a proxy.
"whitenoise.middleware.WhiteNoiseMiddleware",
"django.middleware.common.CommonMiddleware",
"django.contrib.sessions.middleware.SessionMiddleware",
"django.middleware.csrf.CsrfViewMiddleware",
@@ -132,7 +135,16 @@ SENTRY_ENVIRONMENT = os.getenv("SENTRY_ENVIRONENT", "dev")
ADMIN_SITE_NAME = "MDB"
try:
from .local_settings import *
except ImportError:
pass
# Production's settings live outside the code, in the file MDB_SETTINGS names; the mdb role
# in edb-infra writes it. An error in it stops MDB rather than leaving the defaults above.
if os.getenv("MDB_SETTINGS"):
with open(os.environ["MDB_SETTINGS"]) as f:
exec(compile(f.read(), os.environ["MDB_SETTINGS"], "exec"))
else:
# Development. Only a missing local_settings is skipped: a local_settings whose own
# imports fail must not leave MDB running on the defaults.
try:
from .local_settings import * # noqa: F403
except ModuleNotFoundError as e:
if e.name != "mdbsite.local_settings":
raise
+3 -2
View File
@@ -1,13 +1,14 @@
from django.contrib import admin
from django.contrib.staticfiles.urls import staticfiles_urlpatterns
from django.urls import re_path, path
from django.urls import path
from django.views.generic.base import RedirectView
from mdb.api.views import HostPXEValidate
admin.autodiscover()
urlpatterns = [
re_path(r"^$", RedirectView.as_view(url="admin/", permanent=True)),
path("", RedirectView.as_view(url="admin/", permanent=True)),
path("admin/", admin.site.urls),
path(
"api/validate-host-secret/",
+2 -1
View File
@@ -1,6 +1,7 @@
from django.core.wsgi import get_wsgi_application
import os
from django.core.wsgi import get_wsgi_application
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "mdbsite.settings")
application = get_wsgi_application()
+4
View File
@@ -1,3 +1,7 @@
[tools]
python = "3.14"
uv = "latest"
[env]
# settings.py leaves DEBUG off unless this says otherwise.
DJANGO_DEBUG = "1"
+32
View File
@@ -0,0 +1,32 @@
# Git hooks, run by prek (https://prek.j178.dev). `prek install` once per clone; CI runs
# them all (.gitea/workflows/prek.yaml).
[[repos]]
repo = "https://github.com/astral-sh/ruff-pre-commit"
rev = "v0.15.6" # keep in sync with ruff in uv.lock
hooks = [
{ id = "ruff-check", args = ["--fix"] },
{ id = "ruff-format" },
]
[[repos]]
repo = "https://github.com/adamchainz/django-upgrade"
rev = "1.33.0"
hooks = [{ id = "django-upgrade", args = ["--target-version", "6.0"] }]
[[repos]]
repo = "https://github.com/pre-commit/pre-commit-hooks"
rev = "v6.0.0"
hooks = [
{ id = "trailing-whitespace" },
{ id = "end-of-file-fixer" },
{ id = "check-merge-conflict" },
{ id = "check-added-large-files" },
{ id = "check-toml" },
{ id = "check-yaml" },
]
[[repos]]
repo = "https://github.com/astral-sh/uv-pre-commit"
rev = "0.13.6"
hooks = [{ id = "uv-lock" }] # uv.lock stays in sync with pyproject.toml
+3 -3
View File
@@ -11,21 +11,21 @@ dependencies = [
"django>=6.0.3",
"django-extensions>=4.1",
"djangorestframework>=3.17.0",
"fabric>=3.2.2",
"freezegun>=1.5.5",
"gunicorn>=25.1.0",
"ipaddress>=1.0.23,<2.0.0",
"requests>=2.32.5",
"sentry-sdk>=2.55.0",
"whitenoise>=6.12,<7",
]
[dependency-groups]
# Only needed on the server (LDAP is configured in local_settings.py).
# Only needed on the server (LDAP is configured in production's settings).
# Kept separate so CI can skip building python-ldap from source.
prod = [
"django-auth-ldap>=5.3.0",
]
dev = [
"freezegun>=1.5.5",
"pytest>=9.0.2",
"pytest-django>=4.12.0",
"ruff>=0.15.6,<1.0.0",
-8
View File
@@ -1,8 +0,0 @@
To use this scripts, you have to add the projects location to your environment
pythonpath. This is easily archieved by setting an environment variable
like this:
export PYTHONPATH=/opt/projects/:/opt/projects/dns_mdb
It is important that both the path above the project and the project
folder itself are added to the path before the script is run.
-8
View File
@@ -1,8 +0,0 @@
#!/bin/bash
TMP1=$(bash -c "cd .. ; pwd ; echo \":\" ; cd .. ; pwd")
TMP2=$(echo $TMP1 | sed -e "s/ //g")
echo "Run the following to setup the python environment."
echo "export PYTHONPATH=$TMP2"
-105
View File
@@ -1,105 +0,0 @@
#!/usr/bin/env python
# coding: utf-8
# FIXME: python2
import os,sys,datetime,argparse,threading,time,Queue
from commands import getstatusoutput
parser = argparse.ArgumentParser(description = 'Ping hosts from mdb')
parser.add_argument('-t', '--type', default='all',\
help="Which type of host to operate on")
parser.add_argument('-d', '--debug', action='store_true',\
help="Turn on debugging")
parser.add_argument('--show-types', dest='show_types', action='store_true',\
help='Show available host types')
parser.add_argument('--num-threads', dest='num_threads', type=int, default=1,\
help='Number of threads used for pinging hosts')
parser.add_argument('--num-pings', dest='num_pings', type=int, default=5,\
help='Number of ping requests to each host. More requests gives a more accurate average calculation.')
args = parser.parse_args()
#project_path = '/opt/django/dns_mdb/'
#if project_path not in sys.path:
# sys.path.append(project_path)
from django.core.management import setup_environ
from django.core.mail import mail_admins
from dns_mdb import settings
setup_environ(settings)
from mdb.models import *
PING_CMD='ping -W 1 -c %d -n -q %s | grep rtt | cut -d " " -f4'
queue = Queue.Queue()
class PingThread(threading.Thread):
""" Threaded ping """
def __init__(self, host_queue):
threading.Thread.__init__(self)
self.queue = queue
def run(self):
while True:
host = self.queue.get()
self.do_ping_host(host)
self.queue.task_done()
def do_ping_host(self, host):
interfaces = host.interface_set.all()
for interface in interfaces:
self.do_ping_interface(interface, host)
def do_ping_interface(self, interface, host):
res = self.do_ping_ipaddr(interface.ip4address.address)
if res == None:
if args.debug:
print "%s (%s/%s) : fail " % (host, \
interface.name, interface.ip4address.address)
return
interface.ip4address.last_contact = datetime.datetime.now()
interface.ip4address.ping_avg_rtt = float(res[1])
interface.ip4address.save()
if args.debug:
print "%s (%s/%s) : %s" % (host, interface.name, \
interface.ip4address.address, res)
def do_ping_ipaddr(self, ipaddr):
status, output = getstatusoutput(PING_CMD % (args.num_pings,ipaddr))
if status == 0 and len(output) > 0:
return output.split("/")
else:
return None
def show_host_types():
types = HostType.objects.all()
for t in types:
print "%s (%s)" % (t.host_type, t.description)
if args.show_types:
show_host_types()
sys.exit(0)
if args.type == "all":
hosts = Host.objects.all()
else:
hosts = Host.objects.filter(host_type__host_type = args.type)
# start num_threads threads for pinging
for i in xrange(args.num_threads):
t = PingThread(queue)
t.setDaemon(True)
t.start()
if args.debug:
print "Pinging %d hosts using %d threads." % (len(hosts), args.num_threads)
# Add all hosts to the queue. Ping threads
# will pick hosts from this queue.
for host in hosts:
queue.put(host)
# Wait for all threads to finish, eg. queue is empty.
queue.join()
-157
View File
@@ -1,157 +0,0 @@
#!/usr/bin/env python
# coding: utf-8
# FIXME: python2
import os,sys,datetime,argparse,threading,time,Queue
from commands import getstatusoutput
parser = argparse.ArgumentParser(description = 'Ping hosts from mdb')
parser.add_argument('-t', '--type', default='all',\
help="Which type of host to operate on")
parser.add_argument('-d', '--debug', action='store_true',\
help="Turn on debugging")
parser.add_argument('--show-types', dest='show_types', action='store_true',\
help='Show available host types')
parser.add_argument('--num-threads', dest='num_threads', type=int, default=1,\
help='Number of threads used for pinging hosts')
parser.add_argument('--num-pings', dest='num_pings', type=int, default=5,\
help='Number of ping requests to each host. More requests gives a more accurate average calculation.')
parser.add_argument('--rrd-path', dest='rrd_path', required=True,\
help='Specify the path to the rrd files')
parser.add_argument('--graph-path', dest='graph_path', required=True,\
help="Specify the output directory for the graphs")
args = parser.parse_args()
from django.core.management import setup_environ
from django.core.mail import mail_admins
from dns_mdb import settings
setup_environ(settings)
from mdb.models import *
queue = Queue.Queue()
RRD_GRAPH = "%s " \
"-w 785 -h 120 -a PNG " \
"--slope-mode " \
"--start -86400 --end now " \
"--font DEFAULT:7: "\
"--title \"%s\" " \
"--watermark \"NEUF.NO\" " \
"--vertical-label \"latency(ms)\" " \
"--right-axis-label \"latency(ms)\" " \
"--lower-limit 0 " \
"--right-axis 1:0 " \
"--x-grid MINUTE:10:HOUR:1:MINUTE:120:0:%%R " \
"--alt-y-grid --rigid " \
"DEF:roundtrip=%s:rrd:AVERAGE " \
"LINE1:roundtrip#0000FF:\"latency(ms)\" "
# RRD_GRAPH ARGS: <output_filename.png>, <title>, <rrd>
RRD_DIR = args.rrd_path
RRD_GRAPH_DIR = args.graph_path
RRDTOOL = "/usr/bin/rrdtool"
class RrdService():
def log(self, host, interface, avg_ping):
rrd = self.get_rrd(host, interface)
self.update_rrd(rrd, avg_ping)
def get_rrd(self, host, interface):
return "%s/%s_%s.rrd" % ( RRD_DIR, host.id,\
interface.id)
def get_png(self, host, interface):
return "%s/%s_%s.png" % (RRD_GRAPH_DIR, host.id,\
interface.id)
def update_rrd(self, rrd, avg_ping):
self.create_rrd(rrd)
self.rrd_exec("update", RRD_UPDATE % (rrd, avg_ping))
def create_rrd(self, rrd):
if os.path.isfile(rrd):
return True
if args.debug:
print "Creating rrd: " + rrd
if not self.rrd_exec("create", RRD_CREATE % rrd):
if args.debug:
print "Could not create RRD database."
return False
return True
def create_title(self, host, interface):
# return "%s (%s)" % (host.hostname, unicode(interface))
return unicode(interface)
def create_graph(self, host, interface):
rrd = self.get_rrd(host, interface)
if not os.path.isfile(rrd): return
self.rrd_exec("graph", RRD_GRAPH % (self.get_png(host, interface), \
self.create_title(host, interface), rrd))
def rrd_exec(self, function, rrd_cmd):
cmd = "%s %s %s" % (RRDTOOL, function, rrd_cmd)
if args.debug:
print "RRD_EXEC (%s)" % cmd
if os.system(cmd) != 0:
return False
else:
return True
class WorkerThread(threading.Thread):
""" Threaded ping """
def __init__(self, host_queue, rrd_service):
threading.Thread.__init__(self)
self.queue = queue
self.rrd_service = rrd_service
def run(self):
while True:
host = self.queue.get()
self.do_graph_host(host)
self.queue.task_done()
def do_graph_host(self, host):
interfaces = host.interface_set.all()
for interface in interfaces:
self.rrd_service.create_graph(host, interface)
def show_host_types():
types = HostType.objects.all()
for t in types:
print "%s (%s)" % (t.host_type, t.description)
if args.show_types:
show_host_types()
sys.exit(0)
if args.type == "all":
hosts = Host.objects.all()
else:
hosts = Host.objects.filter(host_type__host_type = args.type)
rrd_service = RrdService()
# start num_threads threads for pinging
for i in xrange(args.num_threads):
t = WorkerThread(queue, rrd_service)
t.setDaemon(True)
t.start()
if args.debug:
print "Pinging %d hosts using %d threads." % (len(hosts), args.num_threads)
# Add all hosts to the queue. Ping threads
# will pick hosts from this queue.
for host in hosts:
queue.put(host)
# Wait for all threads to finish, eg. queue is empty.
queue.join()
sys.exit(0)
-152
View File
@@ -1,152 +0,0 @@
#!/usr/bin/env python
# coding: utf-8
# FIXME: python2
import os,sys,datetime,argparse,threading,time,Queue
from commands import getstatusoutput
parser = argparse.ArgumentParser(description = 'Ping hosts from mdb')
parser.add_argument('-t', '--type', default='all',\
help="Which type of host to operate on")
parser.add_argument('-d', '--debug', action='store_true',\
help="Turn on debugging")
parser.add_argument('--show-types', dest='show_types', action='store_true',\
help='Show available host types')
parser.add_argument('--num-threads', dest='num_threads', type=int, default=1,\
help='Number of threads used for pinging hosts')
parser.add_argument('--num-pings', dest='num_pings', type=int, default=5,\
help='Number of ping requests to each host. More requests gives a more accurate average calculation.')
parser.add_argument('--rrd-path', dest='rrd_path', required=True,\
help='Specify the path to the rrd files')
args = parser.parse_args()
from django.core.management import setup_environ
from django.core.mail import mail_admins
from dns_mdb import settings
setup_environ(settings)
from mdb.models import *
PING_CMD='ping -W 1 -c %d -n -q %s | grep rtt | cut -d " " -f4'
queue = Queue.Queue()
RRD_CREATE = "%s " \
"DS:ttl:GAUGE:600:U:U "\
"RRA:AVERAGE:0.5:1:576 "\
"RRA:AVERAGE:0.5:6:336 "\
"RRA:AVERAGE:0.5:72:124 "\
"RRA:AVERAGE:0.5:288:365"
RRD_UPDATE = "%s --template rtt N:%s"
RRD_DIR = args.rrd_path
RRDTOOL = "/usr/bin/rrdtool"
class RrdService():
def log(self, host, interface, avg_ping):
rrd = self.get_rrd(host, interface)
self.update_rrd(rrd, avg_ping)
def get_rrd(self, host, interface):
return "%s/%s_%s.rrd" % ( RRD_DIR, host.id,\
interface.id)
def update_rrd(self, rrd, avg_ping):
self.create_rrd(rrd)
self.rrd_exec("update", RRD_UPDATE % (rrd, avg_ping))
def create_rrd(self, rrd):
if os.path.isfile(rrd):
return True
if args.debug:
print "Creating rrd: " + rrd
if not self.rrd_exec("create", RRD_CREATE % rrd):
if args.debug:
print "Could not create RRD database."
return False
return True
def rrd_exec(self, function, rrd_cmd):
cmd = "%s %s %s" % (RRDTOOL, function, rrd_cmd)
if args.debug:
print "RRD_EXEC (%s)" % cmd
if os.system(cmd) != 0:
return False
else:
return True
class PingThread(threading.Thread):
""" Threaded ping """
def __init__(self, host_queue, rrd_service):
threading.Thread.__init__(self)
self.queue = queue
self.rrd_service = rrd_service
def run(self):
while True:
host = self.queue.get()
self.do_ping_host(host)
self.queue.task_done()
def do_ping_host(self, host):
interfaces = host.interface_set.all()
for interface in interfaces:
self.do_ping_interface(interface, host)
def do_ping_interface(self, interface, host):
res = self.do_ping_ipaddr(interface.ip4address.address)
if res == None:
if args.debug:
print "%s (%s/%s) : fail " % (host, \
interface.name, interface.ip4address.address)
return
if args.debug:
print "%s (%s/%s) : %s" % (host, interface.name, \
interface.ip4address.address, res)
self.rrd_service.log(host,interface, res[1])
def do_ping_ipaddr(self, ipaddr):
status, output = getstatusoutput(PING_CMD % (args.num_pings,ipaddr))
if status == 0 and len(output) > 0:
return output.split("/")
else:
return None
def show_host_types():
types = HostType.objects.all()
for t in types:
print "%s (%s)" % (t.host_type, t.description)
if args.show_types:
show_host_types()
sys.exit(0)
if args.type == "all":
hosts = Host.objects.all()
else:
hosts = Host.objects.filter(host_type__host_type = args.type)
rrd_service = RrdService()
# start num_threads threads for pinging
for i in xrange(args.num_threads):
t = PingThread(queue, rrd_service)
t.setDaemon(True)
t.start()
if args.debug:
print "Pinging %d hosts using %d threads." % (len(hosts), args.num_threads)
# Add all hosts to the queue. Ping threads
# will pick hosts from this queue.
for host in hosts:
queue.put(host)
# Wait for all threads to finish, eg. queue is empty.
queue.join()
sys.exit(0)
-8
View File
@@ -53,11 +53,3 @@ def db_domain(db, tmp_path):
)
DomainTxtRecord.objects.create(domain=domain, name="@", target='"v=spf1 mx ~all"')
return domain
def load_command_class():
path = pathlib.Path(__file__).parent.parent / "mdb/management/commands/zone-sync.py"
spec = importlib.util.spec_from_file_location("zone_sync_command", path)
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
return mod.Command
+2 -4
View File
@@ -35,11 +35,9 @@ def test_validate_puppet_host_by_secret(api_client, user_with_token, host):
_, token = user_with_token
data = {
"pxe_key": host.pxe_key,
"certname": "{}.{}".format(
host.hostname, host.interface_set.first().domain.domain_name
),
"certname": f"{host.hostname}.{host.interface_set.first().domain.domain_name}",
}
api_client.credentials(HTTP_AUTHORIZATION="Token {}".format(token.key))
api_client.credentials(HTTP_AUTHORIZATION=f"Token {token.key}")
res = api_client.post(reverse("api-validate-host-secret"), data, format="json")
assert res.status_code == 200, res.content
assert not Host.objects.get(pk=host.pk).pxe_installable
+57
View File
@@ -0,0 +1,57 @@
"""How mdbsite.settings loads production's settings, in a fresh interpreter each time,
since the settings module is imported once per process."""
import os
import subprocess
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
PRINT = (
"import django, os; os.environ['DJANGO_SETTINGS_MODULE'] = 'mdbsite.settings'; "
"django.setup(); from django.conf import settings as s; print(s.ALLOWED_HOSTS, s.DEBUG)"
)
def run(env):
return subprocess.run(
[sys.executable, "-c", PRINT],
cwd=ROOT,
env={
k: v
for k, v in os.environ.items()
if k not in ("MDB_SETTINGS", "DJANGO_DEBUG")
}
| env,
capture_output=True,
text=True,
)
def test_settings_file_overrides(tmp_path):
f = tmp_path / "settings.py"
f.write_text("ALLOWED_HOSTS = ['mdb.example.org']\n")
result = run({"MDB_SETTINGS": str(f)})
assert result.returncode == 0, result.stderr
assert result.stdout.strip() == "['mdb.example.org'] False"
def test_failing_settings_file_stops_mdb(tmp_path):
# As on sega after the 24.04 upgrade: python-ldap would not load, and the old
# `except ImportError: pass` left MDB running on the defaults.
f = tmp_path / "settings.py"
f.write_text("import a_module_that_does_not_exist\n")
result = run({"MDB_SETTINGS": str(f)})
assert result.returncode != 0
assert "a_module_that_does_not_exist" in result.stderr
def test_missing_settings_file_stops_mdb(tmp_path):
result = run({"MDB_SETTINGS": str(tmp_path / "missing.py")})
assert result.returncode != 0
def test_debug_is_off_unless_asked():
assert run({}).stdout.strip().endswith("False")
assert run({"DJANGO_DEBUG": "1"}).stdout.strip().endswith("True")
+1
View File
@@ -3,6 +3,7 @@ from io import StringIO
from unittest.mock import MagicMock, patch
import pytest
from mdb.management.commands.zone_sync import Command as CommandClass
from mdb.services.zone_sync import ZoneSyncService
Generated
+10 -199
View File
@@ -1,5 +1,5 @@
version = 1
revision = 3
revision = 5
requires-python = ">=3.14"
[[package]]
@@ -11,42 +11,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/5c/0a/a72d10ed65068e115044937873362e6e32fab1b7dce0046aeb224682c989/asgiref-3.11.1-py3-none-any.whl", hash = "sha256:e8667a091e69529631969fd45dc268fa79b99c92c5fcdda727757e52146ec133", size = 24345, upload-time = "2026-02-03T13:30:13.039Z" },
]
[[package]]
name = "bcrypt"
version = "4.3.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/bb/5d/6d7433e0f3cd46ce0b43cd65e1db465ea024dbb8216fb2404e919c2ad77b/bcrypt-4.3.0.tar.gz", hash = "sha256:3a3fd2204178b6d2adcf09cb4f6426ffef54762577a7c9b54c159008cb288c18", size = 25697, upload-time = "2025-02-28T01:24:09.174Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/11/22/5ada0b9af72b60cbc4c9a399fdde4af0feaa609d27eb0adc61607997a3fa/bcrypt-4.3.0-cp38-abi3-macosx_10_12_universal2.whl", hash = "sha256:f81b0ed2639568bf14749112298f9e4e2b28853dab50a8b357e31798686a036d", size = 498019, upload-time = "2025-02-28T01:23:05.838Z" },
{ url = "https://files.pythonhosted.org/packages/b8/8c/252a1edc598dc1ce57905be173328eda073083826955ee3c97c7ff5ba584/bcrypt-4.3.0-cp38-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:864f8f19adbe13b7de11ba15d85d4a428c7e2f344bac110f667676a0ff84924b", size = 279174, upload-time = "2025-02-28T01:23:07.274Z" },
{ url = "https://files.pythonhosted.org/packages/29/5b/4547d5c49b85f0337c13929f2ccbe08b7283069eea3550a457914fc078aa/bcrypt-4.3.0-cp38-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3e36506d001e93bffe59754397572f21bb5dc7c83f54454c990c74a468cd589e", size = 283870, upload-time = "2025-02-28T01:23:09.151Z" },
{ url = "https://files.pythonhosted.org/packages/be/21/7dbaf3fa1745cb63f776bb046e481fbababd7d344c5324eab47f5ca92dd2/bcrypt-4.3.0-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:842d08d75d9fe9fb94b18b071090220697f9f184d4547179b60734846461ed59", size = 279601, upload-time = "2025-02-28T01:23:11.461Z" },
{ url = "https://files.pythonhosted.org/packages/6d/64/e042fc8262e971347d9230d9abbe70d68b0a549acd8611c83cebd3eaec67/bcrypt-4.3.0-cp38-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7c03296b85cb87db865d91da79bf63d5609284fc0cab9472fdd8367bbd830753", size = 297660, upload-time = "2025-02-28T01:23:12.989Z" },
{ url = "https://files.pythonhosted.org/packages/50/b8/6294eb84a3fef3b67c69b4470fcdd5326676806bf2519cda79331ab3c3a9/bcrypt-4.3.0-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:62f26585e8b219cdc909b6a0069efc5e4267e25d4a3770a364ac58024f62a761", size = 284083, upload-time = "2025-02-28T01:23:14.5Z" },
{ url = "https://files.pythonhosted.org/packages/62/e6/baff635a4f2c42e8788fe1b1633911c38551ecca9a749d1052d296329da6/bcrypt-4.3.0-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:beeefe437218a65322fbd0069eb437e7c98137e08f22c4660ac2dc795c31f8bb", size = 279237, upload-time = "2025-02-28T01:23:16.686Z" },
{ url = "https://files.pythonhosted.org/packages/39/48/46f623f1b0c7dc2e5de0b8af5e6f5ac4cc26408ac33f3d424e5ad8da4a90/bcrypt-4.3.0-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:97eea7408db3a5bcce4a55d13245ab3fa566e23b4c67cd227062bb49e26c585d", size = 283737, upload-time = "2025-02-28T01:23:18.897Z" },
{ url = "https://files.pythonhosted.org/packages/49/8b/70671c3ce9c0fca4a6cc3cc6ccbaa7e948875a2e62cbd146e04a4011899c/bcrypt-4.3.0-cp38-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:191354ebfe305e84f344c5964c7cd5f924a3bfc5d405c75ad07f232b6dffb49f", size = 312741, upload-time = "2025-02-28T01:23:21.041Z" },
{ url = "https://files.pythonhosted.org/packages/27/fb/910d3a1caa2d249b6040a5caf9f9866c52114d51523ac2fb47578a27faee/bcrypt-4.3.0-cp38-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:41261d64150858eeb5ff43c753c4b216991e0ae16614a308a15d909503617732", size = 316472, upload-time = "2025-02-28T01:23:23.183Z" },
{ url = "https://files.pythonhosted.org/packages/dc/cf/7cf3a05b66ce466cfb575dbbda39718d45a609daa78500f57fa9f36fa3c0/bcrypt-4.3.0-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:33752b1ba962ee793fa2b6321404bf20011fe45b9afd2a842139de3011898fef", size = 343606, upload-time = "2025-02-28T01:23:25.361Z" },
{ url = "https://files.pythonhosted.org/packages/e3/b8/e970ecc6d7e355c0d892b7f733480f4aa8509f99b33e71550242cf0b7e63/bcrypt-4.3.0-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:50e6e80a4bfd23a25f5c05b90167c19030cf9f87930f7cb2eacb99f45d1c3304", size = 362867, upload-time = "2025-02-28T01:23:26.875Z" },
{ url = "https://files.pythonhosted.org/packages/a9/97/8d3118efd8354c555a3422d544163f40d9f236be5b96c714086463f11699/bcrypt-4.3.0-cp38-abi3-win32.whl", hash = "sha256:67a561c4d9fb9465ec866177e7aebcad08fe23aaf6fbd692a6fab69088abfc51", size = 160589, upload-time = "2025-02-28T01:23:28.381Z" },
{ url = "https://files.pythonhosted.org/packages/29/07/416f0b99f7f3997c69815365babbc2e8754181a4b1899d921b3c7d5b6f12/bcrypt-4.3.0-cp38-abi3-win_amd64.whl", hash = "sha256:584027857bc2843772114717a7490a37f68da563b3620f78a849bcb54dc11e62", size = 152794, upload-time = "2025-02-28T01:23:30.187Z" },
{ url = "https://files.pythonhosted.org/packages/6e/c1/3fa0e9e4e0bfd3fd77eb8b52ec198fd6e1fd7e9402052e43f23483f956dd/bcrypt-4.3.0-cp39-abi3-macosx_10_12_universal2.whl", hash = "sha256:0d3efb1157edebfd9128e4e46e2ac1a64e0c1fe46fb023158a407c7892b0f8c3", size = 498969, upload-time = "2025-02-28T01:23:31.945Z" },
{ url = "https://files.pythonhosted.org/packages/ce/d4/755ce19b6743394787fbd7dff6bf271b27ee9b5912a97242e3caf125885b/bcrypt-4.3.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:08bacc884fd302b611226c01014eca277d48f0a05187666bca23aac0dad6fe24", size = 279158, upload-time = "2025-02-28T01:23:34.161Z" },
{ url = "https://files.pythonhosted.org/packages/9b/5d/805ef1a749c965c46b28285dfb5cd272a7ed9fa971f970435a5133250182/bcrypt-4.3.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f6746e6fec103fcd509b96bacdfdaa2fbde9a553245dbada284435173a6f1aef", size = 284285, upload-time = "2025-02-28T01:23:35.765Z" },
{ url = "https://files.pythonhosted.org/packages/ab/2b/698580547a4a4988e415721b71eb45e80c879f0fb04a62da131f45987b96/bcrypt-4.3.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:afe327968aaf13fc143a56a3360cb27d4ad0345e34da12c7290f1b00b8fe9a8b", size = 279583, upload-time = "2025-02-28T01:23:38.021Z" },
{ url = "https://files.pythonhosted.org/packages/f2/87/62e1e426418204db520f955ffd06f1efd389feca893dad7095bf35612eec/bcrypt-4.3.0-cp39-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d9af79d322e735b1fc33404b5765108ae0ff232d4b54666d46730f8ac1a43676", size = 297896, upload-time = "2025-02-28T01:23:39.575Z" },
{ url = "https://files.pythonhosted.org/packages/cb/c6/8fedca4c2ada1b6e889c52d2943b2f968d3427e5d65f595620ec4c06fa2f/bcrypt-4.3.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:f1e3ffa1365e8702dc48c8b360fef8d7afeca482809c5e45e653af82ccd088c1", size = 284492, upload-time = "2025-02-28T01:23:40.901Z" },
{ url = "https://files.pythonhosted.org/packages/4d/4d/c43332dcaaddb7710a8ff5269fcccba97ed3c85987ddaa808db084267b9a/bcrypt-4.3.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:3004df1b323d10021fda07a813fd33e0fd57bef0e9a480bb143877f6cba996fe", size = 279213, upload-time = "2025-02-28T01:23:42.653Z" },
{ url = "https://files.pythonhosted.org/packages/dc/7f/1e36379e169a7df3a14a1c160a49b7b918600a6008de43ff20d479e6f4b5/bcrypt-4.3.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:531457e5c839d8caea9b589a1bcfe3756b0547d7814e9ce3d437f17da75c32b0", size = 284162, upload-time = "2025-02-28T01:23:43.964Z" },
{ url = "https://files.pythonhosted.org/packages/1c/0a/644b2731194b0d7646f3210dc4d80c7fee3ecb3a1f791a6e0ae6bb8684e3/bcrypt-4.3.0-cp39-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:17a854d9a7a476a89dcef6c8bd119ad23e0f82557afbd2c442777a16408e614f", size = 312856, upload-time = "2025-02-28T01:23:46.011Z" },
{ url = "https://files.pythonhosted.org/packages/dc/62/2a871837c0bb6ab0c9a88bf54de0fc021a6a08832d4ea313ed92a669d437/bcrypt-4.3.0-cp39-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:6fb1fd3ab08c0cbc6826a2e0447610c6f09e983a281b919ed721ad32236b8b23", size = 316726, upload-time = "2025-02-28T01:23:47.575Z" },
{ url = "https://files.pythonhosted.org/packages/0c/a1/9898ea3faac0b156d457fd73a3cb9c2855c6fd063e44b8522925cdd8ce46/bcrypt-4.3.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:e965a9c1e9a393b8005031ff52583cedc15b7884fce7deb8b0346388837d6cfe", size = 343664, upload-time = "2025-02-28T01:23:49.059Z" },
{ url = "https://files.pythonhosted.org/packages/40/f2/71b4ed65ce38982ecdda0ff20c3ad1b15e71949c78b2c053df53629ce940/bcrypt-4.3.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:79e70b8342a33b52b55d93b3a59223a844962bef479f6a0ea318ebbcadf71505", size = 363128, upload-time = "2025-02-28T01:23:50.399Z" },
{ url = "https://files.pythonhosted.org/packages/11/99/12f6a58eca6dea4be992d6c681b7ec9410a1d9f5cf368c61437e31daa879/bcrypt-4.3.0-cp39-abi3-win32.whl", hash = "sha256:b4d4e57f0a63fd0b358eb765063ff661328f69a04494427265950c71b992a39a", size = 160598, upload-time = "2025-02-28T01:23:51.775Z" },
{ url = "https://files.pythonhosted.org/packages/a9/cf/45fb5261ece3e6b9817d3d82b2f343a505fd58674a92577923bc500bd1aa/bcrypt-4.3.0-cp39-abi3-win_amd64.whl", hash = "sha256:e53e074b120f2877a35cc6c736b8eb161377caae8925c17688bd46ba56daaa5b", size = 152799, upload-time = "2025-02-28T01:23:53.139Z" },
]
[[package]]
name = "certifi"
version = "2025.4.26"
@@ -56,15 +20,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/4a/7e/3db2bd1b1f9e95f7cddca6d6e75e2f2bd9f51b1246e546d88addca0106bd/certifi-2025.4.26-py3-none-any.whl", hash = "sha256:30350364dfe371162649852c63336a15c70c6510c2ad5015b21c2345311805f3", size = 159618, upload-time = "2025-04-26T02:12:27.662Z" },
]
[[package]]
name = "cffi"
version = "1.17.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pycparser" },
]
sdist = { url = "https://files.pythonhosted.org/packages/fc/97/c783634659c2920c3fc70419e3af40972dbaf758daa229a7d6ea6135c90d/cffi-1.17.1.tar.gz", hash = "sha256:1c39c6016c32bc48dd54561950ebd6836e1670f2ae46128f67cf49e789c52824", size = 516621, upload-time = "2024-09-04T20:45:21.852Z" }
[[package]]
name = "charset-normalizer"
version = "3.4.6"
@@ -115,62 +70,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
]
[[package]]
name = "cryptography"
version = "44.0.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cffi", marker = "platform_python_implementation != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/53/d6/1411ab4d6108ab167d06254c5be517681f1e331f90edf1379895bcb87020/cryptography-44.0.3.tar.gz", hash = "sha256:fe19d8bc5536a91a24a8133328880a41831b6c5df54599a8417b62fe015d3053", size = 711096, upload-time = "2025-05-02T19:36:04.667Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/08/53/c776d80e9d26441bb3868457909b4e74dd9ccabd182e10b2b0ae7a07e265/cryptography-44.0.3-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:962bc30480a08d133e631e8dfd4783ab71cc9e33d5d7c1e192f0b7c06397bb88", size = 6670281, upload-time = "2025-05-02T19:34:50.665Z" },
{ url = "https://files.pythonhosted.org/packages/6a/06/af2cf8d56ef87c77319e9086601bef621bedf40f6f59069e1b6d1ec498c5/cryptography-44.0.3-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4ffc61e8f3bf5b60346d89cd3d37231019c17a081208dfbbd6e1605ba03fa137", size = 3959305, upload-time = "2025-05-02T19:34:53.042Z" },
{ url = "https://files.pythonhosted.org/packages/ae/01/80de3bec64627207d030f47bf3536889efee8913cd363e78ca9a09b13c8e/cryptography-44.0.3-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58968d331425a6f9eedcee087f77fd3c927c88f55368f43ff7e0a19891f2642c", size = 4171040, upload-time = "2025-05-02T19:34:54.675Z" },
{ url = "https://files.pythonhosted.org/packages/bd/48/bb16b7541d207a19d9ae8b541c70037a05e473ddc72ccb1386524d4f023c/cryptography-44.0.3-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:e28d62e59a4dbd1d22e747f57d4f00c459af22181f0b2f787ea83f5a876d7c76", size = 3963411, upload-time = "2025-05-02T19:34:56.61Z" },
{ url = "https://files.pythonhosted.org/packages/42/b2/7d31f2af5591d217d71d37d044ef5412945a8a8e98d5a2a8ae4fd9cd4489/cryptography-44.0.3-cp37-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:af653022a0c25ef2e3ffb2c673a50e5a0d02fecc41608f4954176f1933b12359", size = 3689263, upload-time = "2025-05-02T19:34:58.591Z" },
{ url = "https://files.pythonhosted.org/packages/25/50/c0dfb9d87ae88ccc01aad8eb93e23cfbcea6a6a106a9b63a7b14c1f93c75/cryptography-44.0.3-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:157f1f3b8d941c2bd8f3ffee0af9b049c9665c39d3da9db2dc338feca5e98a43", size = 4196198, upload-time = "2025-05-02T19:35:00.988Z" },
{ url = "https://files.pythonhosted.org/packages/66/c9/55c6b8794a74da652690c898cb43906310a3e4e4f6ee0b5f8b3b3e70c441/cryptography-44.0.3-cp37-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:c6cd67722619e4d55fdb42ead64ed8843d64638e9c07f4011163e46bc512cf01", size = 3966502, upload-time = "2025-05-02T19:35:03.091Z" },
{ url = "https://files.pythonhosted.org/packages/b6/f7/7cb5488c682ca59a02a32ec5f975074084db4c983f849d47b7b67cc8697a/cryptography-44.0.3-cp37-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:b424563394c369a804ecbee9b06dfb34997f19d00b3518e39f83a5642618397d", size = 4196173, upload-time = "2025-05-02T19:35:05.018Z" },
{ url = "https://files.pythonhosted.org/packages/d2/0b/2f789a8403ae089b0b121f8f54f4a3e5228df756e2146efdf4a09a3d5083/cryptography-44.0.3-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:c91fc8e8fd78af553f98bc7f2a1d8db977334e4eea302a4bfd75b9461c2d8904", size = 4087713, upload-time = "2025-05-02T19:35:07.187Z" },
{ url = "https://files.pythonhosted.org/packages/1d/aa/330c13655f1af398fc154089295cf259252f0ba5df93b4bc9d9c7d7f843e/cryptography-44.0.3-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:25cd194c39fa5a0aa4169125ee27d1172097857b27109a45fadc59653ec06f44", size = 4299064, upload-time = "2025-05-02T19:35:08.879Z" },
{ url = "https://files.pythonhosted.org/packages/10/a8/8c540a421b44fd267a7d58a1fd5f072a552d72204a3f08194f98889de76d/cryptography-44.0.3-cp37-abi3-win32.whl", hash = "sha256:3be3f649d91cb182c3a6bd336de8b61a0a71965bd13d1a04a0e15b39c3d5809d", size = 2773887, upload-time = "2025-05-02T19:35:10.41Z" },
{ url = "https://files.pythonhosted.org/packages/b9/0d/c4b1657c39ead18d76bbd122da86bd95bdc4095413460d09544000a17d56/cryptography-44.0.3-cp37-abi3-win_amd64.whl", hash = "sha256:3883076d5c4cc56dbef0b898a74eb6992fdac29a7b9013870b34efe4ddb39a0d", size = 3209737, upload-time = "2025-05-02T19:35:12.12Z" },
{ url = "https://files.pythonhosted.org/packages/34/a3/ad08e0bcc34ad436013458d7528e83ac29910943cea42ad7dd4141a27bbb/cryptography-44.0.3-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:5639c2b16764c6f76eedf722dbad9a0914960d3489c0cc38694ddf9464f1bb2f", size = 6673501, upload-time = "2025-05-02T19:35:13.775Z" },
{ url = "https://files.pythonhosted.org/packages/b1/f0/7491d44bba8d28b464a5bc8cc709f25a51e3eac54c0a4444cf2473a57c37/cryptography-44.0.3-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f3ffef566ac88f75967d7abd852ed5f182da252d23fac11b4766da3957766759", size = 3960307, upload-time = "2025-05-02T19:35:15.917Z" },
{ url = "https://files.pythonhosted.org/packages/f7/c8/e5c5d0e1364d3346a5747cdcd7ecbb23ca87e6dea4f942a44e88be349f06/cryptography-44.0.3-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:192ed30fac1728f7587c6f4613c29c584abdc565d7417c13904708db10206645", size = 4170876, upload-time = "2025-05-02T19:35:18.138Z" },
{ url = "https://files.pythonhosted.org/packages/73/96/025cb26fc351d8c7d3a1c44e20cf9a01e9f7cf740353c9c7a17072e4b264/cryptography-44.0.3-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:7d5fe7195c27c32a64955740b949070f21cba664604291c298518d2e255931d2", size = 3964127, upload-time = "2025-05-02T19:35:19.864Z" },
{ url = "https://files.pythonhosted.org/packages/01/44/eb6522db7d9f84e8833ba3bf63313f8e257729cf3a8917379473fcfd6601/cryptography-44.0.3-cp39-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3f07943aa4d7dad689e3bb1638ddc4944cc5e0921e3c227486daae0e31a05e54", size = 3689164, upload-time = "2025-05-02T19:35:21.449Z" },
{ url = "https://files.pythonhosted.org/packages/68/fb/d61a4defd0d6cee20b1b8a1ea8f5e25007e26aeb413ca53835f0cae2bcd1/cryptography-44.0.3-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:cb90f60e03d563ca2445099edf605c16ed1d5b15182d21831f58460c48bffb93", size = 4198081, upload-time = "2025-05-02T19:35:23.187Z" },
{ url = "https://files.pythonhosted.org/packages/1b/50/457f6911d36432a8811c3ab8bd5a6090e8d18ce655c22820994913dd06ea/cryptography-44.0.3-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:ab0b005721cc0039e885ac3503825661bd9810b15d4f374e473f8c89b7d5460c", size = 3967716, upload-time = "2025-05-02T19:35:25.426Z" },
{ url = "https://files.pythonhosted.org/packages/35/6e/dca39d553075980ccb631955c47b93d87d27f3596da8d48b1ae81463d915/cryptography-44.0.3-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:3bb0847e6363c037df8f6ede57d88eaf3410ca2267fb12275370a76f85786a6f", size = 4197398, upload-time = "2025-05-02T19:35:27.678Z" },
{ url = "https://files.pythonhosted.org/packages/9b/9d/d1f2fe681eabc682067c66a74addd46c887ebacf39038ba01f8860338d3d/cryptography-44.0.3-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:b0cc66c74c797e1db750aaa842ad5b8b78e14805a9b5d1348dc603612d3e3ff5", size = 4087900, upload-time = "2025-05-02T19:35:29.312Z" },
{ url = "https://files.pythonhosted.org/packages/c4/f5/3599e48c5464580b73b236aafb20973b953cd2e7b44c7c2533de1d888446/cryptography-44.0.3-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:6866df152b581f9429020320e5eb9794c8780e90f7ccb021940d7f50ee00ae0b", size = 4301067, upload-time = "2025-05-02T19:35:31.547Z" },
{ url = "https://files.pythonhosted.org/packages/a7/6c/d2c48c8137eb39d0c193274db5c04a75dab20d2f7c3f81a7dcc3a8897701/cryptography-44.0.3-cp39-abi3-win32.whl", hash = "sha256:c138abae3a12a94c75c10499f1cbae81294a6f983b3af066390adee73f433028", size = 2775467, upload-time = "2025-05-02T19:35:33.805Z" },
{ url = "https://files.pythonhosted.org/packages/c9/ad/51f212198681ea7b0deaaf8846ee10af99fba4e894f67b353524eab2bbe5/cryptography-44.0.3-cp39-abi3-win_amd64.whl", hash = "sha256:5d186f32e52e66994dce4f766884bcb9c68b8da62d61d9d215bfe5fb56d21334", size = 3210375, upload-time = "2025-05-02T19:35:35.369Z" },
]
[[package]]
name = "decorator"
version = "5.2.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/43/fa/6d96a0978d19e17b68d634497769987b16c8f4cd0a7a05048bec693caa6b/decorator-5.2.1.tar.gz", hash = "sha256:65f266143752f734b0a7cc83c46f4618af75b8c5911b00ccb61d0ac9b6da0360", size = 56711, upload-time = "2025-02-24T04:41:34.073Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/4e/8c/f3147f5c4b73e7550fe5f9352eaa956ae838d5c51eb58e7a25b9f3e2643b/decorator-5.2.1-py3-none-any.whl", hash = "sha256:d316bb415a2d9e2d2b3abcc4084c6502fc09240e292cd76a76afc106a1c8e04a", size = 9190, upload-time = "2025-02-24T04:41:32.565Z" },
]
[[package]]
name = "deprecated"
version = "1.3.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "wrapt" },
]
sdist = { url = "https://files.pythonhosted.org/packages/49/85/12f0a49a7c4ffb70572b6c2ef13c90c88fd190debda93b23f026b25f9634/deprecated-1.3.1.tar.gz", hash = "sha256:b1b50e0ff0c1fddaa5708a2c6b0a6588bb09b892825ab2b214ac9ea9d92a5223", size = 2932523, upload-time = "2025-10-30T08:19:02.757Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/84/d0/205d54408c08b13550c733c4b85429e7ead111c7f0014309637425520a9a/deprecated-1.3.1-py2.py3-none-any.whl", hash = "sha256:597bfef186b6f60181535a29fbe44865ce137a5079f295b479886c82729d5f3f", size = 11298, upload-time = "2025-10-30T08:19:00.758Z" },
]
[[package]]
name = "django"
version = "6.0.3"
@@ -222,21 +121,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/5f/ee/01dfe0e712d37007a4417bf7ba518a9e6830d4de009ed4bfdfebd45b99cf/djangorestframework-3.17.0-py3-none-any.whl", hash = "sha256:d84fe85f30b7ac6e8c0076ce9ff635e4eaedca5912f8d7d2926ce448c08533ba", size = 898818, upload-time = "2026-03-18T20:33:41.335Z" },
]
[[package]]
name = "fabric"
version = "3.2.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "decorator" },
{ name = "deprecated" },
{ name = "invoke" },
{ name = "paramiko" },
]
sdist = { url = "https://files.pythonhosted.org/packages/0d/3f/337f278b70ba339c618a490f6b8033b7006c583bd197a897f12fbc468c51/fabric-3.2.2.tar.gz", hash = "sha256:8783ca42e3b0076f08b26901aac6b9d9b1f19c410074e7accfab902c184ff4a3", size = 183215, upload-time = "2023-08-31T01:42:05.55Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d6/1f/e99e23ee01847147fa194e8d41cfcf2535a2dbfcb51414c541cadb15c5d7/fabric-3.2.2-py3-none-any.whl", hash = "sha256:91c47c0be68b14936c88b34da8a1f55e5710fd28397dac5d4ff2e21558113a6f", size = 59417, upload-time = "2023-08-31T01:42:03.917Z" },
]
[[package]]
name = "freezegun"
version = "1.5.5"
@@ -279,15 +163,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
]
[[package]]
name = "invoke"
version = "2.2.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/de/bd/b461d3424a24c80490313fd77feeb666ca4f6a28c7e72713e3d9095719b4/invoke-2.2.1.tar.gz", hash = "sha256:515bf49b4a48932b79b024590348da22f39c4942dff991ad1fb8b8baea1be707", size = 304762, upload-time = "2025-10-11T00:36:35.172Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/32/4b/b99e37f88336009971405cbb7630610322ed6fbfa31e1d7ab3fbf3049a2d/invoke-2.2.1-py3-none-any.whl", hash = "sha256:2413bc441b376e5cd3f55bb5d364f973ad8bdd7bf87e53c79de3c11bf3feecc8", size = 160287, upload-time = "2025-10-11T00:36:33.703Z" },
]
[[package]]
name = "ipaddress"
version = "1.0.23"
@@ -301,20 +176,21 @@ wheels = [
name = "mdb"
version = "0.1.0"
source = { editable = "." }
default-groups = ["dev", "prod"]
dependencies = [
{ name = "django" },
{ name = "django-extensions" },
{ name = "djangorestframework" },
{ name = "fabric" },
{ name = "freezegun" },
{ name = "gunicorn" },
{ name = "ipaddress" },
{ name = "requests" },
{ name = "sentry-sdk" },
{ name = "whitenoise" },
]
[package.dev-dependencies]
dev = [
{ name = "freezegun" },
{ name = "pytest" },
{ name = "pytest-django" },
{ name = "ruff" },
@@ -328,16 +204,16 @@ requires-dist = [
{ name = "django", specifier = ">=6.0.3" },
{ name = "django-extensions", specifier = ">=4.1" },
{ name = "djangorestframework", specifier = ">=3.17.0" },
{ name = "fabric", specifier = ">=3.2.2" },
{ name = "freezegun", specifier = ">=1.5.5" },
{ name = "gunicorn", specifier = ">=25.1.0" },
{ name = "ipaddress", specifier = ">=1.0.23,<2.0.0" },
{ name = "requests", specifier = ">=2.32.5" },
{ name = "sentry-sdk", specifier = ">=2.55.0" },
{ name = "whitenoise", specifier = ">=6.12,<7" },
]
[package.metadata.requires-dev]
dev = [
{ name = "freezegun", specifier = ">=1.5.5" },
{ name = "pytest", specifier = ">=9.0.2" },
{ name = "pytest-django", specifier = ">=4.12.0" },
{ name = "ruff", specifier = ">=0.15.6,<1.0.0" },
@@ -353,20 +229,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" },
]
[[package]]
name = "paramiko"
version = "3.5.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "bcrypt" },
{ name = "cryptography" },
{ name = "pynacl" },
]
sdist = { url = "https://files.pythonhosted.org/packages/7d/15/ad6ce226e8138315f2451c2aeea985bf35ee910afb477bae7477dc3a8f3b/paramiko-3.5.1.tar.gz", hash = "sha256:b2c665bc45b2b215bd7d7f039901b14b067da00f3a11e6640995fd58f2664822", size = 1566110, upload-time = "2025-02-04T02:37:59.783Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/15/f8/c7bd0ef12954a81a1d3cea60a13946bd9a49a0036a5927770c461eade7ae/paramiko-3.5.1-py3-none-any.whl", hash = "sha256:43b9a0501fc2b5e70680388d9346cf252cfb7d00b0667c39e80eb43a408b8f61", size = 227298, upload-time = "2025-02-04T02:37:57.672Z" },
]
[[package]]
name = "pluggy"
version = "1.6.0"
@@ -397,15 +259,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/47/8d/d529b5d697919ba8c11ad626e835d4039be708a35b0d22de83a269a6682c/pyasn1_modules-0.4.2-py3-none-any.whl", hash = "sha256:29253a9207ce32b64c3ac6600edc75368f98473906e8fd1043bd6b5b1de2c14a", size = 181259, upload-time = "2025-03-28T02:41:19.028Z" },
]
[[package]]
name = "pycparser"
version = "2.22"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/1d/b2/31537cf4b1ca988837256c910a668b553fceb8f069bedc4b1c826024b52c/pycparser-2.22.tar.gz", hash = "sha256:491c8be9c040f5390f5bf44a5b07752bd07f56edf992381b05c701439eec10f6", size = 172736, upload-time = "2024-03-30T13:22:22.564Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/13/a3/a812df4e2dd5696d1f351d58b8fe16a405b234ad2886a0dab9183fb78109/pycparser-2.22-py3-none-any.whl", hash = "sha256:c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc", size = 117552, upload-time = "2024-03-30T13:22:20.476Z" },
]
[[package]]
name = "pygments"
version = "2.19.2"
@@ -415,26 +268,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" },
]
[[package]]
name = "pynacl"
version = "1.5.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cffi" },
]
sdist = { url = "https://files.pythonhosted.org/packages/a7/22/27582568be639dfe22ddb3902225f91f2f17ceff88ce80e4db396c8986da/PyNaCl-1.5.0.tar.gz", hash = "sha256:8ac7448f09ab85811607bdd21ec2464495ac8b7c66d146bf545b0f08fb9220ba", size = 3392854, upload-time = "2022-01-07T22:05:41.134Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ce/75/0b8ede18506041c0bf23ac4d8e2971b4161cd6ce630b177d0a08eb0d8857/PyNaCl-1.5.0-cp36-abi3-macosx_10_10_universal2.whl", hash = "sha256:401002a4aaa07c9414132aaed7f6836ff98f59277a234704ff66878c2ee4a0d1", size = 349920, upload-time = "2022-01-07T22:05:49.156Z" },
{ url = "https://files.pythonhosted.org/packages/59/bb/fddf10acd09637327a97ef89d2a9d621328850a72f1fdc8c08bdf72e385f/PyNaCl-1.5.0-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:52cb72a79269189d4e0dc537556f4740f7f0a9ec41c1322598799b0bdad4ef92", size = 601722, upload-time = "2022-01-07T22:05:50.989Z" },
{ url = "https://files.pythonhosted.org/packages/5d/70/87a065c37cca41a75f2ce113a5a2c2aa7533be648b184ade58971b5f7ccc/PyNaCl-1.5.0-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a36d4a9dda1f19ce6e03c9a784a2921a4b726b02e1c736600ca9c22029474394", size = 680087, upload-time = "2022-01-07T22:05:52.539Z" },
{ url = "https://files.pythonhosted.org/packages/ee/87/f1bb6a595f14a327e8285b9eb54d41fef76c585a0edef0a45f6fc95de125/PyNaCl-1.5.0-cp36-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:0c84947a22519e013607c9be43706dd42513f9e6ae5d39d3613ca1e142fba44d", size = 856678, upload-time = "2022-01-07T22:05:54.251Z" },
{ url = "https://files.pythonhosted.org/packages/66/28/ca86676b69bf9f90e710571b67450508484388bfce09acf8a46f0b8c785f/PyNaCl-1.5.0-cp36-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:06b8f6fa7f5de8d5d2f7573fe8c863c051225a27b61e6860fd047b1775807858", size = 1133660, upload-time = "2022-01-07T22:05:56.056Z" },
{ url = "https://files.pythonhosted.org/packages/3d/85/c262db650e86812585e2bc59e497a8f59948a005325a11bbbc9ecd3fe26b/PyNaCl-1.5.0-cp36-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:a422368fc821589c228f4c49438a368831cb5bbc0eab5ebe1d7fac9dded6567b", size = 663824, upload-time = "2022-01-07T22:05:57.434Z" },
{ url = "https://files.pythonhosted.org/packages/fd/1a/cc308a884bd299b651f1633acb978e8596c71c33ca85e9dc9fa33a5399b9/PyNaCl-1.5.0-cp36-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:61f642bf2378713e2c2e1de73444a3778e5f0a38be6fee0fe532fe30060282ff", size = 1117912, upload-time = "2022-01-07T22:05:58.665Z" },
{ url = "https://files.pythonhosted.org/packages/25/2d/b7df6ddb0c2a33afdb358f8af6ea3b8c4d1196ca45497dd37a56f0c122be/PyNaCl-1.5.0-cp36-abi3-win32.whl", hash = "sha256:e46dae94e34b085175f8abb3b0aaa7da40767865ac82c928eeb9e57e1ea8a543", size = 204624, upload-time = "2022-01-07T22:06:00.085Z" },
{ url = "https://files.pythonhosted.org/packages/5e/22/d3db169895faaf3e2eda892f005f433a62db2decbcfbc2f61e6517adfa87/PyNaCl-1.5.0-cp36-abi3-win_amd64.whl", hash = "sha256:20f42270d27e1b6a29f54032090b972d97f0a1b0948cc52392041ef7831fee93", size = 212141, upload-time = "2022-01-07T22:06:01.861Z" },
]
[[package]]
name = "pytest"
version = "9.0.2"
@@ -575,32 +408,10 @@ wheels = [
]
[[package]]
name = "wrapt"
version = "2.1.2"
name = "whitenoise"
version = "6.12.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/2e/64/925f213fdcbb9baeb1530449ac71a4d57fc361c053d06bf78d0c5c7cd80c/wrapt-2.1.2.tar.gz", hash = "sha256:3996a67eecc2c68fd47b4e3c564405a5777367adfd9b8abb58387b63ee83b21e", size = 81678, upload-time = "2026-03-06T02:53:25.134Z" }
sdist = { url = "https://files.pythonhosted.org/packages/cb/2a/55b3f3a4ec326cd077c1c3defeee656b9298372a69229134d930151acd01/whitenoise-6.12.0.tar.gz", hash = "sha256:f723ebb76a112e98816ff80fcea0a6c9b8ecde835f8ddda25df7a30a3c2db6ad", size = 26841, upload-time = "2026-02-27T00:05:42.028Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/39/25/e7ea0b417db02bb796182a5316398a75792cd9a22528783d868755e1f669/wrapt-2.1.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:1370e516598854e5b4366e09ce81e08bfe94d42b0fd569b88ec46cc56d9164a9", size = 61418, upload-time = "2026-03-06T02:53:55.706Z" },
{ url = "https://files.pythonhosted.org/packages/ec/0f/fa539e2f6a770249907757eaeb9a5ff4deb41c026f8466c1c6d799088a9b/wrapt-2.1.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:6de1a3851c27e0bd6a04ca993ea6f80fc53e6c742ee1601f486c08e9f9b900a9", size = 61914, upload-time = "2026-03-06T02:52:53.37Z" },
{ url = "https://files.pythonhosted.org/packages/53/37/02af1867f5b1441aaeda9c82deed061b7cd1372572ddcd717f6df90b5e93/wrapt-2.1.2-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:de9f1a2bbc5ac7f6012ec24525bdd444765a2ff64b5985ac6e0692144838542e", size = 120417, upload-time = "2026-03-06T02:54:30.74Z" },
{ url = "https://files.pythonhosted.org/packages/c3/b7/0138a6238c8ba7476c77cf786a807f871672b37f37a422970342308276e7/wrapt-2.1.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:970d57ed83fa040d8b20c52fe74a6ae7e3775ae8cff5efd6a81e06b19078484c", size = 122797, upload-time = "2026-03-06T02:54:51.539Z" },
{ url = "https://files.pythonhosted.org/packages/e1/ad/819ae558036d6a15b7ed290d5b14e209ca795dd4da9c58e50c067d5927b0/wrapt-2.1.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3969c56e4563c375861c8df14fa55146e81ac11c8db49ea6fb7f2ba58bc1ff9a", size = 117350, upload-time = "2026-03-06T02:54:37.651Z" },
{ url = "https://files.pythonhosted.org/packages/8b/2d/afc18dc57a4600a6e594f77a9ae09db54f55ba455440a54886694a84c71b/wrapt-2.1.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:57d7c0c980abdc5f1d98b11a2aa3bb159790add80258c717fa49a99921456d90", size = 121223, upload-time = "2026-03-06T02:54:35.221Z" },
{ url = "https://files.pythonhosted.org/packages/b9/5b/5ec189b22205697bc56eb3b62aed87a1e0423e9c8285d0781c7a83170d15/wrapt-2.1.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:776867878e83130c7a04237010463372e877c1c994d449ca6aaafeab6aab2586", size = 116287, upload-time = "2026-03-06T02:54:19.654Z" },
{ url = "https://files.pythonhosted.org/packages/f7/2d/f84939a7c9b5e6cdd8a8d0f6a26cabf36a0f7e468b967720e8b0cd2bdf69/wrapt-2.1.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:fab036efe5464ec3291411fabb80a7a39e2dd80bae9bcbeeca5087fdfa891e19", size = 119593, upload-time = "2026-03-06T02:54:16.697Z" },
{ url = "https://files.pythonhosted.org/packages/0b/fe/ccd22a1263159c4ac811ab9374c061bcb4a702773f6e06e38de5f81a1bdc/wrapt-2.1.2-cp314-cp314-win32.whl", hash = "sha256:e6ed62c82ddf58d001096ae84ce7f833db97ae2263bff31c9b336ba8cfe3f508", size = 58631, upload-time = "2026-03-06T02:53:06.498Z" },
{ url = "https://files.pythonhosted.org/packages/65/0a/6bd83be7bff2e7efaac7b4ac9748da9d75a34634bbbbc8ad077d527146df/wrapt-2.1.2-cp314-cp314-win_amd64.whl", hash = "sha256:467e7c76315390331c67073073d00662015bb730c566820c9ca9b54e4d67fd04", size = 60875, upload-time = "2026-03-06T02:53:50.252Z" },
{ url = "https://files.pythonhosted.org/packages/6c/c0/0b3056397fe02ff80e5a5d72d627c11eb885d1ca78e71b1a5c1e8c7d45de/wrapt-2.1.2-cp314-cp314-win_arm64.whl", hash = "sha256:da1f00a557c66225d53b095a97eace0fc5349e3bfda28fa34ffae238978ee575", size = 59164, upload-time = "2026-03-06T02:53:59.128Z" },
{ url = "https://files.pythonhosted.org/packages/71/ed/5d89c798741993b2371396eb9d4634f009ff1ad8a6c78d366fe2883ea7a6/wrapt-2.1.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:62503ffbc2d3a69891cf29beeaccdb4d5e0a126e2b6a851688d4777e01428dbb", size = 63163, upload-time = "2026-03-06T02:52:54.873Z" },
{ url = "https://files.pythonhosted.org/packages/c6/8c/05d277d182bf36b0a13d6bd393ed1dec3468a25b59d01fba2dd70fe4d6ae/wrapt-2.1.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c7e6cd120ef837d5b6f860a6ea3745f8763805c418bb2f12eeb1fa6e25f22d22", size = 63723, upload-time = "2026-03-06T02:52:56.374Z" },
{ url = "https://files.pythonhosted.org/packages/f4/27/6c51ec1eff4413c57e72d6106bb8dec6f0c7cdba6503d78f0fa98767bcc9/wrapt-2.1.2-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:3769a77df8e756d65fbc050333f423c01ae012b4f6731aaf70cf2bef61b34596", size = 152652, upload-time = "2026-03-06T02:53:23.79Z" },
{ url = "https://files.pythonhosted.org/packages/db/4c/d7dd662d6963fc7335bfe29d512b02b71cdfa23eeca7ab3ac74a67505deb/wrapt-2.1.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a76d61a2e851996150ba0f80582dd92a870643fa481f3b3846f229de88caf044", size = 158807, upload-time = "2026-03-06T02:53:35.742Z" },
{ url = "https://files.pythonhosted.org/packages/b4/4d/1e5eea1a78d539d346765727422976676615814029522c76b87a95f6bcdd/wrapt-2.1.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6f97edc9842cf215312b75fe737ee7c8adda75a89979f8e11558dfff6343cc4b", size = 146061, upload-time = "2026-03-06T02:52:57.574Z" },
{ url = "https://files.pythonhosted.org/packages/89/bc/62cabea7695cd12a288023251eeefdcb8465056ddaab6227cb78a2de005b/wrapt-2.1.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:4006c351de6d5007aa33a551f600404ba44228a89e833d2fadc5caa5de8edfbf", size = 155667, upload-time = "2026-03-06T02:53:39.422Z" },
{ url = "https://files.pythonhosted.org/packages/e9/99/6f2888cd68588f24df3a76572c69c2de28287acb9e1972bf0c83ce97dbc1/wrapt-2.1.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:a9372fc3639a878c8e7d87e1556fa209091b0a66e912c611e3f833e2c4202be2", size = 144392, upload-time = "2026-03-06T02:54:22.41Z" },
{ url = "https://files.pythonhosted.org/packages/40/51/1dfc783a6c57971614c48e361a82ca3b6da9055879952587bc99fe1a7171/wrapt-2.1.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:3144b027ff30cbd2fca07c0a87e67011adb717eb5f5bd8496325c17e454257a3", size = 150296, upload-time = "2026-03-06T02:54:07.848Z" },
{ url = "https://files.pythonhosted.org/packages/6c/38/cbb8b933a0201076c1f64fc42883b0023002bdc14a4964219154e6ff3350/wrapt-2.1.2-cp314-cp314t-win32.whl", hash = "sha256:3b8d15e52e195813efe5db8cec156eebe339aaf84222f4f4f051a6c01f237ed7", size = 60539, upload-time = "2026-03-06T02:54:00.594Z" },
{ url = "https://files.pythonhosted.org/packages/82/dd/e5176e4b241c9f528402cebb238a36785a628179d7d8b71091154b3e4c9e/wrapt-2.1.2-cp314-cp314t-win_amd64.whl", hash = "sha256:08ffa54146a7559f5b8df4b289b46d963a8e74ed16ba3687f99896101a3990c5", size = 63969, upload-time = "2026-03-06T02:54:39Z" },
{ url = "https://files.pythonhosted.org/packages/5c/99/79f17046cf67e4a95b9987ea129632ba8bcec0bc81f3fb3d19bdb0bd60cd/wrapt-2.1.2-cp314-cp314t-win_arm64.whl", hash = "sha256:72aaa9d0d8e4ed0e2e98019cea47a21f823c9dd4b43c7b77bba6679ffcca6a00", size = 60554, upload-time = "2026-03-06T02:53:14.132Z" },
{ url = "https://files.pythonhosted.org/packages/1a/c7/8528ac2dfa2c1e6708f647df7ae144ead13f0a31146f43c7264b4942bf12/wrapt-2.1.2-py3-none-any.whl", hash = "sha256:b8fd6fa2b2c4e7621808f8c62e8317f4aae56e59721ad933bac5239d913cf0e8", size = 43993, upload-time = "2026-03-06T02:53:12.905Z" },
{ url = "https://files.pythonhosted.org/packages/db/eb/d5583a11486211f3ebd4b385545ae787f32363d453c19fffd81106c9c138/whitenoise-6.12.0-py3-none-any.whl", hash = "sha256:fc5e8c572e33ebf24795b47b6a7da8da3c00cff2349f5b04c02f28d0cc5a3cc2", size = 20302, upload-time = "2026-02-27T00:05:40.086Z" },
]