acex-devkit 2.10.0__tar.gz → 2.11.0__tar.gz

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.
Files changed (91) hide show
  1. {acex_devkit-2.10.0 → acex_devkit-2.11.0}/PKG-INFO +1 -1
  2. {acex_devkit-2.10.0 → acex_devkit-2.11.0}/pyproject.toml +1 -1
  3. {acex_devkit-2.10.0 → acex_devkit-2.11.0}/src/acex_devkit/__init__.py +3 -1
  4. {acex_devkit-2.10.0 → acex_devkit-2.11.0}/src/acex_devkit/drivers/base.py +6 -7
  5. {acex_devkit-2.10.0 → acex_devkit-2.11.0}/src/acex_devkit/drivers/base_driver.py +3 -4
  6. acex_devkit-2.11.0/src/acex_devkit/models/job.py +96 -0
  7. {acex_devkit-2.10.0 → acex_devkit-2.11.0}/src/acex_devkit/models/node_response.py +11 -7
  8. acex_devkit-2.11.0/src/acex_devkit/models/worker.py +44 -0
  9. acex_devkit-2.11.0/src/acex_devkit/models/ztp.py +434 -0
  10. {acex_devkit-2.10.0 → acex_devkit-2.11.0}/README.md +0 -0
  11. {acex_devkit-2.10.0 → acex_devkit-2.11.0}/src/acex_devkit/configdiffer/__init__.py +0 -0
  12. {acex_devkit-2.10.0 → acex_devkit-2.11.0}/src/acex_devkit/configdiffer/command.py +0 -0
  13. {acex_devkit-2.10.0 → acex_devkit-2.11.0}/src/acex_devkit/configdiffer/configdiffer.py +0 -0
  14. {acex_devkit-2.10.0 → acex_devkit-2.11.0}/src/acex_devkit/configdiffer/diff.py +0 -0
  15. {acex_devkit-2.10.0 → acex_devkit-2.11.0}/src/acex_devkit/configdiffer/old_configdiffer.py +0 -0
  16. {acex_devkit-2.10.0 → acex_devkit-2.11.0}/src/acex_devkit/configdiffer/old_diff.py +0 -0
  17. {acex_devkit-2.10.0 → acex_devkit-2.11.0}/src/acex_devkit/device_types/__init__.py +0 -0
  18. {acex_devkit-2.10.0 → acex_devkit-2.11.0}/src/acex_devkit/device_types/cisco/__init__.py +0 -0
  19. {acex_devkit-2.10.0 → acex_devkit-2.11.0}/src/acex_devkit/device_types/cisco/base.py +0 -0
  20. {acex_devkit-2.10.0 → acex_devkit-2.11.0}/src/acex_devkit/device_types/cisco/catalyst2950.py +0 -0
  21. {acex_devkit-2.10.0 → acex_devkit-2.11.0}/src/acex_devkit/device_types/cisco/catalyst2960.py +0 -0
  22. {acex_devkit-2.10.0 → acex_devkit-2.11.0}/src/acex_devkit/device_types/cisco/catalyst3500.py +0 -0
  23. {acex_devkit-2.10.0 → acex_devkit-2.11.0}/src/acex_devkit/device_types/cisco/catalyst3560.py +0 -0
  24. {acex_devkit-2.10.0 → acex_devkit-2.11.0}/src/acex_devkit/device_types/cisco/catalyst3650.py +0 -0
  25. {acex_devkit-2.10.0 → acex_devkit-2.11.0}/src/acex_devkit/device_types/cisco/catalyst3750.py +0 -0
  26. {acex_devkit-2.10.0 → acex_devkit-2.11.0}/src/acex_devkit/device_types/cisco/catalyst3850.py +0 -0
  27. {acex_devkit-2.10.0 → acex_devkit-2.11.0}/src/acex_devkit/device_types/cisco/catalyst4500.py +0 -0
  28. {acex_devkit-2.10.0 → acex_devkit-2.11.0}/src/acex_devkit/device_types/cisco/catalyst6500.py +0 -0
  29. {acex_devkit-2.10.0 → acex_devkit-2.11.0}/src/acex_devkit/device_types/cisco/catalyst6800.py +0 -0
  30. {acex_devkit-2.10.0 → acex_devkit-2.11.0}/src/acex_devkit/device_types/cisco/catalyst9200.py +0 -0
  31. {acex_devkit-2.10.0 → acex_devkit-2.11.0}/src/acex_devkit/device_types/cisco/catalyst9300.py +0 -0
  32. {acex_devkit-2.10.0 → acex_devkit-2.11.0}/src/acex_devkit/device_types/cisco/catalyst9400.py +0 -0
  33. {acex_devkit-2.10.0 → acex_devkit-2.11.0}/src/acex_devkit/device_types/cisco/catalyst9500.py +0 -0
  34. {acex_devkit-2.10.0 → acex_devkit-2.11.0}/src/acex_devkit/device_types/cisco/generic.py +0 -0
  35. {acex_devkit-2.10.0 → acex_devkit-2.11.0}/src/acex_devkit/device_types/cisco/industrial.py +0 -0
  36. {acex_devkit-2.10.0 → acex_devkit-2.11.0}/src/acex_devkit/device_types/cisco/modules.py +0 -0
  37. {acex_devkit-2.10.0 → acex_devkit-2.11.0}/src/acex_devkit/device_types/cisco/nexus9000.py +0 -0
  38. {acex_devkit-2.10.0 → acex_devkit-2.11.0}/src/acex_devkit/device_types/cisco/routers.py +0 -0
  39. {acex_devkit-2.10.0 → acex_devkit-2.11.0}/src/acex_devkit/device_types/fortinet/__init__.py +0 -0
  40. {acex_devkit-2.10.0 → acex_devkit-2.11.0}/src/acex_devkit/device_types/fortinet/base.py +0 -0
  41. {acex_devkit-2.10.0 → acex_devkit-2.11.0}/src/acex_devkit/device_types/fortinet/fortigate_f.py +0 -0
  42. {acex_devkit-2.10.0 → acex_devkit-2.11.0}/src/acex_devkit/device_types/juniper/__init__.py +0 -0
  43. {acex_devkit-2.10.0 → acex_devkit-2.11.0}/src/acex_devkit/device_types/juniper/base.py +0 -0
  44. {acex_devkit-2.10.0 → acex_devkit-2.11.0}/src/acex_devkit/device_types/juniper/ex2300.py +0 -0
  45. {acex_devkit-2.10.0 → acex_devkit-2.11.0}/src/acex_devkit/device_types/juniper/ex3400.py +0 -0
  46. {acex_devkit-2.10.0 → acex_devkit-2.11.0}/src/acex_devkit/device_types/juniper/ex4100.py +0 -0
  47. {acex_devkit-2.10.0 → acex_devkit-2.11.0}/src/acex_devkit/device_types/juniper/ex4400.py +0 -0
  48. {acex_devkit-2.10.0 → acex_devkit-2.11.0}/src/acex_devkit/device_types/juniper/ex4650.py +0 -0
  49. {acex_devkit-2.10.0 → acex_devkit-2.11.0}/src/acex_devkit/device_types/juniper/modules.py +0 -0
  50. {acex_devkit-2.10.0 → acex_devkit-2.11.0}/src/acex_devkit/drivers/__init__.py +0 -0
  51. {acex_devkit-2.10.0 → acex_devkit-2.11.0}/src/acex_devkit/exceptions/__init__.py +0 -0
  52. {acex_devkit-2.10.0 → acex_devkit-2.11.0}/src/acex_devkit/models/__init__.py +0 -0
  53. {acex_devkit-2.10.0 → acex_devkit-2.11.0}/src/acex_devkit/models/acl_model.py +0 -0
  54. {acex_devkit-2.10.0 → acex_devkit-2.11.0}/src/acex_devkit/models/agent_manifest.py +0 -0
  55. {acex_devkit-2.10.0 → acex_devkit-2.11.0}/src/acex_devkit/models/ai_ops.py +0 -0
  56. {acex_devkit-2.10.0 → acex_devkit-2.11.0}/src/acex_devkit/models/asset.py +0 -0
  57. {acex_devkit-2.10.0 → acex_devkit-2.11.0}/src/acex_devkit/models/assignment.py +0 -0
  58. {acex_devkit-2.10.0 → acex_devkit-2.11.0}/src/acex_devkit/models/attribute_value.py +0 -0
  59. {acex_devkit-2.10.0 → acex_devkit-2.11.0}/src/acex_devkit/models/augment.py +0 -0
  60. {acex_devkit-2.10.0 → acex_devkit-2.11.0}/src/acex_devkit/models/auth_config.py +0 -0
  61. {acex_devkit-2.10.0 → acex_devkit-2.11.0}/src/acex_devkit/models/base.py +0 -0
  62. {acex_devkit-2.10.0 → acex_devkit-2.11.0}/src/acex_devkit/models/capability.py +0 -0
  63. {acex_devkit-2.10.0 → acex_devkit-2.11.0}/src/acex_devkit/models/collection_agent.py +0 -0
  64. {acex_devkit-2.10.0 → acex_devkit-2.11.0}/src/acex_devkit/models/compliance.py +0 -0
  65. {acex_devkit-2.10.0 → acex_devkit-2.11.0}/src/acex_devkit/models/composed_configuration.py +0 -0
  66. {acex_devkit-2.10.0 → acex_devkit-2.11.0}/src/acex_devkit/models/config_components.py +0 -0
  67. {acex_devkit-2.10.0 → acex_devkit-2.11.0}/src/acex_devkit/models/config_snapshot.py +0 -0
  68. {acex_devkit-2.10.0 → acex_devkit-2.11.0}/src/acex_devkit/models/contact.py +0 -0
  69. {acex_devkit-2.10.0 → acex_devkit-2.11.0}/src/acex_devkit/models/container_entry.py +0 -0
  70. {acex_devkit-2.10.0 → acex_devkit-2.11.0}/src/acex_devkit/models/credential.py +0 -0
  71. {acex_devkit-2.10.0 → acex_devkit-2.11.0}/src/acex_devkit/models/device_type.py +0 -0
  72. {acex_devkit-2.10.0 → acex_devkit-2.11.0}/src/acex_devkit/models/external_value.py +0 -0
  73. {acex_devkit-2.10.0 → acex_devkit-2.11.0}/src/acex_devkit/models/lldp_neighbor.py +0 -0
  74. {acex_devkit-2.10.0 → acex_devkit-2.11.0}/src/acex_devkit/models/lldp_topology.py +0 -0
  75. {acex_devkit-2.10.0 → acex_devkit-2.11.0}/src/acex_devkit/models/logging.py +0 -0
  76. {acex_devkit-2.10.0 → acex_devkit-2.11.0}/src/acex_devkit/models/logical_node.py +0 -0
  77. {acex_devkit-2.10.0 → acex_devkit-2.11.0}/src/acex_devkit/models/management_connection.py +0 -0
  78. {acex_devkit-2.10.0 → acex_devkit-2.11.0}/src/acex_devkit/models/ned.py +0 -0
  79. {acex_devkit-2.10.0 → acex_devkit-2.11.0}/src/acex_devkit/models/os_version.py +0 -0
  80. {acex_devkit-2.10.0 → acex_devkit-2.11.0}/src/acex_devkit/models/pagination.py +0 -0
  81. {acex_devkit-2.10.0 → acex_devkit-2.11.0}/src/acex_devkit/models/platform.py +0 -0
  82. {acex_devkit-2.10.0 → acex_devkit-2.11.0}/src/acex_devkit/models/reference.py +0 -0
  83. {acex_devkit-2.10.0 → acex_devkit-2.11.0}/src/acex_devkit/models/region.py +0 -0
  84. {acex_devkit-2.10.0 → acex_devkit-2.11.0}/src/acex_devkit/models/site.py +0 -0
  85. {acex_devkit-2.10.0 → acex_devkit-2.11.0}/src/acex_devkit/models/spanning_tree.py +0 -0
  86. {acex_devkit-2.10.0 → acex_devkit-2.11.0}/src/acex_devkit/models/telemetry_agent.py +0 -0
  87. {acex_devkit-2.10.0 → acex_devkit-2.11.0}/src/acex_devkit/normalizer/__init__.py +0 -0
  88. {acex_devkit-2.10.0 → acex_devkit-2.11.0}/src/acex_devkit/normalizer/base.py +0 -0
  89. {acex_devkit-2.10.0 → acex_devkit-2.11.0}/src/acex_devkit/normalizer/engine.py +0 -0
  90. {acex_devkit-2.10.0 → acex_devkit-2.11.0}/src/acex_devkit/os_versions.py +0 -0
  91. {acex_devkit-2.10.0 → acex_devkit-2.11.0}/src/acex_devkit/types/__init__.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: acex-devkit
