Skip to content

Commit 90d4a17

Browse files
authored
ci: merge main to release (#8306)
2 parents 7ecf23e + b255883 commit 90d4a17

28 files changed

+763
-76
lines changed

dev/build/Dockerfile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
FROM ghcr.io/ietf-tools/datatracker-app-base:20241114T1954
1+
FROM ghcr.io/ietf-tools/datatracker-app-base:20241127T2054
22
LABEL maintainer="IETF Tools Team <[email protected]>"
33

44
ENV DEBIAN_FRONTEND=noninteractive

dev/build/TARGET_BASE

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
20241114T1954
1+
20241127T2054

dev/build/gunicorn.conf.py

Lines changed: 59 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -12,26 +12,25 @@
1212
"level": "INFO",
1313
"handlers": ["console"],
1414
"propagate": False,
15-
"qualname": "gunicorn.error"
15+
"qualname": "gunicorn.error",
1616
},
17-
1817
"gunicorn.access": {
1918
"level": "INFO",
2019
"handlers": ["access_console"],
2120
"propagate": False,
22-
"qualname": "gunicorn.access"
23-
}
21+
"qualname": "gunicorn.access",
22+
},
2423
},
2524
"handlers": {
2625
"console": {
2726
"class": "logging.StreamHandler",
2827
"formatter": "json",
29-
"stream": "ext://sys.stdout"
28+
"stream": "ext://sys.stdout",
3029
},
3130
"access_console": {
3231
"class": "logging.StreamHandler",
3332
"formatter": "access_json",
34-
"stream": "ext://sys.stdout"
33+
"stream": "ext://sys.stdout",
3534
},
3635
},
3736
"formatters": {
@@ -44,14 +43,29 @@
4443
"class": "ietf.utils.jsonlogger.GunicornRequestJsonFormatter",
4544
"style": "{",
4645
"format": "{asctime}{levelname}{message}{name}{process}",
47-
}
48-
}
46+
},
47+
},
4948
}
5049

51-
def pre_request(worker, req):
50+
# Track in-flight requests and emit a list of what was happeningwhen a worker is terminated.
51+
# For the default sync worker, there will only be one request per PID, but allow for the
52+
# possibility of multiple requests in case we switch to a different worker class.
53+
#
54+
# This dict is only visible within a single worker, but key by pid to guarantee no conflicts.
55+
#
56+
# Use a list rather than a set to allow for the possibility of overlapping identical requests.
57+
in_flight_by_pid: dict[str, list[str]] = {} # pid -> list of in-flight requests
58+
59+
60+
def _describe_request(req):
61+
"""Generate a consistent description of a request
62+
63+
The return value is used identify in-flight requests, so it must not vary between the
64+
start and end of handling a request. E.g., do not include a timestamp.
65+
"""
5266
client_ip = "-"
5367
cf_ray = "-"
54-
for (header, value) in req.headers:
68+
for header, value in req.headers:
5569
header = header.lower()
5670
if header == "cf-connecting-ip":
5771
client_ip = value
@@ -61,4 +75,38 @@ def pre_request(worker, req):
6175
path = f"{req.path}?{req.query}"
6276
else:
6377
path = req.path
64-
worker.log.info(f"gunicorn starting to process {req.method} {path} (client_ip={client_ip}, cf_ray={cf_ray})")
78+
return f"{req.method} {path} (client_ip={client_ip}, cf_ray={cf_ray})"
79+
80+
81+
def pre_request(worker, req):
82+
"""Log the start of a request and add it to the in-flight list"""
83+
request_description = _describe_request(req)
84+
worker.log.info(f"gunicorn starting to process {request_description}")
85+
in_flight = in_flight_by_pid.setdefault(worker.pid, [])
86+
in_flight.append(request_description)
87+
88+
89+
def worker_abort(worker):
90+
"""Emit an error log if any requests were in-flight"""
91+
in_flight = in_flight_by_pid.get(worker.pid, [])
92+
if len(in_flight) > 0:
93+
worker.log.error(
94+
f"Aborted worker {worker.pid} with in-flight requests: {', '.join(in_flight)}"
95+
)
96+
97+
98+
def worker_int(worker):
99+
"""Emit an error log if any requests were in-flight"""
100+
in_flight = in_flight_by_pid.get(worker.pid, [])
101+
if len(in_flight) > 0:
102+
worker.log.error(
103+
f"Interrupted worker {worker.pid} with in-flight requests: {', '.join(in_flight)}"
104+
)
105+
106+
107+
def post_request(worker, req, environ, resp):
108+
"""Remove request from in-flight list when we finish handling it"""
109+
request_description = _describe_request(req)
110+
in_flight = in_flight_by_pid.get(worker.pid, [])
111+
if request_description in in_flight:
112+
in_flight.remove(request_description)

