Skip to content

Commit 4e8db54

Browse files
authored
ci: merge main to release (#10303)
2 parents b152dac + ed209d2 commit 4e8db54

24 files changed

+798
-96
lines changed

docker/configs/nginx-proxy.conf

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ server {
44

55
proxy_read_timeout 1d;
66
proxy_send_timeout 1d;
7+
client_max_body_size 0; # disable checking
78

89
root /var/www/html;
910
index index.html index.htm index.nginx-debian.html;

ietf/api/serializers_rpc.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55

66
from django.db import transaction
77
from django.urls import reverse as urlreverse
8+
from django.utils import timezone
89
from drf_spectacular.types import OpenApiTypes
910
from drf_spectacular.utils import extend_schema_field
1011
from rest_framework import serializers
@@ -571,6 +572,12 @@ class RfcFileSerializer(serializers.Serializer):
571572
"file types, but filenames are otherwise ignored."
572573
),
573574
)
575+
mtime = serializers.DateTimeField(
576+
required=False,
577+
default=timezone.now,
578+
default_timezone=datetime.UTC,
579+
help_text="Modification timestamp to apply to uploaded files",
580+
)
574581
replace = serializers.BooleanField(
575582
required=False,
576583
default=False,

ietf/api/tests_views_rpc.py

Lines changed: 56 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,11 @@
66
from django.conf import settings
77
from django.core.files.base import ContentFile
88
from django.db.models import Max
9+
from django.db.models.functions import Coalesce
910
from django.test.utils import override_settings
1011
from django.urls import reverse as urlreverse
1112

13+
from ietf.blobdb.models import Blob
1214
from ietf.doc.factories import IndividualDraftFactory, WgDraftFactory, WgRfcFactory
1315
from ietf.doc.models import RelatedDocument, Document
1416
from ietf.group.factories import RoleFactory, GroupFactory
@@ -22,7 +24,9 @@ def test_draftviewset_references(self):
2224
viewname = "ietf.api.purple_api.draft-references"
2325

2426
# non-existent draft
25-
bad_id = Document.objects.aggregate(unused_id=Max("id") + 100)["unused_id"]
27+
bad_id = Document.objects.aggregate(unused_id=Coalesce(Max("id"), 0) + 100)[
28+
"unused_id"
29+
]
2630
url = urlreverse(viewname, kwargs={"doc_id": bad_id})
2731
# Without credentials
2832
r = self.client.get(url)
@@ -256,6 +260,31 @@ def _valid_post_data():
256260
)
257261
self.assertEqual(r.status_code, 400)
258262

263+
# Put a file in the way. Post should fail because replace = False
264+
file_in_the_way = (rfc_path / f"rfc{unused_rfc_number}.txt")
265+
file_in_the_way.touch()
266+
r = self.client.post(
267+
url,
268+
_valid_post_data(),
269+
format="multipart",
270+
headers={"X-Api-Key": "valid-token"},
271+
)
272+
self.assertEqual(r.status_code, 409) # conflict
273+
file_in_the_way.unlink()
274+
275+
# Put a blob in the way. Post should fail because replace = False
276+
blob_in_the_way = Blob.objects.create(
277+
bucket="rfc", name=f"txt/rfc{unused_rfc_number}.txt", content=b""
278+
)
279+
r = self.client.post(
280+
url,
281+
_valid_post_data(),
282+
format="multipart",
283+
headers={"X-Api-Key": "valid-token"},
284+
)
285+
self.assertEqual(r.status_code, 409) # conflict
286+
blob_in_the_way.delete()
287+
259288
# valid post
260289
r = self.client.post(
261290
url,
@@ -264,21 +293,41 @@ def _valid_post_data():
264293
headers={"X-Api-Key": "valid-token"},
265294
)
266295
self.assertEqual(r.status_code, 200)
267-
for suffix in [".xml", ".txt", ".html", ".pdf", ".json"]:
296+
for extension in ["xml", "txt", "html", "pdf", "json"]:
297+
filename = f"rfc{unused_rfc_number}.{extension}"
268298
self.assertEqual(
269-
(rfc_path / f"rfc{unused_rfc_number}")
270-
.with_suffix(suffix)
299+
(rfc_path / filename)
271300
.read_text(),
272-
f"This is {suffix}",
273-
f"{suffix} file should contain the expected content",
301+
f"This is .{extension}",
302+
f"{extension} file should contain the expected content",
303+
)
304+
self.assertEqual(
305+
bytes(
306+
Blob.objects.get(
307+
bucket="rfc", name=f"{extension}/{filename}"
308+
).content
309+
),
310+
f"This is .{extension}".encode("utf-8"),
311+
f"{extension} blob should contain the expected content",
274312
)
313+
# special case for notprepped
314+
notprepped_fn = f"rfc{unused_rfc_number}.notprepped.xml"
275315
self.assertEqual(
276316
(
277-
rfc_path / "prerelease" / f"rfc{unused_rfc_number}.notprepped.xml"
317+
rfc_path / "prerelease" / notprepped_fn
278318
).read_text(),
279319
"This is .notprepped.xml",
280320
".notprepped.xml file should contain the expected content",
281321
)
322+
self.assertEqual(
323+
bytes(
324+
Blob.objects.get(
325+
bucket="rfc", name=f"notprepped/{notprepped_fn}"
326+
).content
327+
),
328+
b"This is .notprepped.xml",
329+
".notprepped.xml blob should contain the expected content",
330+
)
282331