3
- Version: 2.10.0
3
+ Version: 2.11.0
4
4
  Summary: ACE-X DevKit - Development kit for building ACE-X drivers and plugins
5
5
  License: AGPL-3.0
6
6
  Keywords: automation,devkit,sdk,drivers,plugins
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "acex-devkit"
3
- version = "2.10.0"
3
+ version = "2.11.0"
4
4
  description = "ACE-X DevKit - Development kit for building ACE-X drivers and plugins"
5
5
  authors = ["Johan Lahti <johan.lahti@acebit.se>"]
6
6
  readme = "README.md"
@@ -1,6 +1,8 @@
1
1
  """ACE-X DevKit - Development kit for building ACE-X drivers and plugins."""
2
2
 
3
- __version__ = "1.0.0"
3
+ import importlib.metadata
4
+
5
+ __version__ = importlib.metadata.version("acex-devkit")
4
6
 
5
7
  from acex_devkit.drivers import (
6
8
  NetworkElementDriver,
@@ -4,7 +4,6 @@ from abc import ABC, abstractmethod
4
4
  from contextlib import contextmanager
5
5
  from typing import Any
6
6
 
7
- from acex_devkit.models.management_connection import ManagementConnection
8
7
  from acex_devkit.models.node_response import NodeListItem
9
8
 
10
9
 
@@ -50,12 +49,12 @@ class TransportBase(ABC):
50
49
 
51
50
  Args:
52
51
  node: The node instance (identity, hostname, vendor, os, ned_id)
53
- connection: Management connection (target_ip, connection_type)
52
+ target_ip: Management IP or hostname of the device
54
53
  **kwargs: Future use (credentials, options, etc.)
55
54
  """
56
55
 
57
56
  @contextmanager
58
- def session(self, connection: ManagementConnection, **kwargs):
57
+ def session(self, target_ip: str, **kwargs):
59
58
  """Open a reusable session for multiple ops on one device.
60
59
 
61
60
  Default is a no-op: methods continue to open/close per call.
@@ -65,20 +64,20 @@ class TransportBase(ABC):
65
64
  yield self
66
65
 
67
66
  @abstractmethod
68
- def get_config(self, node: NodeListItem, connection: ManagementConnection, **kwargs) -> str:
67
+ def get_config(self, node: NodeListItem, target_ip: str, **kwargs) -> str:
69
68
  """Fetch the full running configuration from a device."""
70
69
  pass
71
70
 
72
71
  @abstractmethod
73
- def send_config(self, node: NodeListItem, connection: ManagementConnection, commands: list[str], **kwargs) -> str:
72
+ def send_config(self, node: NodeListItem, target_ip: str, commands: list[str], **kwargs) -> str:
74
73
  """Apply configuration commands to a device."""
75
74
  pass
76
75
 
77
- def execute(self, node: NodeListItem, connection: ManagementConnection, commands: list[str], **kwargs) -> list[str]:
76
+ def execute(self, node: NodeListItem, target_ip: str, commands: list[str], **kwargs) -> list[str]:
78
77
  """Run arbitrary commands and return output per command. Opt-in per driver."""
79
78
  raise NotImplementedError(f"{self.__class__.__name__} does not implement execute()")
80
79
 
81
- def get_lldp_neighbors(self, node: NodeListItem, connection: ManagementConnection, **kwargs) -> list[dict]:
80
+ def get_lldp_neighbors(self, node: NodeListItem, target_ip: str, **kwargs) -> list[dict]:
82
81
  """Fetch LLDP/CDP neighbor table from a device.
83
82
 
84
83
  Returns list of dicts with keys:
@@ -3,7 +3,6 @@ from typing import Any
3
3
 
4
4
  from acex_devkit.models.asset import Asset
5
5
  from acex_devkit.models.composed_configuration import ComposedConfiguration
6
- from acex_devkit.models.management_connection import ManagementConnection
7
6
  from acex_devkit.models.node_response import NodeListItem
8
7
 
9
8
 
@@ -22,16 +21,16 @@ class RendererBase(ABC):
22
21
 
23
22
  class TransportBase(ABC):
24
23
  @abstractmethod
25
- def get_config(self, node: NodeListItem, connection: ManagementConnection, **kwargs) -> str:
24
+ def get_config(self, node: NodeListItem, target_ip: str, **kwargs) -> str:
26
25
  """Fetch the full running configuration from a device."""
27
26
  pass
28
27
 
29
28
  @abstractmethod
30
- def send_config(self, node: NodeListItem, connection: ManagementConnection, commands: list[str], **kwargs) -> str:
29
+ def send_config(self, node: NodeListItem, target_ip: str, commands: list[str], **kwargs) -> str:
31
30
  """Apply configuration commands to a device."""
32
31
  pass
33
32
 
34
- def execute(self, node: NodeListItem, connection: ManagementConnection, commands: list[str], **kwargs) -> list[str]:
33
+ def execute(self, node: NodeListItem, target_ip: str, commands: list[str], **kwargs) -> list[str]:
35
34
  """Run arbitrary commands. Opt-in per driver."""
36
35
  raise NotImplementedError(f"{self.__class__.__name__} does not implement execute()")
37
36
 
@@ -0,0 +1,96 @@
1
+ from datetime import datetime
2
+ from enum import StrEnum
3
+ from typing import Literal
4
+
5
+ from pydantic import BaseModel, model_validator
6
+
7
+ from acex_devkit.models.base import PersistedResponse
8
+
9
+
10
+ class JobState(StrEnum):
11
+ queued = "queued"
12
+ running = "running"
13
+ succeeded = "succeeded"
14
+ failed = "failed"
15
+ cancelled = "cancelled"
16
+
17
+
18
+ class JobSubjectType(StrEnum):
19
+ """The kind of object a job is about, for finding every job that concerns it."""
20
+
21
+ node = "node"
22
+ logical_node = "logical_node"
23
+ asset = "asset"
24
+
25
+
26
+ class JobBase(BaseModel):
27
+ #: The job type, e.g. "acex.ztp.discover". Also the Celery task name.
28
+ type: str
29
+ #: Set on the jobs of a batch. Only one level: a child has no children.
30
+ parent_id: int | None = None
31
+ #: Derived from the job type and its data, never set by the caller.
32
+ subject_type: JobSubjectType | None = None
33
+ subject_id: int | None = None
34
+ #: Typed per job type, and only ids: workers fetch the rest from the API.
35
+ data: dict | None = None
36
+ #: Typed per job type; set when a worker reports success.
37
+ result: dict | None = None
38
+ state: JobState = JobState.queued
39
+ attempts: int = 0
40
+ error: str | None = None
41
+ #: The user's `sub`, or "system" for jobs the backend creates itself.
42
+ created_by: str
43
+ claimed_by: str | None = None
44
+ #: The `sub` of whoever cancelled the job.
45
+ cancelled_by: str | None = None
46
+ created_at: datetime
47
+ started_at: datetime | None = None
48
+ finished_at: datetime | None = None
49
+
50
+
51
+ class JobResponse(PersistedResponse, JobBase):
52
+ #: On a batch's parent: how many of its jobs are in each state. The
53
+ #: parent's own state is derived from these.
54
+ children: dict[JobState, int] | None = None
55
+
56
+
57
+ class JobSummary(PersistedResponse):
58
+ """A job in a listing. Its data, result and error are fetched one job at a time."""
59
+
60
+ type: str
61
+ state: JobState
62
+ parent_id: int | None = None
63
+ subject_type: JobSubjectType | None = None
64
+ subject_id: int | None = None
65
+ attempts: int
66
+ claimed_by: str | None = None
67
+ created_at: datetime
68
+ finished_at: datetime | None = None
69
+ children: dict[JobState, int] | None = None
70
+
71
+
72
+ class JobPurge(BaseModel):
73
+ """How many jobs a purge deleted, batch parents included."""
74
+
75
+ deleted: int
76
+
77
+
78
+ class JobUpdate(BaseModel):
79
+ """What a worker reports on a job it runs: that it has claimed it, or how it ended."""
80
+
81
+ #: running claims the job; succeeded and failed finish it.
82
+ state: Literal[JobState.running, JobState.succeeded, JobState.failed]
83
+ #: With succeeded, for job types that give a result.
84
+ result: dict | None = None
85
+ #: With failed: why.
86
+ error: str | None = None
87
+
88
+ @model_validator(mode="after")
89
+ def _fits_state(self) -> "JobUpdate":
90
+ if self.result is not None and self.state != JobState.succeeded:
91
+ raise ValueError("a result is only reported with state succeeded")
92
+ if self.state == JobState.failed and not self.error:
93
+ raise ValueError("state failed needs an error")
94
+ if self.error is not None and self.state != JobState.failed:
95
+ raise ValueError("an error is only reported with state failed")
96
+ return self
@@ -27,15 +27,19 @@ class NodeAdminStatus(StrEnum):
27
27
 
28
28
 
29
29
  class NodeProvisionStatus(StrEnum):
30
- """Where the node's device is in the provisioning lifecycle. Set by the provisioning flow, not by operators."""
30
+ """Where the node's device is in the provisioning lifecycle. Set by the provisioning flow, not by operators.
31
31
 
32
- unprovisioned = "unprovisioned" # never provisioned by ACEX
33
- adopted = "adopted" # brownfield: already running when brought under management
34
- pending = "pending" # ZTP armed, waiting for the device to show up
35
- bootstrapping = "bootstrapping" # bootstrap config fetched, waiting for SSH reachability
32
+ With no provisioning under way the node sits in a steady state (unprovisioned, adopted,
33
+ provisioned). While it is in ZTP it is enrolled, provisioning or failed; an enrolled
34
+ node's ZTP stage (awaiting device, awaiting approval, ready) comes from its asset.
35
+ """
36
+
37
+ unprovisioned = "unprovisioned" # steady: no provisioning requested, never provisioned by ACEX
38
+ adopted = "adopted" # steady: brownfield, already running when brought under management
39
+ enrolled = "enrolled" # in ZTP, waiting for its device to be discovered, approved and provisioned
36
40
  provisioning = "provisioning" # full config being pushed and verified
37
- provisioned = "provisioned" # provisioned by ACEX and verified
38
- failed = "failed"
41
+ provisioned = "provisioned" # steady: provisioned by ACEX and verified
42
+ failed = "failed" # in ZTP, stuck until retried or cancelled; the reason is on the job
39
43
 
40
44
 
41
45
  class NodeBase(BaseModel):
@@ -0,0 +1,44 @@
1
+ from pydantic import BaseModel, Field, SecretStr, field_serializer
2
+
3
+
4
+ class JobTypeInfo(BaseModel):
5
+ """A job type the backend creates, and the queue that carries it."""
6
+
7
+ name: str
8
+ queue: str
9
+
10
+
11
+ class WorkerConnectRequest(BaseModel):
12
+ """What a starting worker can run, so it is told which queues carry those jobs."""
13
+
14
+ job_types: list[str] = Field(min_length=1)
15
+
16
+
17
+ class BrokerConnection(BaseModel):
18
+ """The RabbitMQ broker a worker consumes from."""
19
+
20
+ host: str
21
+ port: int
22
+ vhost: str
23
+ user: str | None = None
24
+ #: Hidden in repr and logs, but sent in full: the worker has to log in with it.
25
+ password: SecretStr | None = None
26
+
27
+ @field_serializer("password", when_used="json")
28
+ def _send_password(self, password: SecretStr | None) -> str | None:
29
+ return password.get_secret_value() if password is not None else None
30
+
31
+
32
+ class QueueDeclaration(BaseModel):
33
+ """A queue as the backend declares it. A worker must declare it the same
34
+ way: RabbitMQ refuses a second declaration with different arguments."""
35
+
36
+ name: str
37
+ durable: bool
38
+
39
+
40
+ class WorkerConnection(BaseModel):
41
+ """Everything a worker needs to start consuming, handed out by the API."""
42
+
43
+ broker: BrokerConnection
44
+ queues: list[QueueDeclaration]
@@ -0,0 +1,434 @@
1
+ from datetime import datetime
2
+ from enum import StrEnum
3
+ from ipaddress import IPv4Address, IPv4Network
4
+
5
+ from pydantic import BaseModel, IPvAnyAddress
6
+
7
+ from acex_devkit.models.base import PersistedResponse
8
+ from acex_devkit.models.job import JobResponse, JobState
9
+
10
+
11
+ class ZtpMethod(StrEnum):
12
+ """How a device bootstraps itself. One OS can have several methods."""
13
+
14
+ #: The device downloads and runs a Python script (Cisco IOS XE guestshell).
15
+ cisco_iosxe_python = "cisco_iosxe_python"
16
+
17
+
18
+ class ZtpMethodInfo(BaseModel):
19
+ """How a ZTP method is presented to an administrator."""
20
+
21
+ label: str
22
+ description: str
23
+
24
+
25
+ #: One entry per ZtpMethod; add one with every new method.
26
+ ZTP_METHOD_INFO: dict[ZtpMethod, ZtpMethodInfo] = {
27
+ ZtpMethod.cisco_iosxe_python: ZtpMethodInfo(
28
+ label="Cisco IOS XE — Python",
29
+ description="The device downloads ztp.py over HTTP and runs it in guestshell.",
30
+ ),
31
+ }
32
+
33
+
34
+ class ZtpMethodResponse(ZtpMethodInfo):
35
+ """A ZTP method, and what an administrator has chosen for it."""
36
+
37
+ method: ZtpMethod
38
+ #: The NED discovery uses for devices that bootstrap this way. None until one is chosen.
39
+ ned: str | None = None
40
+ #: The temporary login the bootstrap gives a device and discovery logs in with,
41
+ #: until onboarding rotates it. Not a secret: the bootstrap is public.
42
+ bootstrap_username: str | None = None
43
+ bootstrap_password: str | None = None
44
+ #: Devices whose vendor class matches no mapping bootstrap with this method. At most one method is.
45
+ is_default: bool = False
46
+ updated_by: str | None = None
47
+ updated_at: datetime | None = None
48
+
49
+
50
+ class ZtpMethodUpdate(BaseModel):
51
+ """Change what is chosen for a ZTP method. Only the fields sent change; None clears one."""
52
+
53
+ ned: str | None = None
54
+ bootstrap_username: str | None = None
55
+ bootstrap_password: str | None = None
56
+ #: True makes this the default method, and no other; false leaves no default.
57
+ is_default: bool | None = None
58
+
59
+
60
+ class ZtpVendorClassMatch(StrEnum):
61
+ """How the vendor class (option 60) a device sends is compared to a mapped one."""
62
+
63
+ exact = "exact"
64
+ #: The device's vendor class starts with it, for vendors that add model or serial.
65
+ prefix = "prefix"
66
+
67
+
68
+ class ZtpVendorClassCreate(BaseModel):
69
+ """A DHCP vendor class (option 60) a device may send, and the ZTP method it bootstraps with."""
70
+
71
+ #: Free text, matched by the DHCP server as `match` says.
72
+ vendor_class: str
73
+ match: ZtpVendorClassMatch = ZtpVendorClassMatch.exact
74
+ #: None until an administrator maps it.
75
+ method: ZtpMethod | None = None
76
+
77
+
78
+ class ZtpVendorClassUpdate(BaseModel):
79
+ """Change a vendor class. Only the fields sent change; method None unmaps it."""
80
+
81
+ vendor_class: str | None = None
82
+ match: ZtpVendorClassMatch | None = None
83
+ method: ZtpMethod | None = None
84
+
85
+
86
+ class ZtpVendorClassResponse(PersistedResponse):
87
+ vendor_class: str
88
+ match: ZtpVendorClassMatch
89
+ method: ZtpMethod | None = None
90
+ updated_by: str | None = None
91
+ updated_at: datetime | None = None
92
+
93
+
94
+ class ZtpDhcpScopeCreate(BaseModel):
95
+ """An IPv4 network the ZTP DHCP server hands out addresses in."""
96
+
97
+ network: IPv4Network
98
+ #: The addresses handed out, both inclusive, inside the network.
99
+ pool_start: IPv4Address
100
+ pool_end: IPv4Address
101
+ #: Option 3. Inside the network, outside the pool.
102
+ gateway: IPv4Address
103
+ #: Option 6.
104
+ dns_servers: list[IPv4Address] = []
105
+ #: Option 15.
106
+ domain_name: str | None = None
107
+ description: str | None = None
108
+
109
+
110
+ class ZtpDhcpScopeUpdate(BaseModel):
111
+ """Change a scope. Only the fields sent change; None clears an optional one."""
112
+
113
+ network: IPv4Network | None = None
114
+ pool_start: IPv4Address | None = None
115
+ pool_end: IPv4Address | None = None
116
+ gateway: IPv4Address | None = None
117
+ dns_servers: list[IPv4Address] | None = None
118
+ domain_name: str | None = None
119
+ description: str | None = None
120
+
121
+
122
+ class ZtpDhcpScopeResponse(PersistedResponse, ZtpDhcpScopeCreate):
123
+ updated_by: str | None = None
124
+ updated_at: datetime | None = None
125
+
126
+
127
+ class ZtpDiscoverData(BaseModel):
128
+ """Data of an acex.ztp.discover job: a device that fetched its ZTP bootstrap."""
129
+
130
+ #: The address the device fetched its bootstrap from, which discovery logs in to.
131
+ ip: IPvAnyAddress
132
+ #: The bootstrap method it used. Decides how it is discovered.
133
+ method: ZtpMethod
134
+
135
+
136
+ class ZtpProvisionData(BaseModel):
137
+ """Data of an acex.ztp.provision job: a node whose approved device gets its full configuration."""
138
+
139
+ node_id: int
140
+ #: The bootstrap method the device used. Decides which bootstrap login provisioning logs in with.
141
+ method: ZtpMethod
142
+
143
+
144
+ class ZtpProvisionResult(BaseModel):
145
+ """Worker response from accessing the node
146
+ during provision"""
147
+
148
+ lines_sent: int
149
+ output: str
150
+
151
+
152
+ class ZtpDiscoverResult(BaseModel):
153
+ """Result of an acex.ztp.discover job: what the device says it is.
154
+
155
+ Self-reported and unverified, so plain strings: a value ACEX has no enum
156
+ member for is still worth showing to the administrator who reviews it.
157
+ """
158
+
159
+ serial_number: str
160
+ vendor: str | None = None
161
+ hardware_model: str | None = None
162
+ os: str | None = None
163
+ os_version: str | None = None
164
+
165
+
166
+ class ZtpReviewStatus(StrEnum):
167
+ """Where a discovery stands with the administrator who has to approve it."""
168
+
169
+ unreviewed = "unreviewed"
170
+ approved = "approved"
171
+ rejected = "rejected"
172
+
173
+
174
+ class ZtpConflictKind(StrEnum):
175
+ #: The same serial number was reported from another IP.
176
+ serial_on_other_ip = "serial_on_other_ip"
177
+ #: The same IP has reported another serial number.
178
+ ip_with_other_serial = "ip_with_other_serial"
179
+ #: The serial belongs to a provisioned node: the device may have been reset.
180
+ serial_of_provisioned_node = "serial_of_provisioned_node"
181
+
182
+
183
+ #: Conflicts that put the device's identity in doubt. A bulk approval skips a
184
+ #: discovery with one; it can still be approved on its own.
185
+ IDENTITY_CONFLICTS = frozenset({ZtpConflictKind.serial_on_other_ip, ZtpConflictKind.ip_with_other_serial})
186
+
187
+
188
+ class ZtpConflict(BaseModel):
189
+ """Something about a discovery an administrator should look at before approving it."""
190
+
191
+ kind: ZtpConflictKind
192
+ message: str
193
+ #: The other discovery involved, for the kinds that compare two.
194
+ discovery_id: int | None = None
195
+ #: The node involved, for serial_of_provisioned_node.
196
+ node_id: int | None = None
197
+
198
+
199
+ class ZtpApprovalAction(StrEnum):
200
+ """What approving a discovery does to the asset with its serial number."""
201
+
202
+ #: No asset has the serial; one is created from the reported facts.
203
+ create = "create"
204
+ #: The asset exists; the reported facts that differ are written to it.
205
+ update = "update"
206
+ #: The asset exists and already matches.
207
+ unchanged = "unchanged"
208
+ #: The discovery cannot be approved; `blocked_by` says why.
209
+ blocked = "blocked"
210
+
211
+
212
+ class ZtpFactChange(BaseModel):
213
+ field: str
214
+ current: str | None = None
215
+ reported: str | None = None
216
+
217
+
218
+ class ZtpProposedAsset(BaseModel):
219
+ """The asset as it will be once the discovery is approved."""
220
+
221
+ serial_number: str
222
+ vendor: str | None = None
223
+ hardware_model: str | None = None
224
+ os: str | None = None
225
+ os_version: str | None = None
226
+ ned_id: str | None = None
227
+
228
+
229
+ class ZtpApprovalProposal(BaseModel):
230
+ """What approving the discovery would do, computed by the same code that approves it."""
231
+
232
+ action: ZtpApprovalAction
233
+ #: The existing asset; None for create and for some blocked proposals.
234
+ asset_id: int | None = None
235
+ asset: ZtpProposedAsset | None = None
236
+ #: For update: the facts that will be written.
237
+ changes: list[ZtpFactChange] = []
238
+ blocked_by: list[str] = []
239
+ #: Identifies this exact proposal. Sent back on approve, so nothing is
240
+ #: written that differs from what the administrator saw.
241
+ hash: str
242
+
243
+
244
+ class ZtpDiscoveryResponse(PersistedResponse, ZtpDiscoverResult):
245
+ """A discovery: the device at `source_ip` says it is `serial_number`."""
246
+
247
+ job_id: int | None = None
248
+ source_ip: str
249
+ #: The asset the discovery was approved as; None until approved.
250
+ asset_id: int | None = None
251
+ #: The node the asset with this serial is bound to, if any. Approval never changes it.
252
+ node_id: int | None = None
253
+ #: Hostname of that node's logical node.
254
+ node_hostname: str | None = None
255
+ review_status: ZtpReviewStatus
256
+ reviewed_by: str | None = None
257
+ reviewed_at: datetime | None = None
258
+ created_at: datetime
259
+ conflicts: list[ZtpConflict] = []
260
+ #: What approving would do; None once reviewed.
261
+ proposal: ZtpApprovalProposal | None = None
262
+
263
+
264
+ class ZtpApproveRequest(BaseModel):
265
+ #: The hash of the proposal the administrator saw. Approval is refused if it
266
+ #: no longer matches. Leave out to approve whatever the proposal now is.
267
+ proposal_hash: str | None = None
268
+
269
+
270
+ class ZtpBulkApproveItem(BaseModel):
271
+ discovery_id: int
272
+ proposal_hash: str | None = None
273
+
274
+
275
+ class ZtpBulkApprove(BaseModel):
276
+ items: list[ZtpBulkApproveItem]
277
+ #: Work out what would happen without writing anything.
278
+ dry_run: bool = False
279
+
280
+
281
+ class ZtpBulkReject(BaseModel):
282
+ discovery_ids: list[int]
283
+
284
+
285
+ class ZtpBulkOutcome(StrEnum):
286
+ approved = "approved"
287
+ #: A dry run would approve it.
288
+ would_approve = "would_approve"
289
+ rejected = "rejected"
290
+ skipped = "skipped"
291
+
292
+
293
+ class ZtpBulkItemResult(BaseModel):
294
+ discovery_id: int
295
+ outcome: ZtpBulkOutcome
296
+ #: Why it was skipped.
297
+ reason: str | None = None
298
+ proposal: ZtpApprovalProposal | None = None
299
+
300
+
301
+ class ZtpBulkResult(BaseModel):
302
+ results: list[ZtpBulkItemResult]
303
+ #: Counts per proposal action (approve) and per outcome.
304
+ summary: dict[str, int]
305
+
306
+
307
+ class ZtpCallStage(StrEnum):
308
+ """Where a device that called in stands before anyone reviews it, from its latest discovery job."""
309
+
310
+ #: Fetched its bootstrap; no worker has picked up the discovery yet.
311
+ waiting = "waiting"
312
+ #: A worker is logging in to it.
313
+ discovering = "discovering"
314
+ #: The worker could not find out what it is.
315
+ failed = "failed"
316
+ #: It said what it is; the discovery waits in review.
317
+ reported = "reported"
318
+
319
+
320
+ class ZtpCallAttempt(BaseModel):
321
+ """One bootstrap fetch and the discovery job it started."""
322
+
323
+ job_id: int
324
+ state: JobState
325
+ attempts: int
326
+ error: str | None = None
327
+ created_at: datetime
328
+ started_at: datetime | None = None
329
+ finished_at: datetime | None = None
330
+ #: Set once the worker reported what the device is.
331
+ discovery_id: int | None = None
332
+ serial_number: str | None = None
333
+ review_status: ZtpReviewStatus | None = None
334
+
335
+
336
+ class ZtpCall(BaseModel):
337
+ """A device that called in, by the IP it fetched its bootstrap from.
338
+
339
+ A device that reboots calls in again, so one IP gathers several attempts.
340
+ """
341
+
342
+ source_ip: str
343
+ method: ZtpMethod
344
+ stage: ZtpCallStage
345
+ #: When it last fetched its bootstrap.
346
+ last_seen: datetime
347
+ #: Newest first.
348
+ attempts: list[ZtpCallAttempt]
349
+
350
+
351
+ class ZtpAssetReadiness(StrEnum):
352
+ """How far a node's device has come before provisioning, from the discoveries of its serial."""
353
+
354
+ #: No discovery of the serial, or only rejected ones.
355
+ unseen = "unseen"
356
+ #: The latest discovery waits for an administrator.
357
+ awaiting_review = "awaiting_review"
358
+ #: The latest discovery is approved: identity and address are trusted.
359
+ ready = "ready"
360
+
361
+
362
+ class ZtpStage(StrEnum):
363
+ """Where a node stands in ZTP.
364
+
365
+ Before provisioning starts this comes from the asset's readiness; from then
366
+ on it is the node's provision_status.
367
+ """
368
+
369
+ awaiting_device = "awaiting_device"
370
+ awaiting_approval = "awaiting_approval"
371
+ ready = "ready"
372
+ provisioning = "provisioning"
373
+ provisioned = "provisioned"
374
+ failed = "failed"
375
+ adopted = "adopted"
376
+ unprovisioned = "unprovisioned"
377
+
378
+
379
+ #: The stages a node is in the pipeline for.
380
+ PIPELINE_STAGES = (
381
+ ZtpStage.awaiting_device,
382
+ ZtpStage.awaiting_approval,
383
+ ZtpStage.ready,
384
+ ZtpStage.provisioning,
385
+ ZtpStage.failed,
386
+ )
387
+
388
+
389
+ class ZtpNodeAsset(BaseModel):
390
+ id: int
391
+ serial_number: str
392
+ vendor: str | None = None
393
+ hardware_model: str | None = None
394
+ os: str | None = None
395
+ os_version: str | None = None
396
+ ned_id: str | None = None
397
+
398
+
399
+ class ZtpPipelineNode(BaseModel):
400
+ """A node and where it stands in ZTP."""
401
+
402
+ node_id: int
403
+ hostname: str | None = None
404
+ site: str | None = None
405
+ admin_status: str
406
+ provision_status: str
407
+ #: The status the node had when it was enrolled; cancelling ZTP returns it there.
408
+ ztp_previous_status: str | None = None
409
+ stage: ZtpStage
410
+ #: Why a provisioning node is not getting anywhere: no job, or a job lost on its queue or its worker.
411
+ stalled: str | None = None
412
+ #: None when the node is bound to an asset cluster.
413
+ asset: ZtpNodeAsset | None = None
414
+ readiness: ZtpAssetReadiness | None = None
415
+ #: When the device was last discovered.
416
+ last_seen: datetime | None = None
417
+ #: The address of the approved discovery, which provisioning uses.
418
+ approved_ip: str | None = None
419
+
420
+
421
+ class ZtpNodeDetail(ZtpPipelineNode):
422
+ #: Discoveries of the asset's serial, newest first.
423
+ discoveries: list[ZtpDiscoveryResponse] = []
424
+ #: Provisioning jobs of the node, newest first.
425
+ provision_jobs: list[JobResponse] = []
426
+
427
+
428
+ class ZtpPipelineSummary(BaseModel):
429
+ #: Nodes per stage, every node counted once.
430
+ stages: dict[ZtpStage, int]
431
+ #: Approved assets not bound to any node: ready for a node to be assigned.
432
+ pool: int
433
+ #: Provisioning nodes that are not getting anywhere.
434
+ stalled: int = 0
File without changes