Skip to content

Commit a94aa1f

Browse files
authored
Env variables: increase size limit (#11748)
Closes #7901
1 parent 0e4a305 commit a94aa1f

File tree

7 files changed

+170
-4
lines changed

7 files changed

+170
-4
lines changed

docs/user/environment-variables.rst

+6
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,12 @@ Pull Request builds
5959
.. with a few more scenarios,
6060
.. once there is better options for environment variables in config files
6161
62+
Limitations
63+
-----------
64+
65+
- Individual environment variables are limited to 48 KB in size.
66+
- The total size of all environment variables in a project is limited to 256 KB.
67+
6268
Patterns of using environment variables
6369
---------------------------------------
6470

readthedocs/api/v3/serializers.py

+20-1
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
Project,
3232
ProjectRelationship,
3333
)
34+
from readthedocs.projects.validators import validate_environment_variable_size
3435
from readthedocs.redirects.constants import TYPE_CHOICES as REDIRECT_TYPE_CHOICES
3536
from readthedocs.redirects.models import Redirect
3637
from readthedocs.redirects.validators import validate_redirect
@@ -1115,7 +1116,6 @@ def get_project(self, obj):
11151116

11161117

11171118
class EnvironmentVariableSerializer(serializers.ModelSerializer):
1118-
value = serializers.CharField(write_only=True)
11191119
project = serializers.SlugRelatedField(slug_field="slug", read_only=True)
11201120
_links = EnvironmentVariableLinksSerializer(source="*", read_only=True)
11211121

@@ -1131,6 +1131,25 @@ class Meta:
11311131
"project",
11321132
"_links",
11331133
]
1134+
extra_kwargs = {
1135+
"value": {"write_only": True},
1136+
}
1137+
1138+
def create(self, validated_data):
1139+
validate_environment_variable_size(
1140+
project=validated_data["project"],
1141+
new_env_value=validated_data["value"],
1142+
error_class=serializers.ValidationError,
1143+
)
1144+
return super().create(validated_data)
1145+
1146+
def update(self, instance, validated_data):
1147+
validate_environment_variable_size(
1148+
project=instance.project,
1149+
new_env_value=validated_data["value"],
1150+
error_class=serializers.ValidationError,
1151+
)
1152+
return super().update(instance, validated_data)
11341153

11351154

11361155
class OrganizationLinksSerializer(BaseLinksSerializer):

readthedocs/api/v3/tests/test_environmentvariables.py

+56-2
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
1-
from .mixins import APIEndpointMixin
1+
import django_dynamic_fixture as fixture
22
from django.urls import reverse
3+
from django_dynamic_fixture import get
34

4-
import django_dynamic_fixture as fixture
55
from readthedocs.projects.models import EnvironmentVariable
6+
from readthedocs.projects.validators import MAX_SIZE_ENV_VARS_PER_PROJECT
7+
8+
from .mixins import APIEndpointMixin
69

710