283332
# re-post with replace = False should now fail
284333
r = self.client.post(

ietf/api/views_rpc.py

Lines changed: 58 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
# Copyright The IETF Trust 2023-2026, All Rights Reserved
2+
import os
23
import shutil
34
from pathlib import Path
45
from tempfile import TemporaryDirectory
@@ -35,6 +36,7 @@
3536
)
3637
from ietf.doc.models import Document, DocHistory, RfcAuthor
3738
from ietf.doc.serializers import RfcAuthorSerializer
39+
from ietf.doc.storage_utils import remove_from_storage, store_file, exists_in_storage
3840
from ietf.person.models import Email, Person
3941

4042

@@ -366,8 +368,8 @@ class RfcPubFilesView(APIView):
366368
api_key_endpoint = "ietf.api.views_rpc"
367369
parser_classes = [parsers.MultiPartParser]
368370

369-
def _destination(self, filename: str | Path) -> Path:
370-
"""Destination for an uploaded RFC file
371+
def _fs_destination(self, filename: str | Path) -> Path:
372+
"""Destination for an uploaded RFC file in the filesystem
371373
372374
Strips any path components in filename and returns an absolute Path.
373375
"""
@@ -378,6 +380,23 @@ def _destination(self, filename: str | Path) -> Path:
378380
return rfc_path / "prerelease" / filename.name
379381
return rfc_path / filename.name
380382

383+
def _blob_destination(self, filename: str | Path) -> str:
384+
"""Destination name for an uploaded RFC file in the blob store
385+
386+
Strips any path components in filename and returns an absolute Path.
387+
"""
388+
filename = Path(filename) # could potentially have directory components
389+
extension = "".join(filename.suffixes)
390+
if extension == ".notprepped.xml":
391+
file_type = "notprepped"
392+
elif extension[0] == ".":
393+
file_type = extension[1:]
394+
else:
395+
raise serializers.ValidationError(
396+
f"Extension does not begin with '.'!? ({filename})",
397+
)
398+
return f"{file_type}/{filename.name}"
399+
381400
@extend_schema(
382401
operation_id="upload_rfc_files",
383402
summary="Upload files for a published RFC",
@@ -394,10 +413,17 @@ def post(self, request):
394413
uploaded_files = serializer.validated_data["contents"] # list[UploadedFile]
395414
replace = serializer.validated_data["replace"]
396415
dest_stem = f"rfc{rfc.rfc_number}"
416+
mtime = serializer.validated_data["mtime"]
417+
mtimestamp = mtime.timestamp()
418+
blob_kind = "rfc"
397419

398420
# List of files that might exist for an RFC
399421
possible_rfc_files = [
400-
self._destination(dest_stem + ext)
422+
self._fs_destination(dest_stem + ext)
423+
for ext in serializer.allowed_extensions
424+
]
425+
possible_rfc_blobs = [
426+
self._blob_destination(dest_stem + ext)
401427
for ext in serializer.allowed_extensions
402428
]
403429
if not replace:
@@ -408,6 +434,14 @@ def post(self, request):
408434
"File(s) already exist for this RFC",
409435
code="files-exist",
410436
)
437+
for possible_existing_blob in possible_rfc_blobs:
438+
if exists_in_storage(
439+
kind=blob_kind, name=possible_existing_blob
440+
):
441+
raise Conflict(
442+
"Blob(s) already exist for this RFC",
443+
code="blobs-exist",
444+
)
411445

412446
with TemporaryDirectory() as tempdir:
413447
# Save files in a temporary directory. Use the uploaded filename
@@ -421,14 +455,33 @@ def post(self, request):
421455
with tempfile_path.open("wb") as dest:
422456
for chunk in upfile.chunks():
423457
dest.write(chunk)
458+
os.utime(tempfile_path, (mtimestamp, mtimestamp))
424459
files_to_move.append(tempfile_path)
425460
# copy files to final location, removing any existing ones first if the
426461
# remove flag was set
427462
if replace:
428463
for possible_existing_file in possible_rfc_files:
429464
possible_existing_file.unlink(missing_ok=True)
465+
for possible_existing_blob in possible_rfc_blobs:
466+
remove_from_storage(
467+
blob_kind, possible_existing_blob, warn_if_missing=False
468+
)
430469
for ftm in files_to_move:
431-
shutil.move(ftm, self._destination(ftm))
432-
# todo store in blob storage as well (need a bucket for RFCs)
470+
with ftm.open("rb") as f:
471+
store_file(
472+
kind=blob_kind,
473+
name=self._blob_destination(ftm),
474+
file=f,
475+
doc_name=rfc.name,
476+
doc_rev=rfc.rev, # expect None, but match whatever it is
477+
mtime=mtime,
478+
)
479+
destination = self._fs_destination(ftm)
480+
if (
481+
settings.SERVER_MODE != "production"
482+
and not destination.parent.exists()
483+
):
484+
destination.parent.mkdir()
485+
shutil.move(ftm, destination)
433486

434487
return Response(NotificationAckSerializer().data)

ietf/blobdb/models.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,9 @@ class Meta:
6464
),
6565
]
6666

67+
def __str__(self):
68+
return f"{self.bucket}:{self.name}"
69+
6770
def save(self, **kwargs):
6871
db = get_blobdb()
6972
with transaction.atomic(using=db):

ietf/doc/tasks.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
from .utils import (
3030
generate_idnits2_rfc_status,
3131
generate_idnits2_rfcs_obsoleted,
32+
rebuild_reference_relations,
3233
update_or_create_draft_bibxml_file,
3334
ensure_draft_bibxml_path_exists,
3435
investigate_fragment,
@@ -128,3 +129,23 @@ def investigate_fragment_task(name_fragment: str):
128129
"name_fragment": name_fragment,
129130
"results": investigate_fragment(name_fragment),
130131
}
132+
133+
@shared_task
134+
def rebuild_reference_relations_task(doc_names: list[str]):
135+
log.log(f"Task: Rebuilding reference relations for {doc_names}")
136+
for doc in Document.objects.filter(name__in=doc_names, type__in=["rfc", "draft"]):
137+
filenames = dict()
138+
base = (
139+
settings.RFC_PATH
140+
if doc.type_id == "rfc"
141+
else settings.INTERNET_ALL_DRAFTS_ARCHIVE_DIR
142+
)
143+
stem = doc.name if doc.type_id == "rfc" else f"{doc.name}-{doc.rev}"
144+
for ext in ["xml", "txt"]:
145+
path = Path(base) / f"{stem}.{ext}"
146+
if path.is_file():
147+
filenames[ext] = str(path)
148+
if len(filenames) > 0:
149+
rebuild_reference_relations(doc, filenames)
150+
else:
151+
log.log(f"Found no content for {stem}")

ietf/doc/tests_utils.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -389,13 +389,13 @@ def test_requires_txt_or_xml(self):
389389
result = rebuild_reference_relations(self.doc, {})
390390
self.assertCountEqual(result.keys(), ['errors'])
391391
self.assertEqual(len(result['errors']), 1)
392-
self.assertIn('No Internet-Draft text available', result['errors'][0],
392+
self.assertIn('No file available', result['errors'][0],
393393
'Error should be reported if no Internet-Draft file is given')
394394

395395
result = rebuild_reference_relations(self.doc, {'md': 'cant-do-this.md'})
396396
self.assertCountEqual(result.keys(), ['errors'])
397397
self.assertEqual(len(result['errors']), 1)
398-
self.assertIn('No Internet-Draft text available', result['errors'][0],
398+
self.assertIn('No file available', result['errors'][0],
399399
'Error should be reported if no XML or plaintext file is given')
400400

401401
@patch.object(XMLDraft, 'get_refs')

ietf/doc/utils.py

Lines changed: 32 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -941,50 +941,66 @@ def rebuild_reference_relations(doc, filenames):
941941
942942
filenames should be a dict mapping file ext (i.e., type) to the full path of each file.
943943
"""
944-
if doc.type.slug != 'draft':
944+
if doc.type.slug not in ["draft", "rfc"]:
945945
return None
946+
947+
log.log(f"Rebuilding reference relations for {doc.name}")
946948

