Skip to content

Commit e5cc09a

Browse files
sorrisonclaude
andcommitted
feat(reservations): allow admins to bypass maintenance window check
Adds a warre:reservation:create:bypass_maintenance policy (admin-only by default) so operators can create test reservations that overlap an active maintenance window. Capacity from real reservations is still enforced. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Change-Id: Icc9ed9a09ee520cd33ead178f14d87a1b4138326
1 parent a860afe commit e5cc09a

5 files changed

Lines changed: 134 additions & 25 deletions

File tree

warre/api/v1/resources/reservation.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -97,9 +97,15 @@ def post(self, **kwargs):
9797
except limit_exceptions.ProjectOverLimit as e:
9898
return {"error_message": str(e)}, 413
9999

100+
bypass_maintenance = self.authorize(
101+
"create:bypass_maintenance", do_raise=False
102+
)
103+
100104
try:
101105
reservation = self.manager.create_reservation(
102-
self.context, reservation
106+
self.context,
107+
reservation,
108+
bypass_maintenance=bypass_maintenance,
103109
)
104110
except exceptions.InvalidReservation as err:
105111
LOG.info("Failed to create reservation: %s", err)

warre/common/policies.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -230,6 +230,13 @@
230230
description="List all reservations.",
231231
operations=[{"path": "/v1/reservations/", "method": "GET"}],
232232
),
233+
policy.DocumentedRuleDefault(
234+
name=RESERVATION_PREFIX % "create:bypass_maintenance",
235+
check_str="rule:admin_required",
236+
description="Create a reservation that overlaps a maintenance "
237+
"window (intended for admin testing during maintenance).",
238+
operations=[{"path": "/v1/reservations/", "method": "POST"}],
239+
),
233240
policy.DocumentedRuleDefault(
234241
name=RESERVATION_PREFIX % "update",
235242
check_str=f"rule:{ADMIN_OR_OWNER}",

warre/manager.py

Lines changed: 41 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,9 @@ def __init__(self):
3131
self.worker_api = worker_api.WorkerAPI()
3232
self.blazar = blazar.BlazarClient()
3333

34-
def create_reservation(self, context, reservation):
34+
def create_reservation(
35+
self, context, reservation, bypass_maintenance=False
36+
):
3537
flavor = db.session.query(models.Flavor).get(reservation.flavor_id)
3638
if not flavor.active:
3739
raise exceptions.InvalidReservation("Flavor is not available")
@@ -66,28 +68,30 @@ def create_reservation(self, context, reservation):
6668
f"reservation end time of {reservation.end}"
6769
)
6870

