arthexis 0.1.10__py3-none-any.whl → 0.1.11__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of arthexis might be problematic. Click here for more details.

nodes/models.py CHANGED
@@ -1,10 +1,13 @@
1
+ from __future__ import annotations
2
+
1
3
  from collections.abc import Iterable
2
4
  from django.db import models
5
+ from django.db.utils import DatabaseError
3
6
  from django.db.models.signals import post_delete
4
7
  from django.dispatch import Signal, receiver
5
8
  from core.entity import Entity
6
9
  from core.models import Profile
7
- from core.fields import SigilShortAutoField
10
+ from core.fields import SigilLongAutoField, SigilShortAutoField
8
11
  import re
9
12
  import json
10
13
  import base64
@@ -122,12 +125,23 @@ def get_terminal_role():
122
125
  class Node(Entity):
123
126
  """Information about a running node in the network."""
124
127
 
128
+ class Relation(models.TextChoices):
129
+ UPSTREAM = "UPSTREAM", "Upstream"
130
+ DOWNSTREAM = "DOWNSTREAM", "Downstream"
131
+ PEER = "PEER", "Peer"
132
+ SELF = "SELF", "Self"
133
+
125
134
  hostname = models.CharField(max_length=100)
126
135
  address = models.GenericIPAddressField()
127
136
  mac_address = models.CharField(max_length=17, unique=True, null=True, blank=True)
128
137
  port = models.PositiveIntegerField(default=8000)
129
138
  badge_color = models.CharField(max_length=7, default="#28a745")
130
139
  role = models.ForeignKey(NodeRole, on_delete=models.SET_NULL, null=True, blank=True)
140
+ current_relation = models.CharField(
141
+ max_length=10,
142
+ choices=Relation.choices,
143
+ default=Relation.PEER,
144
+ )
131
145
  last_seen = models.DateTimeField(auto_now=True)
132
146
  enable_public_api = models.BooleanField(
133
147
  default=False,
@@ -178,11 +192,35 @@ class Node(Entity):
178
192
  """Return the MAC address of the current host."""
179
193
  return ":".join(re.findall("..", f"{uuid.getnode():012x}"))
180
194
 
195
+ @classmethod
196
+ def normalize_relation(cls, value):
197
+ """Normalize ``value`` to a valid :class:`Relation`."""
198
+
199
+ if isinstance(value, cls.Relation):
200
+ return value
201
+ if value is None:
202
+ return cls.Relation.PEER
203
+ text = str(value).strip()
204
+ if not text:
205
+ return cls.Relation.PEER
206
+ for relation in cls.Relation:
207
+ if text.lower() == relation.label.lower():
208
+ return relation
209
+ if text.upper() == relation.name:
210
+ return relation
211
+ if text.lower() == relation.value.lower():
212
+ return relation
213
+ return cls.Relation.PEER
214
+
181
215
  @classmethod
182
216
  def get_local(cls):
183
217
  """Return the node representing the current host if it exists."""
184
218
  mac = cls.get_current_mac()
185
- return cls.objects.filter(mac_address=mac).first()
219
+ try:
220
+ return cls.objects.filter(mac_address=mac).first()
221
+ except DatabaseError:
222
+ logger.debug("nodes.Node.get_local skipped: database unavailable", exc_info=True)
223
+ return None
186
224
 
187
225
  @classmethod
188
226
  def register_current(cls):
@@ -212,6 +250,7 @@ class Node(Entity):
212
250
  "installed_revision": installed_revision,
213
251
  "public_endpoint": slug,
214
252
  "mac_address": mac,
253
+ "current_relation": cls.Relation.SELF,
215
254
  }
216
255
  if node:
217
256
  for field, value in defaults.items():
@@ -713,6 +752,243 @@ def _sync_tasks_on_assignment_delete(sender, instance, **kwargs):
713
752
  node.sync_feature_tasks()
714
753
 
715
754
 