811
class EnvironmentVariablessEndpointTests(APIEndpointMixin):
@@ -154,3 +157,54 @@ def test_projects_environmentvariables_detail_delete(self):
154157
response = self.client.delete(url)
155158
self.assertEqual(response.status_code, 204)
156159
self.assertEqual(self.project.environmentvariable_set.count(), 0)
160+
161+
def test_create_large_environment_variable(self):
162+
url = reverse(
163+
"projects-environmentvariables-list",
164+
kwargs={
165+
"parent_lookup_project__slug": self.project.slug,
166+
},
167+
)
168+
self.client.credentials(HTTP_AUTHORIZATION=f"Token {self.token.key}")
169+
resp = self.client.post(
170+
url,
171+
data={"name": "NEWENVVAR", "value": "a" * (48000 + 1), "public": True},
172+
)
173+
assert resp.status_code == 400
174+
assert resp.data == {
175+
"value": ["Ensure this field has no more than 48000 characters."]
176+
}
177+
178+
def test_environment_variables_total_size_per_project(self):
179+
size = 2000
180+
for i in range((MAX_SIZE_ENV_VARS_PER_PROJECT - size) // size):
181+
get(
182+
EnvironmentVariable,
183+
project=self.project,
184+
name=f"ENVVAR{i}",
185+
value="a" * size,
186+
public=False,
187+
)
188+
189+
url = reverse(
190+
"projects-environmentvariables-list",
191+
kwargs={
192+
"parent_lookup_project__slug": self.project.slug,
193+
},
194+
)
195+
self.client.credentials(HTTP_AUTHORIZATION=f"Token {self.token.key}")
196+
197+
resp = self.client.post(
198+
url,
199+
data={"name": "A", "value": "a" * (size // 2), "public": True},
200+
)
201+
assert resp.status_code == 201
202+
203+
resp = self.client.post(
204+
url,
205+
data={"name": "B", "value": "a" * size, "public": True},
206+
)
207+
assert resp.status_code == 400
208+
assert resp.json() == [
209+
"The total size of all environment variables in the project cannot exceed 256 KB."
210+
]
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
# Generated by Django 4.2.16 on 2024-11-06 16:24
2+
3+
from django.db import migrations, models
4+
from django_safemigrate import Safe
5+
6+
7+
class Migration(migrations.Migration):
8+
safe = Safe.before_deploy
9+
10+
dependencies = [
11+
("projects", "0130_addons_remove_old_fields"),
12+
]
13+
14+
operations = [
15+
migrations.AlterField(
16+
model_name="environmentvariable",
17+
name="value",
18+
field=models.CharField(
19+
help_text="Value of the environment variable", max_length=48000
20+
),
21+
),
22+
]

readthedocs/projects/models.py

+8-1
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@
5353
validate_custom_prefix,
5454
validate_custom_subproject_prefix,
5555
validate_domain_name,
56+
validate_environment_variable_size,
5657
validate_no_ip,
5758
validate_repository_url,
5859
)
@@ -2065,7 +2066,7 @@ class EnvironmentVariable(TimeStampedModel, models.Model):
20652066
help_text=_("Name of the environment variable"),
20662067
)
20672068
value = models.CharField(
2068-
max_length=2048,
2069+
max_length=48000,
20692070
help_text=_("Value of the environment variable"),
20702071
)
20712072
project = models.ForeignKey(
@@ -2088,3 +2089,9 @@ def __str__(self):
20882089
def save(self, *args, **kwargs):
20892090
self.value = quote(self.value)
20902091
return super().save(*args, **kwargs)
2092+
2093+
def clean(self):
2094+
validate_environment_variable_size(
2095+
project=self.project, new_env_value=self.value
2096+
)
2097+
return super().clean()

readthedocs/projects/validators.py

+21
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,16 @@
66
from django.conf import settings
77
from django.core.exceptions import ValidationError
88
from django.core.validators import RegexValidator
9+
from django.db.models import Sum
10+
from django.db.models.functions import Length
911
from django.utils.deconstruct import deconstructible
1012
from django.utils.html import format_html
1113
from django.utils.translation import gettext_lazy as _
1214

1315
from readthedocs.projects.constants import LANGUAGES
1416

17+
MAX_SIZE_ENV_VARS_PER_PROJECT = 256000
18+
1519

1620
@deconstructible
1721
class DomainNameValidator(RegexValidator):
@@ -227,3 +231,20 @@ def _clean_prefix(prefix):
227231
if not prefix:
228232
return "/"
229233
return f"/{prefix}/"
234+
235+
236+
def validate_environment_variable_size(
237+
project, new_env_value, error_class=ValidationError
238+
):
239+
existing_size = (
240+
project.environmentvariable_set.annotate(size=Length("value")).aggregate(
241+
total_size=Sum("size")
242+
)["total_size"]
243+
or 0
244+
)
245+
if existing_size + len(new_env_value) > MAX_SIZE_ENV_VARS_PER_PROJECT:
246+
raise error_class(
247+
_(
248+
"The total size of all environment variables in the project cannot exceed 256 KB."
249+
)
250+
)

readthedocs/rtd_tests/tests/test_project_forms.py

+37
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
WebHookForm,
3636
)
3737
from readthedocs.projects.models import EnvironmentVariable, Project, WebHookEvent
38+
from readthedocs.projects.validators import MAX_SIZE_ENV_VARS_PER_PROJECT
3839

3940

4041
class TestProjectForms(TestCase):
@@ -1123,6 +1124,42 @@ def test_create(self):
11231124
r"'string escaped here: #$\1[]{}\|'",
11241125
)
11251126

1127+
def test_create_env_variable_with_long_value(self):
1128+
data = {
1129+
"name": "MYTOKEN",
1130+
"value": "a" * (48000 + 1),
1131+
}
1132+
form = EnvironmentVariableForm(data, project=self.project)
1133+
assert not form.is_valid()
1134+
assert form.errors["value"] == [
1135+
"Ensure this value has at most 48000 characters (it has 48001)."
1136+
]
1137+
1138+
def test_create_env_variable_over_total_project_size(self):
1139+
size = 2000
1140+
for i in range((MAX_SIZE_ENV_VARS_PER_PROJECT - size) // size):
1141+
get(
1142+
EnvironmentVariable,
1143+
project=self.project,
1144+
name=f"ENVVAR{i}",
1145+
value="a" * size,
1146+
public=False,
1147+
)
1148+
1149+
form = EnvironmentVariableForm(
1150+
{"name": "A", "value": "a" * (size // 2)}, project=self.project
1151+
)
1152+
assert form.is_valid()
1153+
form.save()
1154+
1155+
form = EnvironmentVariableForm(
1156+
{"name": "B", "value": "a" * size}, project=self.project
1157+
)
1158+
assert not form.is_valid()
1159+
assert form.errors["__all__"] == [
1160+
"The total size of all environment variables in the project cannot exceed 256 KB."
1161+
]
1162+
11261163

11271164
class TestAddonsConfigForm(TestCase):
11281165
def setUp(self):

0 commit comments

Comments
 (0)