1 | """Models used by the maintenance NApp. |
||
2 | |||
3 | This module define models for the maintenance window itself and the |
||
4 | scheduler. |
||
5 | """ |
||
6 | |||
7 | 1 | from datetime import datetime, timezone |
|
8 | 1 | from enum import Enum |
|
9 | 1 | from typing import NewType, Optional |
|
10 | 1 | from uuid import uuid4 |
|
11 | |||
12 | 1 | import pytz |
|
13 | |||
14 | # pylint: disable=no-name-in-module |
||
15 | 1 | from pydantic import ( |
|
16 | BaseModel, |
||
17 | Field, |
||
18 | RootModel, |
||
19 | ValidationInfo, |
||
20 | field_validator, |
||
21 | model_validator, |
||
22 | ) |
||
23 | |||
24 | # pylint: enable=no-name-in-module |
||
25 | |||
26 | 1 | TIME_FMT = "%Y-%m-%dT%H:%M:%S%z" |
|
27 | |||
28 | |||
29 | 1 | class Status(str, Enum): |
|
30 | """Maintenance windows status.""" |
||
31 | |||
32 | 1 | PENDING = "pending" |
|
33 | 1 | RUNNING = "running" |
|
34 | 1 | FINISHED = "finished" |
|
35 | |||
36 | |||
37 | 1 | MaintenanceID = NewType("MaintenanceID", str) |
|
38 | |||
39 | |||
40 | 1 | class MaintenanceWindow(BaseModel): |
|
41 | """Class for structure of maintenance windows.""" |
||
42 | |||
43 | 1 | start: datetime |
|
44 | 1 | end: Optional[datetime] = Field(default=datetime.max.replace(tzinfo=timezone.utc)) |
|
45 | 1 | switches: list[str] = Field(default_factory=list) |
|
46 | 1 | interfaces: list[str] = Field(default_factory=list) |
|
47 | 1 | links: list[str] = Field(default_factory=list) |
|
48 | 1 | id: MaintenanceID = Field(default_factory=lambda: MaintenanceID(uuid4().hex)) |
|
49 | 1 | description: str = Field(default="") |
|
50 | 1 | status: Status = Field(default=Status.PENDING) |
|
51 | 1 | inserted_at: Optional[datetime] = Field(default=None) |
|
52 | 1 | updated_at: Optional[datetime] = Field(default=None) |
|
53 | |||
54 | # pylint: disable=no-self-argument |
||
55 | |||
56 | 1 | @field_validator("start", "end", mode="before") |
|
57 | 1 | @classmethod |
|
58 | 1 | def convert_time(cls, time): |
|
59 | """Convert time strings using TIME_FMT""" |
||
60 | 1 | if isinstance(time, str): |
|
61 | 1 | time = datetime.strptime(time, TIME_FMT) |
|
62 | 1 | return time |
|
63 | |||
64 | 1 | @field_validator("start") |
|
65 | 1 | @classmethod |
|
66 | 1 | def check_start_in_past(cls, start_time): |
|
67 | """Check if the start is set to occur before now.""" |
||
68 | 1 | if start_time < datetime.now(pytz.utc): |
|
69 | 1 | raise ValueError("Start in the past not allowed") |
|
70 | 1 | return start_time |
|
71 | |||
72 | 1 | @field_validator("end") |
|
73 | 1 | @classmethod |
|
74 | 1 | def check_end_before_start(cls, end_time, values: ValidationInfo): |
|
75 | """Check if the end is set to occur before the start.""" |
||
76 | 1 | if end_time is None: |
|
77 | end_time = cls.model_fields["end"].get_default() |
||
78 | 1 | if "start" in values.data and end_time <= values.data["start"]: |
|
79 | 1 | raise ValueError("End before start not allowed") |
|
80 | 1 | return end_time |
|
81 | |||
82 | 1 | @model_validator(mode="after") |
|
83 | 1 | def check_items_empty(self): |
|
84 | """Check if no items are in the maintenance window.""" |
||
85 | 1 | no_items = all( |
|
86 | map( |
||
87 | lambda key: key not in self.model_dump() |
||
88 | or len(self.model_dump()[key]) == 0, |
||
89 | ["switches", "links", "interfaces"], |
||
90 | ) |
||
91 | ) |
||
92 | 1 | if no_items: |
|
93 | 1 | raise ValueError("At least one item must be provided") |
|
94 | 1 | return self |
|
95 | |||
96 | # pylint: enable=no-self-argument |
||
97 | |||
98 | 1 | def __str__(self) -> str: |
|
99 | return f"'{self.id}'<{self.start} to {self.end}>" |
||
100 | |||
101 | 1 | class Config: |
|
102 | """Config for encoding MaintenanceWindow class""" |
||
103 | |||
104 | 1 | json_encoders = { |
|
105 | datetime: lambda v: v.strftime(TIME_FMT), |
||
106 | } |
||
107 | |||
108 | |||
109 | 1 | class MaintenanceWindows(RootModel): |
|
110 | """List of Maintenance Windows for json conversion.""" |
||
111 | |||
112 | 1 | root: list[MaintenanceWindow] |
|
113 | |||
114 | 1 | def __iter__(self): |
|
115 | return iter(self.root) |
||
116 | |||
117 | 1 | def __getitem__(self, item): |
|
118 | return self.root[item] |
||
119 | |||
120 | 1 | def __len__(self): |
|
121 | return len(self.root) |
||
122 | |||
123 | 1 | class Config: |
|
124 | """Config for encoding MaintenanceWindows class""" |
||
125 | |||
126 | 1 | json_encoders = { |
|
127 | datetime: lambda v: v.strftime(TIME_FMT), |
||
128 | } |
||
129 | |||
130 | |||
131 | 1 | class OverlapError(Exception): |
|
132 | """ |
||
133 | Exception for when a Maintenance Windows execution |
||
134 | period overlaps with one or more windows. |
||
135 | """ |
||
136 | |||
137 | 1 | new_window: MaintenanceWindow |
|
138 | 1 | interfering: MaintenanceWindows |
|
139 | |||
140 | 1 | def __init__(self, new_window: MaintenanceWindow, interfering: MaintenanceWindows): |
|
141 | self.new_window = new_window |
||
142 | self.interfering = interfering |
||
143 | |||
144 | 1 | def __str__(self): |
|
145 | return ( |
||
146 | f"Maintenance Window {self.new_window} " |
||
147 | + "interferes with the following windows: " |
||
148 | + "[" |
||
149 | + ", ".join([f"{window}" for window in self.interfering]) |
||
150 | + "]" |
||
151 | ) |
||
152 |