ietf/api/apps.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,4 +12,8 @@ def ready(self):
1212
interact with the database. See
1313
https://docs.djangoproject.com/en/4.2/ref/applications/#django.apps.AppConfig.ready
1414
"""
15+
# Populate our API list now that the app registry is set up
1516
populate_api_list()
17+
18+
# Import drf-spectacular extensions
19+
import ietf.api.schema # pyflakes: ignore

ietf/api/authentication.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
# Copyright The IETF Trust 2024, All Rights Reserved
2+
#
3+
from rest_framework import authentication
4+
from django.contrib.auth.models import AnonymousUser
5+
6+
7+
class ApiKeyAuthentication(authentication.BaseAuthentication):
8+
"""API-Key header authentication"""
9+
10+
def authenticate(self, request):
11+
"""Extract the authentication token, if present
12+
13+
This does not validate the token, it just arranges for it to be available in request.auth.
14+
It's up to a Permissions class to validate it for the appropriate endpoint.
15+
"""
16+
token = request.META.get("HTTP_X_API_KEY", None)
17+
if token is None:
18+
return None
19+
return AnonymousUser(), token # available as request.user and request.auth

ietf/api/permissions.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
# Copyright The IETF Trust 2024, All Rights Reserved
2+
#
3+
from rest_framework import permissions
4+
from ietf.api.ietf_utils import is_valid_token
5+
6+
7+
class HasApiKey(permissions.BasePermission):
8+
"""Permissions class that validates a token using is_valid_token
9+
10+
The view class must indicate the relevant endpoint by setting `api_key_endpoint`.
11+
Must be used with an Authentication class that puts a token in request.auth.
12+
"""
13+
def has_permission(self, request, view):
14+
endpoint = getattr(view, "api_key_endpoint", None)
15+
auth_token = getattr(request, "auth", None)
16+
if endpoint is not None and auth_token is not None:
17+
return is_valid_token(endpoint, auth_token)
18+
return False
19+
20+
21+
class IsOwnPerson(permissions.BasePermission):
22+
"""Permission to access own Person object"""
23+
def has_object_permission(self, request, view, obj):
24+
if not (request.user.is_authenticated and hasattr(request.user, "person")):
25+
return False
26+
return obj == request.user.person
27+
28+
29+
class BelongsToOwnPerson(permissions.BasePermission):
30+
"""Permission to access objects associated with own Person
31+
32+
Requires that the object have a "person" field that indicates ownership.
33+
"""
34+
def has_object_permission(self, request, view, obj):
35+
if not (request.user.is_authenticated and hasattr(request.user, "person")):
36+
return False
37+
return (
38+
hasattr(obj, "person") and obj.person == request.user.person
39+
)

ietf/api/routers.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
# Copyright The IETF Trust 2024, All Rights Reserved
2+
"""Custom django-rest-framework routers"""
3+
from django.core.exceptions import ImproperlyConfigured
4+
from rest_framework import routers
5+
6+
class PrefixedSimpleRouter(routers.SimpleRouter):
7+
"""SimpleRouter that adds a dot-separated prefix to its basename"""
8+
def __init__(self, name_prefix="", *args, **kwargs):
9+
self.name_prefix = name_prefix
10+
if len(self.name_prefix) == 0 or self.name_prefix[-1] == ".":
11+
raise ImproperlyConfigured("Cannot use a name_prefix that is empty or ends with '.'")
12+
super().__init__(*args, **kwargs)
13+
14+
def get_default_basename(self, viewset):
15+
basename = super().get_default_basename(viewset)
16+
return f"{self.name_prefix}.{basename}"

ietf/api/schema.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
# Copyright The IETF Trust 2024, All Rights Reserved
2+
#
3+
from drf_spectacular.extensions import OpenApiAuthenticationExtension
4+
5+
6+
class ApiKeyAuthenticationScheme(OpenApiAuthenticationExtension):
7+
"""Authentication scheme extension for the ApiKeyAuthentication
8+
9+
Used by drf-spectacular when rendering the OpenAPI schema
10+
"""
11+
target_class = "ietf.api.authentication.ApiKeyAuthentication"
12+
name = "apiKeyAuth"
13+
14+
def get_security_definition(self, auto_schema):
15+
return {
16+
"type": "apiKey",
17+
"description": "Shared secret in the X-Api-Key header",
18+
"name": "X-Api-Key",
19+
"in": "header",
20+
}

ietf/api/serializer.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
1-
# Copyright The IETF Trust 2018-2020, All Rights Reserved
1+
# Copyright The IETF Trust 2018-2024, All Rights Reserved
22
# -*- coding: utf-8 -*-
3+
"""Serialization utilities
34
5+
This is _not_ for django-rest-framework!
6+
"""
47

58
import hashlib
69
import json

ietf/api/tests.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -936,6 +936,8 @@ def test_api_version(self):
936936
r = self.client.get(url)
937937
data = r.json()
938938
self.assertEqual(data['version'], ietf.__version__+ietf.__patch__)
939+
for lib in settings.ADVERTISE_VERSIONS:
940+
self.assertIn(lib, data['other'])
939941
self.assertEqual(data['dumptime'], "2022-08-31 07:10:01 +0000")
940942
DumpInfo.objects.update(tz='PST8PDT')
941943
r = self.client.get(url)

0 commit comments

Comments
 (0)