Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Announce maintenance in stable deployments (#5979) #6095

Draft
wants to merge 3 commits into
base: develop
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 42 additions & 1 deletion lambdas/service/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,9 @@
from azul.logging import (
configure_app_logging,
)
from azul.maintenance import (
MaintenanceService,
)
from azul.openapi import (
application_json,
format_description as fd,
Expand Down Expand Up @@ -232,7 +235,7 @@
# changes and reset the minor version to zero. Otherwise, increment only
# the minor version for backwards compatible changes. A backwards
# compatible change is one that does not require updates to clients.
'version': '9.1'
'version': '9.2'
},
'tags': [
{
Expand Down Expand Up @@ -870,6 +873,44 @@ def get_integrations():
body=json.dumps(body))


@app.route(
'/maintenance/schedule',
methods=['GET'],
cors=True,
method_spec={
'summary': 'A maintenance schedule as an JSON object',
'tags': ['Auxiliary'],
'responses': {
'200': {
'description': fd('''
This object may be hanceforth refered to as "the schedule"
or `schedule`.
The `start` time of an event is its `actual_start` if set,
or its `planned_start` otherwise. The `end` time of an event
is its `actual_end` if set, or its `start` plus
`planned_duration` otherwise. All events in the schedule are
sorted by their `start` time. No two events have the same
`start` time. Each event defines an interval
`[e.start, e.end)` and there is no overlap between these
intervals.

A pending event is one where `actual_start` is absent. An
active event is one where `actual_start` is present but
`actual_end` is absent. There can be at most one active
event.
''')
}
}
}
)
def get_maintenance_schedule():
service = MaintenanceService()
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MaintenanceService has a cached_property which is made pointless if we construct a new instance of the service for every request.

Normally the service would be cached on the controller, which would be cached on the app. We don't have a controller here, but I still think it would e a good idea to find a way to reuse this instance.

schedule = service.get_schedule.to_json()
return Response(status_code=200,
headers={'content-type': 'application/json'},
body=json.dumps(schedule))


@app.route(
'/index/catalogs',
methods=['GET'],
Expand Down
15 changes: 14 additions & 1 deletion lambdas/service/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
"info": {
"title": "azul_service",
"description": "\n# Overview\n\nAzul is a REST web service for querying metadata associated with\nboth experimental and analysis data from a data repository. In order\nto deliver response times that make it suitable for interactive use\ncases, the set of metadata properties that it exposes for sorting,\nfiltering, and aggregation is limited. Azul provides a uniform view\nof the metadata over a range of diverse schemas, effectively\nshielding clients from changes in the schemas as they occur over\ntime. It does so, however, at the expense of detail in the set of\nmetadata properties it exposes and in the accuracy with which it\naggregates them.\n\nAzul denormalizes and aggregates metadata into several different\nindices for selected entity types. Metadata entities can be queried\nusing the [Index](#operations-tag-Index) endpoints.\n\nA set of indices forms a catalog. There is a default catalog called\n`dcp2` which will be used unless a\ndifferent catalog name is specified using the `catalog` query\nparameter. Metadata from different catalogs is completely\nindependent: a response obtained by querying one catalog does not\nnecessarily correlate to a response obtained by querying another\none. Two catalogs can contain metadata from the same sources or\ndifferent sources. It is only guaranteed that the body of a\nresponse by any given endpoint adheres to one schema,\nindependently of which catalog was specified in the request.\n\nAzul provides the ability to download data and metadata via the\n[Manifests](#operations-tag-Manifests) endpoints. The\n`curl` format manifests can be used to\ndownload data files. Other formats provide various views of the\nmetadata. Manifests can be generated for a selection of files using\nfilters. These filters are interchangeable with the filters used by\nthe [Index](#operations-tag-Index) endpoints.\n\nAzul also provides a [summary](#operations-Index-get_index_summary)\nview of indexed data.\n\n## Data model\n\nAny index, when queried, returns a JSON array of hits. Each hit\nrepresents a metadata entity. Nested in each hit is a summary of the\nproperties of entities associated with the hit. An entity is\nassociated either by a direct edge in the original metadata graph,\nor indirectly as a series of edges. The nested properties are\ngrouped by the type of the associated entity. The properties of all\ndata files associated with a particular sample, for example, are\nlisted under `hits[*].files` in a `/index/samples` response. It is\nimportant to note that while each _hit_ represents a discrete\nentity, the properties nested within that hit are the result of an\naggregation over potentially many associated entities.\n\nTo illustrate this, consider a data file that is part of two\nprojects (a project is a group of related experiments, typically by\none laboratory, institution or consortium). Querying the `files`\nindex for this file yields a hit looking something like:\n\n```\n{\n \"projects\": [\n {\n \"projectTitle\": \"Project One\"\n \"laboratory\": ...,\n ...\n },\n {\n \"projectTitle\": \"Project Two\"\n \"laboratory\": ...,\n ...\n }\n ],\n \"files\": [\n {\n \"format\": \"pdf\",\n \"name\": \"Team description.pdf\",\n ...\n }\n ]\n}\n```\n\nThis example hit contains two kinds of nested entities (a hit in an\nactual Azul response will contain more): There are the two projects\nentities, and the file itself. These nested entities contain\nselected metadata properties extracted in a consistent way. This\nmakes filtering and sorting simple.\n\nAlso notice that there is only one file. When querying a particular\nindex, the corresponding entity will always be a singleton like\nthis.\n",
"version": "9.1"
"version": "9.2"
},
"tags": [
{
Expand Down Expand Up @@ -653,6 +653,19 @@
}
}
},
"/maintenance/schedule": {
"get": {
"summary": "A maintenance schedule as an JSON object",
"tags": [
"Auxiliary"
],
"responses": {
"200": {
"description": "\nThis object may be hanceforth refered to as \"the schedule\"\nor `schedule`.\nThe `start` time of an event is its `actual_start` if set,\nor its `planned_start` otherwise. The `end` time of an event\nis its `actual_end` if set, or its `start` plus\n`planned_duration` otherwise. All events in the schedule are\nsorted by their `start` time. No two events have the same\n`start` time. Each event defines an interval\n`[e.start, e.end)` and there is no overlap between these\n intervals.\n\n A pending event is one where `actual_start` is absent. An\n active event is one where `actual_start` is present but\n `actual_end` is absent. There can be at most one active\n event.\n"
}
}
}
},
"/index/catalogs": {
"get": {
"summary": "List all available catalogs.",
Expand Down
142 changes: 142 additions & 0 deletions scripts/manage_maintenance.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
"""
This is a command line utility for managing announcement of maintenance events.
Reads the JSON from designated bucket, deserializes the model from it, validates
the model, applies an action to it, serializes the model back to JSON and
finally uploads it back to the bucket where the service exposes it. The service
must also validate the model before returning it.
"""
import argparse
from datetime import (
timedelta,
)
import json
import re
import sys

from azul import (
require,
)
from azul.args import (
AzulArgumentHelpFormatter,
)
from azul.maintenance import (
MaintenanceService,
)


def parse_duration(duration: str) -> timedelta:
"""
>>> parse_duration('1d')
datetime.timedelta(days=1)
>>> parse_duration('24 hours')
datetime.timedelta(days=1)
>>> parse_duration('.5 Days 12 hours')
datetime.timedelta(days=1)

>>> parse_duration('2h20Min')
datetime.timedelta(seconds=8400)
>>> parse_duration('1 H 80m')
datetime.timedelta(seconds=8400)
>>> parse_duration('140 Minutes')
datetime.timedelta(seconds=8400)

>>> parse_duration('2 Days 3hours 4min 5 secs')
datetime.timedelta(days=2, seconds=11045)
>>> parse_duration('1d 25h')
datetime.timedelta(days=2, seconds=3600)
>>> parse_duration('1m30s')
datetime.timedelta(seconds=90)

>>> parse_duration('Bad foo')
Traceback (most recent call last):
...
azul.RequirementError: Try a duration such as "2d 6hrs", "1.5 Days", or "15m"
"""

pattern = r'(\d*\.?\d+)\s*(d|h|m|s)'
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

d|h|m|s can be written more clearly and succinctly as [dhms]

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That said, capturing only the 1st character of the unit name troubles me. Consider what would happen if we encountered input such as "100 milliseconds", "1 month", or "1.5 squirrels".

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(these are all good ideas for additional doctests)

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"1.5 Days Bad foo" is another example of an input that is accepted and should not be.

matches = re.findall(pattern, duration.lower())
require(bool(matches), 'Try a duration such as "2d 6hrs", "1.5 Days", or "15m"')
time_delta = {'days': 0, 'hours': 0, 'minutes': 0, 'seconds': 0}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Might be a good place to use defaultdict?

for value, unit in matches:
value = float(value)
match unit:
case 'd':
time_delta['days'] += value
case 'h':
time_delta['hours'] += value
case 'm':
time_delta['minutes'] += value
case 's':
time_delta['seconds'] += value
return timedelta(**time_delta)


def main(args: list[str]):
parser = argparse.ArgumentParser(description=__doc__,
formatter_class=AzulArgumentHelpFormatter)
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')
subparsers.add_parser('start', help='Activate a pending event')
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(args)

service = MaintenanceService()

if args.command == 'list':
events = service.get_schedule
if args.all:
events = events.to_json()
else:
active = events.active_event()
active = {} if active is None else {'active': active.to_json()}
pending = events.pending_events()
pending = {'pending': [pe.to_json() for pe in pending]}
events = active | pending
elif args.command == 'add':
duration = int(parse_duration(args.duration).total_seconds())
events = service.provision_event(planned_start=args.start,
planned_duration=duration,
description=args.description,
partial=args.partial_responses,
degraded=args.degraded_performance,
unavailable=args.service_unavailable)
events = service.add_event(events)
elif args.command == 'cancel':
events = service.cancel_event(args.index)
elif args.command == 'start':
events = service.start_event()
elif args.command == 'end':
events = service.end_event()
elif args.command == 'adjust':
events = service.adjust_event(parse_duration(args.duration))
else:
raise argparse.ArgumentError(subparsers, 'Invalid command')
print(json.dumps(events, indent=4))


if __name__ == '__main__':
main(sys.argv[1:])
Loading
Loading