947949
# try XML first
948-
if 'xml' in filenames:
949-
refs = XMLDraft(filenames['xml']).get_refs()
950-
elif 'txt' in filenames:
951-
filename = filenames['txt']
950+
if "xml" in filenames:
951+
refs = XMLDraft(filenames["xml"]).get_refs()
952+
elif "txt" in filenames:
953+
filename = filenames["txt"]
952954
try:
953955
refs = draft.PlaintextDraft.from_file(filename).get_refs()
954956
except IOError as e:
955-
return { 'errors': ["%s :%s" % (e.strerror, filename)] }
957+
return {"errors": [f"{e.strerror}: {filename}"]}
956958
else:
957-
return {'errors': ['No Internet-Draft text available for rebuilding reference relations. Need XML or plaintext.']}
959+
return {
960+
"errors": [
961+
"No file available for rebuilding reference relations. Need XML or plaintext."
962+
]
963+
}
958964

959-
doc.relateddocument_set.filter(relationship__slug__in=['refnorm','refinfo','refold','refunk']).delete()
965+
doc.relateddocument_set.filter(
966+
relationship__slug__in=["refnorm", "refinfo", "refold", "refunk"]
967+
).delete()
960968

961969
warnings = []
962970
errors = []
963971
unfound = set()
964-
for ( ref, refType ) in refs.items():
972+
for ref, refType in refs.items():
965973
refdoc = Document.objects.filter(name=ref)
966974
if not refdoc and re.match(r"^draft-.*-\d{2}$", ref):
967975
refdoc = Document.objects.filter(name=ref[:-3])
968976
count = refdoc.count()
969977
if count == 0:
970-
unfound.add( "%s" % ref )
978+
unfound.add("%s" % ref)
971979
continue
972980
elif count > 1:
973-
errors.append("Too many Document objects found for %s"%ref)
981+
errors.append("Too many Document objects found for %s" % ref)
974982
else:
975983
# Don't add references to ourself
976984
if doc != refdoc[0]:
977-
RelatedDocument.objects.get_or_create( source=doc, target=refdoc[ 0 ], relationship=DocRelationshipName.objects.get( slug='ref%s' % refType ) )
985+
RelatedDocument.objects.get_or_create(
986+
source=doc,
987+
target=refdoc[0],
988+
relationship=DocRelationshipName.objects.get(
989+
slug="ref%s" % refType
990+
),
991+
)
978992
if unfound:
979-
warnings.append('There were %d references with no matching Document'%len(unfound))
993+
warnings.append(
994+
"There were %d references with no matching Document" % len(unfound)
995+
)
980996

981997
ret = {}
982998
if errors:
983-
ret['errors']=errors
999+
ret["errors"] = errors
9841000
if warnings:
985-
ret['warnings']=warnings
1001+
ret["warnings"] = warnings
9861002
if unfound:
987-
ret['unfound']=list(unfound)
1003+
ret["unfound"] = list(unfound)
9881004

9891005
return ret
9901006

0 commit comments

Comments
 (0)