755
+ class NodeManager(Profile):
756
+ """Credentials for interacting with external DNS providers."""
757
+
758
+ class Provider(models.TextChoices):
759
+ GODADDY = "godaddy", "GoDaddy"
760
+
761
+ profile_fields = (
762
+ "provider",
763
+ "api_key",
764
+ "api_secret",
765
+ "customer_id",
766
+ "default_domain",
767
+ )
768
+
769
+ provider = models.CharField(
770
+ max_length=20,
771
+ choices=Provider.choices,
772
+ default=Provider.GODADDY,
773
+ )
774
+ api_key = SigilShortAutoField(
775
+ max_length=255,
776
+ help_text="API key issued by the DNS provider.",
777
+ )
778
+ api_secret = SigilShortAutoField(
779
+ max_length=255,
780
+ help_text="API secret issued by the DNS provider.",
781
+ )
782
+ customer_id = SigilShortAutoField(
783
+ max_length=100,
784
+ blank=True,
785
+ help_text="Optional GoDaddy customer identifier for the account.",
786
+ )
787
+ default_domain = SigilShortAutoField(
788
+ max_length=253,
789
+ blank=True,
790
+ help_text="Fallback domain when records omit one.",
791
+ )
792
+ use_sandbox = models.BooleanField(
793
+ default=False,
794
+ help_text="Use the GoDaddy OTE (test) environment.",
795
+ )
796
+ is_enabled = models.BooleanField(
797
+ default=True,
798
+ help_text="Disable to prevent deployments with this manager.",
799
+ )
800
+
801
+ class Meta:
802
+ verbose_name = "Node Manager"
803
+ verbose_name_plural = "Node Managers"
804
+
805
+ def __str__(self) -> str:
806
+ owner = self.owner_display()
807
+ provider = self.get_provider_display()
808
+ if owner:
809
+ return f"{provider} ({owner})"
810
+ return provider
811
+
812
+ def clean(self):
813
+ if self.user_id or self.group_id:
814
+ super().clean()
815
+ else:
816
+ super(Profile, self).clean()
817
+
818
+ def get_base_url(self) -> str:
819
+ if self.provider != self.Provider.GODADDY:
820
+ raise ValueError("Unsupported DNS provider")
821
+ if self.use_sandbox:
822
+ return "https://api.ote-godaddy.com"
823
+ return "https://api.godaddy.com"
824
+
825
+ def get_auth_header(self) -> str:
826
+ key = (self.resolve_sigils("api_key") or "").strip()
827
+ secret = (self.resolve_sigils("api_secret") or "").strip()
828
+ if not key or not secret:
829
+ raise ValueError("API credentials are required for DNS deployment")
830
+ return f"sso-key {key}:{secret}"
831
+
832
+ def get_customer_id(self) -> str:
833
+ return (self.resolve_sigils("customer_id") or "").strip()
834
+
835
+ def get_default_domain(self) -> str:
836
+ return (self.resolve_sigils("default_domain") or "").strip()
837
+
838
+ def publish_dns_records(self, records: Iterable["DNSRecord"]):
839
+ from . import dns as dns_utils
840
+
841
+ return dns_utils.deploy_records(self, records)
842
+
843
+
844
+ class DNSRecord(Entity):
845
+ """Stored DNS configuration ready for deployment."""
846
+
847
+ class Type(models.TextChoices):
848
+ A = "A", "A"
849
+ AAAA = "AAAA", "AAAA"
850
+ CNAME = "CNAME", "CNAME"
851
+ MX = "MX", "MX"
852
+ NS = "NS", "NS"
853
+ SRV = "SRV", "SRV"
854
+ TXT = "TXT", "TXT"
855
+
856
+ class Provider(models.TextChoices):
857
+ GODADDY = "godaddy", "GoDaddy"
858
+
859
+ provider = models.CharField(
860
+ max_length=20,
861
+ choices=Provider.choices,
862
+ default=Provider.GODADDY,
863
+ )
864
+ node_manager = models.ForeignKey(
865
+ "NodeManager",
866
+ on_delete=models.SET_NULL,
867
+ null=True,
868
+ blank=True,
869
+ related_name="dns_records",
870
+ )
871
+ domain = SigilShortAutoField(
872
+ max_length=253,
873
+ help_text="Base domain such as example.com.",
874
+ )
875
+ name = SigilShortAutoField(
876
+ max_length=253,
877
+ help_text="Record host. Use @ for the zone apex.",
878
+ )
879
+ record_type = models.CharField(
880
+ max_length=10,
881
+ choices=Type.choices,
882
+ default=Type.A,
883
+ verbose_name="Type",
884
+ )
885
+ data = SigilLongAutoField(
886
+ help_text="Record value such as an IP address or hostname.",
887
+ )
888
+ ttl = models.PositiveIntegerField(
889
+ default=600,
890
+ help_text="Time to live in seconds.",
891
+ )
892
+ priority = models.PositiveIntegerField(
893
+ null=True,
894
+ blank=True,
895
+ help_text="Priority for MX and SRV records.",
896
+ )
897
+ port = models.PositiveIntegerField(
898
+ null=True,
899
+ blank=True,
900
+ help_text="Port for SRV records.",
901
+ )
902
+ weight = models.PositiveIntegerField(
903
+ null=True,
904
+ blank=True,
905
+ help_text="Weight for SRV records.",
906
+ )
907
+ service = SigilShortAutoField(
908
+ max_length=50,
909
+ blank=True,
910
+ help_text="Service label for SRV records (for example _sip).",
911
+ )
912
+ protocol = SigilShortAutoField(
913
+ max_length=10,
914
+ blank=True,
915
+ help_text="Protocol label for SRV records (for example _tcp).",
916
+ )
917
+ last_synced_at = models.DateTimeField(null=True, blank=True)
918
+ last_verified_at = models.DateTimeField(null=True, blank=True)
919
+ last_error = models.TextField(blank=True)
920
+
921
+ class Meta:
922
+ verbose_name = "DNS Record"
923
+ verbose_name_plural = "DNS Records"
924
+
925
+ def __str__(self) -> str:
926
+ return f"{self.record_type} {self.fqdn()}"
927
+
928
+ def get_domain(self, manager: "NodeManager" | None = None) -> str:
929
+ domain = (self.resolve_sigils("domain") or "").strip()
930
+ if domain:
931
+ return domain.rstrip(".")
932
+ if manager:
933
+ fallback = manager.get_default_domain()
934
+ if fallback:
935
+ return fallback.rstrip(".")
936
+ return ""
937
+
938
+ def get_name(self) -> str:
939
+ name = (self.resolve_sigils("name") or "").strip()
940
+ return name or "@"
941
+
942
+ def fqdn(self, manager: "NodeManager" | None = None) -> str:
943
+ domain = self.get_domain(manager)
944
+ name = self.get_name()
945
+ if name in {"@", ""}:
946
+ return domain
947
+ if name.endswith("."):
948
+ return name.rstrip(".")
949
+ if domain:
950
+ return f"{name}.{domain}".rstrip(".")
951
+ return name.rstrip(".")
952
+
953
+ def to_godaddy_payload(self) -> dict[str, object]:
954
+ payload: dict[str, object] = {
955
+ "data": (self.resolve_sigils("data") or "").strip(),
956
+ "ttl": self.ttl,
957
+ }
958
+ if self.priority is not None:
959
+ payload["priority"] = self.priority
960
+ if self.port is not None:
961
+ payload["port"] = self.port
962
+ if self.weight is not None:
963
+ payload["weight"] = self.weight
964
+ service = (self.resolve_sigils("service") or "").strip()
965
+ if service:
966
+ payload["service"] = service
967
+ protocol = (self.resolve_sigils("protocol") or "").strip()
968
+ if protocol:
969
+ payload["protocol"] = protocol
970
+ return payload
971
+
972
+ def mark_deployed(self, manager: "NodeManager" | None = None, timestamp=None) -> None:
973
+ if timestamp is None:
974
+ timestamp = timezone.now()
975
+ update_fields = ["last_synced_at", "last_error"]
976
+ self.last_synced_at = timestamp
977
+ self.last_error = ""
978
+ if manager and self.node_manager_id != getattr(manager, "pk", None):
979
+ self.node_manager = manager
980
+ update_fields.append("node_manager")
981
+ self.save(update_fields=update_fields)
982
+
983
+ def mark_error(self, message: str, manager: "NodeManager" | None = None) -> None:
984
+ update_fields = ["last_error"]
985
+ self.last_error = message
986
+ if manager and self.node_manager_id != getattr(manager, "pk", None):
987
+ self.node_manager = manager
988
+ update_fields.append("node_manager")
989
+ self.save(update_fields=update_fields)
990
+
991
+
716
992
  class EmailOutbox(Profile):