69-
maintenance_windows = (
70-
db.session.query(models.MaintenanceWindow)
71-
.filter(models.MaintenanceWindow.end >= reservation.start)
72-
.filter(models.MaintenanceWindow.start <= reservation.end)
73-
.filter(
74-
models.MaintenanceWindow.flavors.any(
75-
models.Flavor.id == flavor.id
71+
if not bypass_maintenance:
72+
maintenance_windows = (
73+
db.session.query(models.MaintenanceWindow)
74+
.filter(models.MaintenanceWindow.end >= reservation.start)
75+
.filter(models.MaintenanceWindow.start <= reservation.end)
76+
.filter(
77+
models.MaintenanceWindow.flavors.any(
78+
models.Flavor.id == flavor.id
79+
)
7680
)
81+
.count()
7782
)
78-
.count()
79-
)
80-
if maintenance_windows > 0:
81-
raise exceptions.InvalidReservation(
82-
"Reservation conflicts with a maintenance window"
83-
)
83+
if maintenance_windows > 0:
84+
raise exceptions.InvalidReservation(
85+
"Reservation conflicts with a maintenance window"
86+
)
8487

8588
free_slots = self.flavor_free_slots(
8689
context,
8790
flavor,
8891
reservation.start,
8992
reservation.end,
9093
reservation,
94+
include_maintenance=not bypass_maintenance,
9195
)
9296

9397
if free_slots:
@@ -168,14 +172,24 @@ def delete_flavor(self, context, flavor):
168172
db.session.delete(flavor)
169173
db.session.commit()
170174

171-
def flavor_free_slots(self, context, flavor, start, end, reservation=None):
175+
def flavor_free_slots(
176+
self,
177+
context,
178+
flavor,
179+
start,
180+
end,
181+
reservation=None,
182+
include_maintenance=True,
183+
):
172184
"""Get the free slots of a flavor
173185
Algorithm:
174186
1. Get slots from flavor table as the total resource
175187
2. Get all reservations of current flavor
176188
3. Calculate the free slots
177189
178190
reservation - used to exclude an existing reservation when extending
191+
include_maintenance - when False, treat maintenance windows as not
192+
occupying any slots (admin bypass for testing during a window)
179193
"""
180194
if not flavor.active:
181195
return []
@@ -209,17 +223,20 @@ def flavor_free_slots(self, context, flavor, start, end, reservation=None):
209223
query = query.filter(models.Reservation.id != reservation.id)
210224
reservations = query.all()
211225

212-
maintenance_windows = (
213-
db.session.query(models.MaintenanceWindow)
214-
.filter(models.MaintenanceWindow.end >= start)
215-
.filter(models.MaintenanceWindow.start <= end)
216-
.filter(
217-
models.MaintenanceWindow.flavors.any(
218-
models.Flavor.id == flavor.id
226+
if include_maintenance:
227+
maintenance_windows = (
228+
db.session.query(models.MaintenanceWindow)
229+
.filter(models.MaintenanceWindow.end >= start)
230+
.filter(models.MaintenanceWindow.start <= end)
231+
.filter(
232+
models.MaintenanceWindow.flavors.any(
233+
models.Flavor.id == flavor.id
234+
)
219235
)
236+
.all()
220237
)
221-
.all()
222-
)
238+
else:
239+
maintenance_windows = []
223240

224241
# the real thing begins here
225242
# Pass 1: segmentation and marking

warre/tests/unit/api/v1/test_reservation.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -262,10 +262,43 @@ def test_update_reservation_no_capacity(self, mock_blazar):
262262
reservation_json.get("error_message"),
263263
)
264264

265+
def test_create_reservation_during_maintenance_window_denied(self):
266+
self.create_maintenance_window(
267+
start=datetime.datetime(2020, 1, 1),
268+
end=datetime.datetime(2020, 1, 10),
269+
flavors=[self.flavor],
270+
)
271+
data = {
272+
"flavor_id": self.flavor.id,
273+
"start": "2020-01-02T00:00:00+00:00",
274+
"end": "2020-01-02T01:00:00+00:00",
275+
}
276+
response = self.client.post("/v1/reservations/", json=data)
277+
self.assert400(response)
278+
self.assertIn(
279+
"maintenance window", response.get_json().get("error_message")
280+
)
281+
265282

283+
@mock.patch("warre.quota.get_enforcer", new=mock.Mock())
266284
class TestAdminReservationAPI(TestReservationAPI):
267285
ROLES = ["admin"]
268286

287+
def test_create_reservation_during_maintenance_window_denied(self):
288+
# Admins are allowed to bypass the maintenance window check.
289+
self.create_maintenance_window(
290+
start=datetime.datetime(2020, 1, 1),
291+
end=datetime.datetime(2020, 1, 10),
292+
flavors=[self.flavor],
293+
)
294+
data = {
295+
"flavor_id": self.flavor.id,
296+
"start": "2020-01-02T00:00:00+00:00",
297+
"end": "2020-01-02T01:00:00+00:00",
298+
}
299+
response = self.client.post("/v1/reservations/", json=data)
300+
self.assert200(response)
301+
269302
def test_list_reservations_all_projects(self):
270303
self.create_reservation(
271304
flavor_id=self.flavor.id,

warre/tests/unit/test_manager.py

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -893,3 +893,49 @@ def test_create_reservation_maintenance_window_other_flavor(
893893
mgr = manager.Manager()
894894
result = mgr.create_reservation(self.context, reservation)
895895
self.assertEqual(self.flavor, result.flavor)
896+
897+
def test_create_reservation_bypass_maintenance_window(self, mock_worker):
898+
self.create_maintenance_window(
899+
start=datetime(2021, 1, 1),
900+
end=datetime(2021, 1, 10),
901+
flavors=[self.flavor],
902+
)
903+
reservation = models.Reservation(
904+
flavor_id=self.flavor.id,
905+
start=datetime(2021, 1, 3),
906+
end=datetime(2021, 1, 5),
907+
)
908+
mgr = manager.Manager()
909+
result = mgr.create_reservation(
910+
self.context, reservation, bypass_maintenance=True
911+
)
912+
self.assertEqual(self.flavor, result.flavor)
913+
914+
def test_create_reservation_bypass_still_enforces_capacity(
915+
self, mock_worker
916+
):
917+
self.create_maintenance_window(
918+
start=datetime(2021, 1, 1),
919+
end=datetime(2021, 1, 10),
920+
flavors=[self.flavor],
921+
)
922+
# Existing reservation already consumes the only slot during the
923+
# maintenance window — bypass must not let us double-book.
924+
self.create_reservation(
925+
flavor_id=self.flavor.id,
926+
start=datetime(2021, 1, 3),
927+
end=datetime(2021, 1, 5),
928+
status=models.Reservation.ALLOCATED,
929+
)
930+
reservation = models.Reservation(
931+
flavor_id=self.flavor.id,
932+
start=datetime(2021, 1, 3),
933+
end=datetime(2021, 1, 5),
934+
)
935+
mgr = manager.Manager()
936+
with self.assertRaisesRegex(
937+
exceptions.InvalidReservation, "No capacity"
938+
):
939+
mgr.create_reservation(
940+
self.context, reservation, bypass_maintenance=True
941+
)

0 commit comments

Comments
 (0)