1 | """Main module of kytos/maintenance Kytos Network Application. |
||
2 | |||
3 | This NApp creates maintenance windows, allowing the maintenance of network |
||
4 | devices (switch, link, and interface) without receiving alerts. |
||
5 | """ |
||
6 | |||
7 | 1 | from datetime import timedelta |
|
8 | |||
9 | 1 | from napps.kytos.maintenance.managers import MaintenanceDeployer as Deployer |
|
10 | 1 | from napps.kytos.maintenance.managers import MaintenanceScheduler as Scheduler |
|
11 | 1 | from napps.kytos.maintenance.models import MaintenanceID |
|
12 | 1 | from napps.kytos.maintenance.models import MaintenanceWindow as MW |
|
13 | 1 | from napps.kytos.maintenance.models import OverlapError, Status |
|
14 | 1 | from pydantic import ValidationError |
|
15 | 1 | from pymongo.errors import DuplicateKeyError |
|
16 | |||
17 | 1 | from kytos.core import KytosNApp, rest |
|
18 | 1 | from kytos.core.rest_api import ( |
|
19 | HTTPException, |
||
20 | JSONResponse, |
||
21 | Request, |
||
22 | Response, |
||
23 | error_msg, |
||
24 | get_json_or_400, |
||
25 | ) |
||
26 | |||
27 | |||
28 | 1 | class Main(KytosNApp): |
|
29 | """Main class of kytos/maintenance NApp. |
||
30 | |||
31 | This class is the entry point for this napp. |
||
32 | """ |
||
33 | |||
34 | 1 | def setup(self): |
|
35 | """Replace the '__init__' method for the KytosNApp subclass. |
||
36 | |||
37 | The setup method is automatically called by the controller when your |
||
38 | application is loaded. |
||
39 | |||
40 | So, if you have any setup routine, insert it here. |
||
41 | """ |
||
42 | 1 | self.maintenance_deployer = Deployer.new_deployer(self.controller) |
|
43 | 1 | self.scheduler = Scheduler.new_scheduler(self.maintenance_deployer) |
|
44 | 1 | self.scheduler.start() |
|
45 | |||
46 | 1 | def execute(self): |
|
47 | """Run after the setup method execution. |
||
48 | |||
49 | You can also use this method in loop mode if you add to the above setup |
||
50 | method a line like the following example: |
||
51 | |||
52 | self.execute_as_loop(30) # 30-second interval. |
||
53 | """ |
||
54 | |||
55 | 1 | def shutdown(self): |
|
56 | """Run when your napp is unloaded. |
||
57 | |||
58 | If you have some cleanup procedure, insert it here. |
||
59 | """ |
||
60 | self.scheduler.shutdown() |
||
61 | |||
62 | 1 | @rest("/v1", methods=["GET"]) |
|
63 | 1 | def get_all_mw(self, _request: Request) -> Response: |
|
64 | """Return all maintenance windows.""" |
||
65 | 1 | maintenances = self.scheduler.list_maintenances() |
|
66 | 1 | return Response( |
|
67 | f"{maintenances.json()}\n", |
||
68 | status_code=200, |
||
69 | media_type="application/json", |
||
70 | ) |
||
71 | |||
72 | 1 | @rest("/v1/{mw_id}", methods=["GET"]) |
|
73 | 1 | def get_mw(self, request: Request) -> Response: |
|
74 | """Return one maintenance window.""" |
||
75 | 1 | mw_id: MaintenanceID = request.path_params["mw_id"] |
|
76 | 1 | window = self.scheduler.get_maintenance(mw_id) |
|
77 | 1 | if window: |
|
78 | 1 | return Response( |
|
79 | f"{window.json()}\n", |
||
80 | status_code=200, |
||
81 | media_type="application/json", |
||
82 | ) |
||
83 | 1 | raise HTTPException(404, f"Maintenance with id {mw_id} not found") |
|
84 | |||
85 | 1 | @rest("/v1", methods=["POST"]) |
|
86 | 1 | def create_mw(self, request: Response) -> JSONResponse: |
|
87 | """Create a new maintenance window.""" |
||
88 | 1 | data = get_json_or_400(request, self.controller.loop) |
|
89 | 1 | if not isinstance(data, dict) or not data: |
|
90 | raise HTTPException(400, detail=f"Invalid json body value: {data}") |
||
91 | |||
92 | 1 | if "status" in data: |
|
93 | 1 | raise HTTPException( |
|
94 | 400, detail="Setting a maintenance status is not allowed" |
||
95 | ) |
||
96 | # if 'id' in data: |
||
97 | # raise HTTPException( |
||
98 | # 400, detail='Setting a maintenance id is not allowed' |
||
99 | # ) |
||
100 | 1 | try: |
|
101 | 1 | maintenance = MW.model_validate(data) |
|
102 | 1 | force = data.get("force", False) |
|
103 | 1 | ignore_no_exists = data.get("ignore_no_exists") |
|
104 | 1 | if not ignore_no_exists: |
|
105 | 1 | self.validate_item_existence(maintenance) |
|
106 | 1 | self.scheduler.add(maintenance, force=force) |
|
107 | 1 | except ValidationError as err: |
|
108 | 1 | msg = error_msg(err.errors()) |
|
109 | 1 | raise HTTPException(400, detail=msg) from err |
|
110 | except DuplicateKeyError as err: |
||
111 | raise HTTPException( |
||
112 | 409, detail=f"Window with id: {maintenance.id} already exists" |
||
113 | ) from err |
||
114 | except OverlapError as err: |
||
115 | raise HTTPException(400, detail=f"{err}") from err |
||
116 | except ValueError as err: |
||
117 | raise HTTPException(400, detail=f"{err}") from err |
||
118 | 1 | return JSONResponse({"mw_id": maintenance.id}, status_code=201) |
|
119 | |||
120 | 1 | @rest("/v1/{mw_id}", methods=["PATCH"]) |
|
121 | 1 | def update_mw(self, request: Request) -> JSONResponse: |
|
122 | """Update a maintenance window.""" |
||
123 | 1 | data = get_json_or_400(request, self.controller.loop) |
|
124 | 1 | if not isinstance(data, dict) or not data: |
|
125 | raise HTTPException(400, detail=f"Invalid json body value: {data}") |
||
126 | |||
127 | 1 | mw_id: MaintenanceID = request.path_params["mw_id"] |
|
128 | 1 | old_maintenance = self.scheduler.get_maintenance(mw_id) |
|
129 | 1 | if old_maintenance is None: |
|
130 | 1 | raise HTTPException(404, detail=f"Maintenance with id {mw_id} not found") |
|
131 | 1 | if old_maintenance.status == Status.RUNNING: |
|
132 | raise HTTPException( |
||
133 | 400, detail="Updating a running maintenance is not allowed" |
||
134 | ) |
||
135 | 1 | if "status" in data: |
|
136 | 1 | raise HTTPException( |
|
137 | 400, detail="Updating a maintenance status is not allowed" |
||
138 | ) |
||
139 | 1 | try: |
|
140 | 1 | new_maintenance = MW.model_validate( |
|
141 | {**old_maintenance.model_dump(), **data} |
||
142 | ) |
||
143 | 1 | except ValidationError as err: |
|
144 | 1 | msg = error_msg(err.errors()) |
|
145 | 1 | raise HTTPException(400, detail=msg) from err |
|
146 | 1 | if new_maintenance.id != old_maintenance.id: |
|
147 | raise HTTPException(400, detail="Updated id must match old id") |
||
148 | 1 | self.scheduler.update(new_maintenance) |
|
149 | 1 | return JSONResponse({"response": f"Maintenance {mw_id} updated"}) |
|
150 | |||
151 | 1 | @rest("/v1/{mw_id}", methods=["DELETE"]) |
|
152 | 1 | def remove_mw(self, request: Request) -> JSONResponse: |
|
153 | """Delete a maintenance window.""" |
||
154 | 1 | mw_id: MaintenanceID = request.path_params["mw_id"] |
|
155 | 1 | maintenance = self.scheduler.get_maintenance(mw_id) |
|
156 | 1 | if maintenance is None: |
|
157 | 1 | raise HTTPException(404, detail=f"Maintenance with id {mw_id} not found") |
|
158 | 1 | if maintenance.status == Status.RUNNING: |
|
159 | 1 | raise HTTPException( |
|
160 | 400, detail="Deleting a running maintenance is not allowed" |
||
161 | ) |
||
162 | 1 | self.scheduler.remove(mw_id) |
|
163 | 1 | return JSONResponse( |
|
164 | {"response": f"Maintenance with id {mw_id} successfully removed"} |
||
165 | ) |
||
166 | |||
167 | 1 | @rest("/v1/{mw_id}/end", methods=["PATCH"]) |
|
168 | 1 | def end_mw(self, request: Request) -> JSONResponse: |
|
169 | """Finish a maintenance window right now.""" |
||
170 | 1 | mw_id: MaintenanceID = request.path_params["mw_id"] |
|
171 | 1 | maintenance = self.scheduler.get_maintenance(mw_id) |
|
172 | 1 | if maintenance is None: |
|
173 | 1 | raise HTTPException(404, detail=f"Maintenance with id {mw_id} not found") |
|
174 | 1 | if maintenance.status == Status.PENDING: |
|
175 | 1 | raise HTTPException( |
|
176 | 400, detail=f"Maintenance window {mw_id} has not yet started" |
||
177 | ) |
||
178 | 1 | if maintenance.status == Status.FINISHED: |
|
179 | 1 | raise HTTPException( |
|
180 | 400, detail=f"Maintenance window {mw_id} has already finished" |
||
181 | ) |
||
182 | 1 | self.scheduler.end_maintenance_early(mw_id) |
|
183 | 1 | return JSONResponse({"response": f"Maintenance window {mw_id} " f"finished"}) |
|
184 | |||
185 | 1 | @rest("/v1/{mw_id}/extend", methods=["PATCH"]) |
|
186 | 1 | def extend_mw(self, request: Request) -> JSONResponse: |
|
187 | """Extend a running maintenance window.""" |
||
188 | 1 | mw_id: MaintenanceID = request.path_params["mw_id"] |
|
189 | 1 | data = get_json_or_400(request, self.controller.loop) |
|
190 | 1 | if not isinstance(data, dict): |
|
191 | raise HTTPException(400, detail=f"Invalid json body value: {data}") |
||
192 | |||
193 | 1 | maintenance = self.scheduler.get_maintenance(mw_id) |
|
194 | 1 | if maintenance is None: |
|
195 | 1 | raise HTTPException(404, detail=f"Maintenance with id {mw_id} not found") |
|
196 | 1 | if "minutes" not in data: |
|
197 | 1 | raise HTTPException(400, detail="Minutes of extension must be sent") |
|
198 | 1 | if maintenance.status == Status.PENDING: |
|
199 | 1 | raise HTTPException( |
|
200 | 400, detail=f"Maintenance window {mw_id} has not yet started" |
||
201 | ) |
||
202 | 1 | if maintenance.status == Status.FINISHED: |
|
203 | 1 | raise HTTPException( |
|
204 | 400, detail=f"Maintenance window {mw_id} has already finished" |
||
205 | ) |
||
206 | 1 | try: |
|
207 | 1 | maintenance_end = maintenance.end + timedelta(minutes=data["minutes"]) |
|
208 | 1 | new_maintenance = maintenance.copy(update={"end": maintenance_end}) |
|
209 | 1 | except TypeError as exc: |
|
210 | 1 | raise HTTPException( |
|
211 | 400, detail="Minutes of extension must be integer" |
||
212 | ) from exc |
||
213 | |||
214 | 1 | self.scheduler.update(new_maintenance) |
|
215 | 1 | return JSONResponse({"response": f"Maintenance {mw_id} extended"}) |
|
216 | |||
217 | 1 | def validate_item_existence(self, window: MW): |
|
218 | """Validate that all items in a maintenance window exist.""" |
||
219 | 1 | non_existant_switches = list( |
|
220 | filter( |
||
221 | lambda switch_id: self.controller.switches.get(switch_id) is None, |
||
222 | window.switches, |
||
223 | ) |
||
224 | ) |
||
225 | 1 | non_existant_interfaces = list( |
|
226 | filter( |
||
227 | lambda interface_id: self.controller.get_interface_by_id(interface_id) |
||
228 | is None, |
||
229 | window.interfaces, |
||
230 | ) |
||
231 | ) |
||
232 | 1 | non_existant_links = list( |
|
233 | filter( |
||
234 | lambda link_id: self.controller.links.get(link_id) is None, |
||
235 | window.links, |
||
236 | ) |
||
237 | ) |
||
238 | |||
239 | 1 | if non_existant_switches or non_existant_interfaces or non_existant_links: |
|
240 | items = { |
||
241 | "switches": non_existant_switches, |
||
242 | "interfaces": non_existant_interfaces, |
||
243 | "links": non_existant_links, |
||
244 | } |
||
245 | raise HTTPException(400, f"Window contains non-existant items: {items}") |
||
246 |