717
993
  """SMTP credentials for sending mail."""
718
994
 
@@ -765,6 +1041,10 @@ class EmailOutbox(Profile):
765
1041
  max_length=254,
766
1042
  help_text="Default From address; usually the same as username",
767
1043
  )
1044
+ is_enabled = models.BooleanField(
1045
+ default=True,
1046
+ help_text="Disable to remove this outbox from automatic selection.",
1047
+ )
768
1048
 
769
1049
  class Meta:
770
1050
  verbose_name = "Email Outbox"
@@ -778,6 +1058,8 @@ class EmailOutbox(Profile):
778
1058
  username = (self.username or "").strip()
779
1059
  host = (self.host or "").strip()
780
1060
  if username and host:
1061
+ if "@" in username:
1062
+ return username
781
1063
  return f"{username}@{host}"
782
1064
  if username:
783
1065
  return username
@@ -849,7 +1131,6 @@ class NetMessage(Entity):
849
1131
  on_delete=models.SET_NULL,
850
1132
  null=True,
851
1133
  blank=True,
852
- default=get_terminal_role,
853
1134
  )
854
1135
  propagated_to = models.ManyToManyField(
855
1136
  Node, blank=True, related_name="received_net_messages"
@@ -880,7 +1161,7 @@ class NetMessage(Entity):
880
1161
  msg = cls.objects.create(
881
1162
  subject=subject[:64],
882
1163
  body=body[:256],
883
- reach=role or get_terminal_role(),
1164
+ reach=role,
884
1165
  node_origin=origin,
885
1166
  )
886
1167
  msg.propagate(seen=seen or [])
@@ -951,7 +1232,7 @@ class NetMessage(Entity):
951
1232
 
952
1233
  target_limit = min(3, len(remaining))
953
1234
 
954
- reach_name = self.reach.name if self.reach else "Terminal"
1235
+ reach_name = self.reach.name if self.reach else None
955
1236
  role_map = {
956
1237
  "Terminal": ["Terminal"],
957
1238
  "Control": ["Control", "Terminal"],
@@ -963,10 +1244,15 @@ class NetMessage(Entity):
963
1244
  "Terminal",
964
1245
  ],
965
1246
  }
966
- role_order = role_map.get(reach_name, ["Terminal"])
1247
+ role_order = role_map.get(reach_name, [None])
967
1248
  selected: list[Node] = []
968
1249
  for role_name in role_order:
969
- role_nodes = [n for n in remaining if n.role and n.role.name == role_name]
1250
+ if role_name is None:
1251
+ role_nodes = remaining[:]
1252
+ else:
1253
+ role_nodes = [
1254
+ n for n in remaining if n.role and n.role.name == role_name
1255
+ ]
970
1256
  random.shuffle(role_nodes)
971
1257
  for n in role_nodes:
972
1258
  selected.append(n)