Skip to content

Commit a5e6bfd

Browse files
committed
Add network configuration support for synchronous file uploads
Introduce RemoteNetworkConfigSerializer to centralize shared network settings (timeouts, proxies, and certificates) and ensure consistent validation across persistent Remotes and ad-hoc upload downloads. - Exclude sync-specific fields (policy, rate_limit) from shared config - Pass validated network kwargs to the downloader in UploadSerializerFieldsMixin - Reuse common proxy and certificate validation logic closes: #7201
1 parent 0459023 commit a5e6bfd

5 files changed

Lines changed: 252 additions & 184 deletions

File tree

CHANGES/7201.feature

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Add network configuration support for synchronous file uploads

pulp_file/app/serializers.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,8 @@ class FileContentUploadSerializer(FileContentSerializer):
6565
"""
6666

6767
def validate(self, data):
68+
data = super().validate(data)
69+
6870
"""Validate the FileContent data."""
6971
if upload := data.pop("upload", None):
7072
# Handle chunked upload

pulpcore/app/serializers/base.py

Lines changed: 202 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
from typing import List, TypedDict
88
from urllib.parse import urljoin
99

10+
from cryptography.x509 import load_pem_x509_certificate
1011
from django.conf import settings
1112
from django.core.validators import URLValidator
1213
from django.core.exceptions import ObjectDoesNotExist
@@ -582,3 +583,204 @@ def validate(self, data):
582583
data = super().validate(data)
583584
data["value"] = self.context["content_object"].pulp_labels[data["key"]]
584585
return data
586+
587+
588+
class RemoteNetworkConfigSerializer(serializers.Serializer):
589+
"""
590+
Shared network configuration fields and validation logic used by both
591+
RemoteSerializer and UploadSerializerFieldsMixin.
592+
"""
593+
594+
ca_cert = serializers.CharField(
595+
help_text="A PEM encoded CA certificate used to validate the server "
596+
"certificate presented by the remote server.",
597+
required=False,
598+
allow_null=True,
599+
)
600+
client_cert = serializers.CharField(
601+
help_text="A PEM encoded client certificate used for authentication.",
602+
required=False,
603+
allow_null=True,
604+
)
605+
client_key = serializers.CharField(
606+
help_text="A PEM encoded private key used for authentication.",
607+
required=False,
608+
allow_null=True,
609+
write_only=True,
610+
)
611+
tls_validation = serializers.BooleanField(
612+
help_text="If True, TLS peer validation must be performed.", required=False
613+
)
614+
proxy_url = serializers.CharField(
615+
help_text="The proxy URL. Format: scheme://host:port",
616+
required=False,
617+
allow_null=True,
618+
)
619+
proxy_username = serializers.CharField(
620+
help_text="The username to authenticte to the proxy.",
621+
required=False,
622+
allow_null=True,
623+
write_only=True,
624+
)
625+
proxy_password = serializers.CharField(
626+
help_text=_(
627+
"The password to authenticate to the proxy. Extra leading and trailing whitespace "
628+
"characters are not trimmed."
629+
),
630+
required=False,
631+
allow_null=True,
632+
write_only=True,
633+
trim_whitespace=False,
634+
style={"input_type": "password"},
635+
)
636+
username = serializers.CharField(
637+
help_text="The username to be used for authentication when syncing.",
638+
required=False,
639+
allow_null=True,
640+
write_only=True,
641+
)
642+
password = serializers.CharField(
643+
help_text=_(
644+
"The password to be used for authentication when syncing. Extra leading and trailing "
645+
"whitespace characters are not trimmed."
646+
),
647+
required=False,
648+
allow_null=True,
649+
write_only=True,
650+
trim_whitespace=False,
651+
style={"input_type": "password"},
652+
)
653+
max_retries = serializers.IntegerField(
654+
help_text=(
655+
"Maximum number of retry attempts after a download failure. If not set then the "
656+
"default value (3) will be used."
657+
),
658+
required=False,
659+
allow_null=True,
660+
)
661+
total_timeout = serializers.FloatField(
662+
allow_null=True,
663+
required=False,
664+
help_text=(
665+
"aiohttp.ClientTimeout.total (q.v.) for download-connections. The default is null, "
666+
"which will cause the default from the aiohttp library to be used."
667+
),
668+
min_value=0.0,
669+
)
670+
connect_timeout = serializers.FloatField(
671+
allow_null=True,
672+
required=False,
673+
help_text=(
674+
"aiohttp.ClientTimeout.connect (q.v.) for download-connections. The default is null, "
675+
"which will cause the default from the aiohttp library to be used."
676+
),
677+
min_value=0.0,
678+
)
679+
sock_connect_timeout = serializers.FloatField(
680+
allow_null=True,
681+
required=False,
682+
help_text=(
683+
"aiohttp.ClientTimeout.sock_connect (q.v.) for download-connections. The default is "
684+
"null, which will cause the default from the aiohttp library to be used."
685+
),
686+
min_value=0.0,
687+
)
688+
sock_read_timeout = serializers.FloatField(
689+
allow_null=True,
690+
required=False,
691+
help_text=(
692+
"aiohttp.ClientTimeout.sock_read (q.v.) for download-connections. The default is "
693+
"null, which will cause the default from the aiohttp library to be used."
694+
),
695+
min_value=0.0,
696+
)
697+
headers = serializers.ListField(
698+
child=serializers.DictField(),
699+
help_text=_("Headers for aiohttp.Clientsession"),
700+
required=False,
701+
)
702+
703+
def validate_proxy_url(self, value):
704+
"""
705+
Check, that the proxy_url does not contain credentials.
706+
"""
707+
if value and "@" in value:
708+
raise serializers.ValidationError(_("proxy_url must not contain credentials"))
709+
return value
710+
711+
def validate_ca_cert(self, value):
712+
return self._validate_certificate("ca_cert", value)
713+
714+
def validate_client_cert(self, value):
715+
return self._validate_certificate("client_cert", value)
716+
717+
@staticmethod
718+
def _validate_certificate(which_cert, value):
719+
"""
720+
Validate and return *just* the certs and not any commentary that came along with them.
721+
722+
Args:
723+
which_cert: The attribute-name whose cert we're validating
724+
(only used for error-message).
725+
value: The string being proposed as a certificate-containing PEM.
726+
727+
Raises:
728+
ValidationError: When the provided value has no or an invalid certificate.
729+
730+
Returns:
731+
The pem-string with *just* the validated BEGIN/END CERTIFICATE segments.
732+
"""
733+
if value:
734+
try:
735+
# Find any/all CERTIFICATE entries in the proposed PEM and let crypto validate them.
736+
# NOTE: crypto/39 includes load_certificates(), which will let us remove this whole
737+
# loop. But we want to fix the current problem on older supported branches that
738+
# allow 38, so we do it ourselves for now
739+
certs = list()
740+
a_cert = ""
741+
for line in value.split("\n"):
742+
if "-----BEGIN CERTIFICATE-----" in line or a_cert:
743+
a_cert += line + "\n"
744+
if "-----END CERTIFICATE-----" in line:
745+
load_pem_x509_certificate(bytes(a_cert, "ASCII"))
746+
certs.append(a_cert.strip())
747+
a_cert = ""
748+
if not certs:
749+
raise serializers.ValidationError(
750+
"No {} specified in string {}".format(which_cert, value)
751+
)
752+
return "\n".join(certs) + "\n"
753+
except ValueError as e:
754+
raise serializers.ValidationError(
755+
"Invalid {} specified, error '{}'".format(which_cert, e.args)
756+
)
757+
758+
def validate(self, data):
759+
"""
760+
Check that proxy credentials are only provided completely and if a proxy is configured.
761+
Adapted to work for both ModelSerializers (Remotes) and standard Serializers (Uploads).
762+
"""
763+
# Handle cases where we don't have an instance (e.g. Uploads)
764+
instance = getattr(self, "instance", None)
765+
partial = getattr(self, "partial", False)
766+
767+
proxy_url = instance.proxy_url if instance and partial else None
768+
proxy_url = data.get("proxy_url", proxy_url)
769+
770+
proxy_username = instance.proxy_username if instance and partial else None
771+
proxy_username = data.get("proxy_username", proxy_username)
772+
773+
proxy_password = instance.proxy_password if instance and partial else None
774+
proxy_password = data.get("proxy_password", proxy_password)
775+
776+
if (proxy_username or proxy_password) and not proxy_url:
777+
raise serializers.ValidationError(
778+
_("proxy credentials cannot be specified without a proxy")
779+
)
780+
781+
if bool(proxy_username) is not bool(proxy_password):
782+
raise serializers.ValidationError(
783+
_("proxy username and password can only be specified together")
784+
)
785+
786+
return data

0 commit comments

Comments
 (0)