diff --git a/lambdas/service/app.py b/lambdas/service/app.py index 1495d0abab..b428f23f7c 100644 --- a/lambdas/service/app.py +++ b/lambdas/service/app.py @@ -69,6 +69,9 @@ from azul.logging import ( configure_app_logging, ) +from azul.maintenance import ( + MaintenanceService, +) from azul.openapi import ( application_json, format_description as fd, @@ -324,6 +327,10 @@ def manifest_controller(self) -> ManifestController: return self._service_controller(ManifestController, manifest_url_func=manifest_url_func) + @cached_property + def maintenance_service(self) -> MaintenanceService: + return MaintenanceService() + def _service_controller(self, controller_cls: Type[C], **kwargs) -> C: file_url_func: FileUrlFunc = self.file_url return self._controller(controller_cls, @@ -839,6 +846,16 @@ def get_integrations(): body=json.dumps(body)) +@app.route( + '/maintenance/schedule', + methods=['GET'], + cors=True +# TODO: spect-out +) +def get_maintenance(): + return app.maintenance_service.list_upcoming_events() + + @app.route( '/index/catalogs', methods=['GET'], diff --git a/scripts/manage_maintenance.py b/scripts/manage_maintenance.py new file mode 100644 index 0000000000..da0bf2a46d --- /dev/null +++ b/scripts/manage_maintenance.py @@ -0,0 +1,84 @@ +import argparse +import json + +from azul.maintenance import ( + MaintenanceService, + MaintenanceEvent +) + +from datetime import datetime, timedelta + + +def parse_duration(duration_str): + # TODO, Code to parse the duration string (e.g., "1h30m", "2d") + # and return a timedelta object + return 'TODO' + + +def main(): + parser = argparse.ArgumentParser(description="Maintenance Schedule CLI") + subparsers = parser.add_subparsers(dest="command") + + list_parser = subparsers.add_parser("list", help="List events in JSON form") + list_parser.add_argument("--all", action="store_true", + help="Include completed events") + + add_parser = subparsers.add_parser("add", help="Schedule an event") + add_parser.add_argument("--start", required=True, + help="Event start time (ISO format)") + add_parser.add_argument("--duration", required=True, + help="Event duration (e.g., '1h30m', '2d')") + add_parser.add_argument("--description", required=True, + help="Event description") + add_parser.add_argument("--partial-responses", nargs="+", + help="Catalog names for partial responses") + add_parser.add_argument("--degraded-performance", nargs="+", + help="Catalog names for degraded performance") + add_parser.add_argument("--service-unavailable", nargs="+", + help="Catalog names for service unavailability") + + cancel_parser = subparsers.add_parser("cancel", + help="Cancel a pending event") + cancel_parser.add_argument("--index", type=int, required=True, + help="Index of the event to cancel") + + start_parser = subparsers.add_parser("start", + help="Activate a pending event") + start_parser.add_argument("--index", type=int, required=True, + help="Index of the event to start") + + end_parser = subparsers.add_parser("end", help="Complete the active event") + + adjust_parser = subparsers.add_parser("adjust", + help="Modify the active event") + adjust_parser.add_argument("--duration", required=True, + help="New event duration (e.g., '1h30m', '2d')") + + args = parser.parse_args() + + event_service = MaintenanceService() + + if args.command == "list": + events = event_service.list_upcoming_events(args.all) + print(json.dumps(events, indent=4)) + + elif args.command == "add": + event = MaintenanceEvent.from_args(args) + event_service.add_event(event) + + elif args.command == "cancel": + event_service.cancel_event(args.index) + + elif args.command == "start": + event_service.start_event(args.index) + + elif args.command == "end": + event_service.end_event() + + elif args.command == "adjust": + new_duration = parse_duration(args.duration) + event_service.adjust_event(new_duration) + + +if __name__ == "__main__": + main() diff --git a/src/azul/maintenance.py b/src/azul/maintenance.py index 4e56ed9270..3a5b3542f1 100644 --- a/src/azul/maintenance.py +++ b/src/azul/maintenance.py @@ -11,10 +11,10 @@ from operator import ( attrgetter, ) -import sys from typing import ( Iterator, Sequence, + Self, ) import attrs @@ -27,10 +27,19 @@ JSON, reject, require, + CatalogName, + cached_property, + config, ) from azul.collections import ( adict, ) +from azul.deployment import ( + aws, +) +from azul.service.storage_service import ( + StorageService, +) from azul.time import ( format_dcp2_datetime, parse_dcp2_datetime, @@ -46,7 +55,7 @@ class MaintenanceImpactKind(Enum): @attrs.define class MaintenanceImpact: kind: MaintenanceImpactKind - affected_catalogs: list[str] + affected_catalogs: list[CatalogName] @classmethod def from_json(cls, impact: JSON): @@ -58,8 +67,9 @@ def to_json(self) -> JSON: affected_catalogs=self.affected_catalogs) def validate(self): - require(all(isinstance(c, str) and c for c in self.affected_catalogs), - 'Invalid catalog name/pattern') + require(all( + isinstance(c, CatalogName) and c for c in self.affected_catalogs), + 'Invalid catalog name/pattern') require(all({0: True, 1: c[-1] == '*'}.get(c.count('*'), False) for c in self.affected_catalogs), 'Invalid catalog pattern') @@ -75,7 +85,7 @@ class MaintenanceEvent: actual_end: datetime | None @classmethod - def from_json(cls, event: JSON): + def from_json(cls, event: JSON) -> Self: return cls(planned_start=cls._parse_datetime(event['planned_start']), planned_duration=timedelta(seconds=event['planned_duration']), description=event['description'], @@ -154,9 +164,9 @@ def validate(self): def pending_events(self) -> list[MaintenanceEvent]: """ Returns a list of pending events in this schedule. The elements in the - returned list can be modified until another method is invoked on this schedule. The - modifications will be reflected in ``self.events`` but the caller is - responsible for ensuring they don't invalidate this schedule. + returned list can be modified until another method is invoked on this + schedule. The modifications will be reflected in ``self.events`` but the + caller is responsible for ensuring they don't invalidate this schedule. """ events = enumerate(self.events) for i, e in events: @@ -223,3 +233,55 @@ def _heal(self, next_event.planned_start = event.end event = next_event self.validate() + + +class MaintenanceService: + + @property + def bucket(self): + return aws.shared_bucket + + @property + def object_key(self): + return f'azul/{config.deployment_stage}/azul.json' + + @cached_property + def storage_service(self): + return StorageService(self.bucket) + + def list_upcoming_events(self, all_events: bool = False) -> JSON: + """ + Display's the schedule, of active and pending events + """ + schedule = MaintenanceSchedule.from_json(self._get_schedule) + schedule.validate() + + if not all_events: + active = schedule.active_event() + schedule = MaintenanceSchedule([active]) + schedule.events += schedule.pending_events() + # Business logic and validation + # Start of an event + # if: actual_start is set [This is the start] + # else: planned_start + # Ending of event + # if: actual_end is set [This is the end] + return schedule.to_json() + + @property + def _get_schedule(self) -> JSON: + try: + schedule = json.loads(self.storage_service.get(self.object_key)) + except self.storage_service.client.exceptions.NoSuchKey: + schedule = self._create_empty_schedule + return schedule['maintenance']['schedule'] + + @property + def _create_empty_schedule(self): + schedule = { + 'maintenance': { + 'schedule': {} + } + } + self.storage_service.put(self.object_key, json.dumps(schedule).encode()) + return schedule