Skip to content

Commit fdf6b60

Browse files
humitosagjohnsonericholscher
authored
Deprecation: notification and feature flag for build.image config (#10589)
* Deprecation: notification and feature flag for `build.image` config Define a weekly task to communicate our users about the deprecation of `build.image` using the deprecation plan we used for the configuration file v2 as well. - 3 brownout days - final removal date on October 2nd - weekly onsite/email notification on Wednesday at 11:15 CEST (around 3.5k projects affected) - allow to opt-out from these emails - feature flag for brownout days - build detail's page notification Related: * readthedocs/meta#48 * #10354 * #10587 * Deprecation: notification and feature flag for `build.image` config Define a weekly task to communicate our users about the deprecation of `build.image` using the deprecation plan we used for the configuration file v2 as well. - 3 brownout days - final removal date on October 2nd - weekly onsite/email notification on Wednesday at 11:15 CEST (around ~22k projects affected) - allow to opt-out from these emails - feature flag for brownout days - build detail's page notification Related: * readthedocs/meta#48 * #10354 * #10587 * Review and update logic * Start emailing people with projects building from 3 years ago * Apply suggestions from code review Co-authored-by: Anthony <[email protected]> Co-authored-by: Eric Holscher <[email protected]> * Add HTML version of the email * Codify brownout dates and remove the feature flag Follows the suggestion from https://github.com/readthedocs/blog/pull/233/files#r1283479184 * Use UTC datetimes to compare * Contact projects with a build in the last 3 years We will start with 3 years timeframe first and then lower it down to 1 year. --------- Co-authored-by: Anthony <[email protected]> Co-authored-by: Eric Holscher <[email protected]>
1 parent 6bb5a73 commit fdf6b60

13 files changed

+335
-10
lines changed

readthedocs/builds/models.py

+15
Original file line numberDiff line numberDiff line change
@@ -1104,6 +1104,21 @@ def deprecated_config_used(self):
11041104

11051105
return int(self.config.get("version", "1")) != LATEST_CONFIGURATION_VERSION
11061106

1107+
def deprecated_build_image_used(self):
1108+
"""
1109+
Check whether this particular build is using the deprecated "build.image" config.
1110+
1111+
Note we are using this to communicate deprecation of "build.image".
1112+
See https://github.com/readthedocs/meta/discussions/48
1113+
"""
1114+
if not self.config:
1115+
# Don't notify users without a config file.
1116+
# We hope they will migrate to `build.os` in the process of adding a `.readthedocs.yaml`
1117+
return False
1118+
1119+
build_config_key = self.config.get("build", {})
1120+
return "image" in build_config_key
1121+
11071122
def reset(self):
11081123
"""
11091124
Reset the build so it can be re-used when re-trying.

readthedocs/core/forms.py

+4-1
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,10 @@ class Meta:
2323
model = UserProfile
2424
# Don't allow users edit someone else's user page
2525
profile_fields = ["first_name", "last_name", "homepage"]
26-
optout_email_fields = ["optout_email_config_file_deprecation"]
26+
optout_email_fields = [
27+
"optout_email_config_file_deprecation",
28+
"optout_email_build_image_deprecation",
29+
]
2730
fields = (
2831
*profile_fields,
2932
*optout_email_fields,
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
# Generated by Django 3.2.20 on 2023-08-01 13:21
2+
3+
from django.db import migrations, models
4+
5+
6+
class Migration(migrations.Migration):
7+
dependencies = [
8+
("core", "0013_add_optout_email_config_file_deprecation"),
9+
]
10+
11+
operations = [
12+
migrations.AddField(
13+
model_name="historicaluserprofile",
14+
name="optout_email_build_image_deprecation",
15+
field=models.BooleanField(
16+
default=False,
17+
null=True,
18+
verbose_name="Opt-out from email about '\"build.image\" config key deprecation'",
19+
),
20+
),
21+
migrations.AddField(
22+
model_name="userprofile",
23+
name="optout_email_build_image_deprecation",
24+
field=models.BooleanField(
25+
default=False,
26+
null=True,
27+
verbose_name="Opt-out from email about '\"build.image\" config key deprecation'",
28+
),
29+
),
30+
]

readthedocs/core/models.py

+7
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,13 @@ class UserProfile(TimeStampedModel):
5151
default=False,
5252
null=True,
5353
)
54+
# NOTE: this is a temporary field that we can remove after October 16, 2023
55+
# See https://blog.readthedocs.com/use-build-os-config/
56+
optout_email_build_image_deprecation = models.BooleanField(
57+
_("Opt-out from email about '\"build.image\" config key deprecation'"),
58+
default=False,
59+
null=True,
60+
)
5461

5562
# Model history
5663
history = ExtraHistoricalRecords()

readthedocs/doc_builder/director.py

+24
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
import structlog
1414
import yaml
1515
from django.conf import settings
16+
from django.utils import timezone
1617
from django.utils.translation import gettext_lazy as _
1718

1819
from readthedocs.builds.constants import EXTERNAL
@@ -255,6 +256,29 @@ def checkout(self):
255256
) and self.data.config.version not in ("2", 2):
256257
raise BuildUserError(BuildUserError.NO_CONFIG_FILE_DEPRECATED)
257258

259+
# Raise a build error if the project is using "build.image" on their config file
260+
261+
now = timezone.now()
262+
263+
# fmt: off
264+
# These browndates matches https://blog.readthedocs.com/use-build-os-config/
265+
browndates = any([
266+
timezone.datetime(2023, 8, 28, 0, 0, 0, tzinfo=timezone.utc) < now < timezone.datetime(2023, 8, 28, 12, 0, 0, tzinfo=timezone.utc), # First, 12hs
267+
timezone.datetime(2023, 9, 18, 0, 0, 0, tzinfo=timezone.utc) < now < timezone.datetime(2023, 9, 19, 0, 0, 0, tzinfo=timezone.utc), # Second, 24hs
268+
timezone.datetime(2023, 10, 2, 0, 0, 0, tzinfo=timezone.utc) < now < timezone.datetime(2023, 10, 4, 0, 0, 0, tzinfo=timezone.utc), # Third, 48hs
269+
timezone.datetime(2023, 10, 16, 0, 0, 0, tzinfo=timezone.utc) < now, # Fully removal
270+
])
271+
# fmt: on
272+
273+
if browndates:
274+
build_config_key = self.data.config.source_config.get("build", {})
275+
if "image" in build_config_key:
276+
raise BuildUserError(BuildUserError.BUILD_IMAGE_CONFIG_KEY_DEPRECATED)
277+
278+
# TODO: move this validation to the Config object once we are settled here
279+
if "image" not in build_config_key and "os" not in build_config_key:
280+
raise BuildUserError(BuildUserError.BUILD_OS_REQUIRED)
281+
258282
if self.vcs_repository.supports_submodules:
259283
self.vcs_repository.update_submodules(self.data.config)
260284

readthedocs/doc_builder/exceptions.py

+9
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,15 @@ class BuildUserError(BuildBaseException):
6666
"Add a configuration file to your project to make it build successfully. "
6767
"Read more at https://docs.readthedocs.io/en/stable/config-file/v2.html"
6868
)
69+
BUILD_IMAGE_CONFIG_KEY_DEPRECATED = gettext_noop(
70+
'The configuration key "build.image" is deprecated. '
71+
'Use "build.os" instead to continue building your project. '
72+
"Read more at https://docs.readthedocs.io/en/stable/config-file/v2.html#build-os"
73+
)
74+
BUILD_OS_REQUIRED = gettext_noop(
75+
'The configuration key "build.os" is required to build your documentation. '
76+
"Read more at https://docs.readthedocs.io/en/stable/config-file/v2.html#build-os"
77+
)
6978

7079

7180
class BuildUserSkip(BuildUserError):

readthedocs/projects/tasks/utils.py

+154
Original file line numberDiff line numberDiff line change
@@ -323,6 +323,160 @@ def deprecated_config_file_used_notification():
323323
)
324324

325325

326+
class DeprecatedBuildImageSiteNotification(SiteNotification):
327+
failure_message = _(
328+
'Your project(s) "{{ project_slugs }}" are using the deprecated "build.image" '
329+
'config on their ".readthedocs.yaml" file. '
330+
'This config is deprecated in favor of "build.os" and <strong>will be removed on October 16, 2023</strong>. ' # noqa
331+
'<a href="https://blog.readthedocs.com/build-image-config-deprecated/">Read our blog post to migrate to "build.os"</a> ' # noqa
332+
"and ensure your project continues building successfully."
333+
)
334+
failure_level = WARNING_PERSISTENT
335+
336+
337+
class DeprecatedBuildImageEmailNotification(Notification):
338+
app_templates = "projects"
339+
name = "deprecated_build_image_used"
340+
subject = '[Action required] Update your ".readthedocs.yaml" file to use "build.os"'
341+
level = REQUIREMENT
342+
343+
def send(self):
344+
"""Method overwritten to remove on-site backend."""
345+
backend = EmailBackend(self.request)
346+
backend.send(self)
347+
348+
349+
@app.task(queue="web")
350+
def deprecated_build_image_notification():
351+
"""
352+
Send an email notification about using "build.image" to all maintainers of the project.
353+
354+
This is a scheduled task to be executed on the webs.
355+
Note the code uses `.iterator` and `.only` to avoid killing the db with this query.
356+
Besdies, it excludes projects with enough spam score to be skipped.
357+
"""
358+
# Skip projects with a spam score bigger than this value.
359+
# Currently, this gives us ~250k in total (from ~550k we have in our database)
360+
spam_score = 300
361+
362+
projects = set()
363+
start_datetime = datetime.datetime.now()
364+
queryset = Project.objects.exclude(users__profile__banned=True)
365+
if settings.ALLOW_PRIVATE_REPOS:
366+
# Only send emails to active customers
367+
queryset = queryset.filter(
368+
organizations__stripe_subscription__status=SubscriptionStatus.active
369+
)
370+
else:
371+
# Take into account spam score on community
372+
queryset = queryset.annotate(spam_score=Sum("spam_rules__value")).filter(
373+
Q(spam_score__lt=spam_score) | Q(is_spam=False)
374+
)
375+
queryset = queryset.only("slug", "default_version").order_by("id")
376+
n_projects = queryset.count()
377+
378+
for i, project in enumerate(queryset.iterator()):
379+
if i % 500 == 0:
380+
log.info(
381+
'Finding projects using "build.image" config key.',
382+
progress=f"{i}/{n_projects}",
383+
current_project_pk=project.pk,
384+
current_project_slug=project.slug,
385+
projects_found=len(projects),
386+
time_elapsed=(datetime.datetime.now() - start_datetime).seconds,
387+
)
388+
389+
# Only check for the default version because if the project is using tags
390+
# they won't be able to update those and we will send them emails forever.
391+
# We can update this query if we consider later.
392+
version = (
393+
project.versions.filter(slug=project.default_version).only("id").first()
394+
)
395+
if version:
396+
# Use a fixed date here to avoid changing the date on each run
397+
years_ago = timezone.datetime(2020, 8, 1)
398+
build = (
399+
version.builds.filter(success=True, date__gt=years_ago)
400+
.only("_config")
401+
.order_by("-date")
402+
.first()
403+
)
404+
if build and build.deprecated_build_image_used():
405+
projects.add(project.slug)
406+
407+
# Store all the users we want to contact
408+
users = set()
409+
410+
n_projects = len(projects)
411+
queryset = Project.objects.filter(slug__in=projects).order_by("id")
412+
for i, project in enumerate(queryset.iterator()):
413+
if i % 500 == 0:
414+
log.info(
415+
'Querying all the users we want to contact about "build.image" deprecation.',
416+
progress=f"{i}/{n_projects}",
417+
current_project_pk=project.pk,
418+
current_project_slug=project.slug,
419+
users_found=len(users),
420+
time_elapsed=(datetime.datetime.now() - start_datetime).seconds,
421+
)
422+
423+
users.update(AdminPermission.owners(project).values_list("username", flat=True))
424+
425+
# Only send 1 email per user,
426+
# even if that user has multiple projects using "build.image".
427+
# The notification will mention all the projects.
428+
queryset = User.objects.filter(
429+
username__in=users,
430+
profile__banned=False,
431+
profile__optout_email_build_image_deprecation=False,
432+
).order_by("id")
433+
434+
n_users = queryset.count()
435+
for i, user in enumerate(queryset.iterator()):
436+
if i % 500 == 0:
437+
log.info(
438+
'Sending deprecated "build.image" config key notification to users.',
439+
progress=f"{i}/{n_users}",
440+
current_user_pk=user.pk,
441+
current_user_username=user.username,
442+
time_elapsed=(datetime.datetime.now() - start_datetime).seconds,
443+
)
444+
445+
# All the projects for this user that are using "build.image".
446+
# Use set() intersection in Python that's pretty quick since we only need the slugs.
447+
# Otherwise we have to pass 82k slugs to the DB query, which makes it pretty slow.
448+
user_projects = AdminPermission.projects(user, admin=True).values_list(
449+
"slug", flat=True
450+
)
451+
user_projects_slugs = list(set(user_projects) & projects)
452+
user_projects = Project.objects.filter(slug__in=user_projects_slugs)
453+
454+
# Create slug string for onsite notification
455+
user_project_slugs = ", ".join(user_projects_slugs[:5])
456+
if len(user_projects) > 5:
457+
user_project_slugs += " and others..."
458+
459+
n_site = DeprecatedBuildImageSiteNotification(
460+
user=user,
461+
context_object=user,
462+
extra_context={"project_slugs": user_project_slugs},
463+
success=False,
464+
)
465+
n_site.send()
466+
467+
n_email = DeprecatedBuildImageEmailNotification(
468+
user=user,
469+
context_object=user,
470+
extra_context={"projects": user_projects},
471+
)
472+
n_email.send()
473+
474+
log.info(
475+
'Finish sending deprecated "build.image" config key notifications.',
476+
time_elapsed=(datetime.datetime.now() - start_datetime).seconds,
477+
)
478+
479+
326480
@app.task(queue="web")
327481
def set_builder_scale_in_protection(instance, protected_from_scale_in):
328482
"""

readthedocs/settings/base.py

+5
Original file line numberDiff line numberDiff line change
@@ -539,6 +539,11 @@ def TEMPLATES(self):
539539
'schedule': crontab(day_of_week='wednesday', hour=11, minute=15),
540540
'options': {'queue': 'web'},
541541
},
542+
'weekly-build-image-notification': {
543+
'task': 'readthedocs.projects.tasks.utils.deprecated_build_image_notification',
544+
'schedule': crontab(day_of_week='wednesday', hour=9, minute=15),
545+
'options': {'queue': 'web'},
546+
},
542547
}
543548

544549
# Sentry

readthedocs/templates/builds/build_detail.html

+19-7
Original file line numberDiff line numberDiff line change
@@ -163,17 +163,29 @@
163163
{% endif %}
164164

165165
{# This message is not dynamic and only appears when loading the page after the build has finished #}
166-
{% if build.finished and build.deprecated_config_used %}
167-
<div class="build-ideas">
166+
{% if build.finished and build.deprecated_build_image_used %}
167+
<div class="build-ideas">
168168
<p>
169-
{% blocktrans trimmed with config_file_link="https://blog.readthedocs.com/migrate-configuration-v2/" %}
169+
{% blocktrans trimmed with config_file_link="https://blog.readthedocs.com/build-image-config-deprecated/" %}
170170
<strong>Your builds will stop working soon!</strong><br/>
171-
Configuration files will <strong>soon be required</strong> by projects, and will no longer be optional.
172-
<a href="{{ config_file_link }}">Read our blog post to create one</a>
171+
"build.image" config key is deprecated and it will be removed soon.
172+
<a href="{{ config_file_link }}">Read our blog post to know how to migrate to new key "build.os"</a>
173173
and ensure your project continues building successfully.
174-
{% endblocktrans %}
174+
{% endblocktrans %}
175175
</p>
176-
</div>
176+
</div>
177+
{% endif %}
178+
{% if build.finished and build.deprecated_config_used %}
179+
<div class="build-ideas">
180+
<p>
181+
{% blocktrans trimmed with config_file_link="https://blog.readthedocs.com/migrate-configuration-v2/" %}
182+
<strong>Your builds will stop working soon!</strong><br/>
183+
Configuration files will <strong>soon be required</strong> by projects, and will no longer be optional.
184+
<a href="{{ config_file_link }}">Read our blog post to create one</a>
185+
and ensure your project continues building successfully.
186+
{% endblocktrans %}
187+
</p>
188+
</div>
177189
{% endif %}
178190

179191
{% endif %}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
{% extends "core/email/common.html" %}
2+
{% block content %}
3+
The <code>build.image</code> config key on <code>.readthedocs.yaml</code> has been deprecated, and will be removed on <strong>October 16, 2023</strong>.
4+
We are sending weekly notifications about this issue to all impacted users,
5+
as well as temporary build failures (brownouts) as the date approaches for those who haven't migrated their projects.
6+
7+
The timeline for this deprecation is as follows:
8+
9+
<ul>
10+
<li><strong>Monday, August 28, 2023</strong>: Do the first brownout (temporarily enforce this deprecation) for 12 hours: 00:01 PST to 11:59 PST (noon)</li>
11+
<li><strong> Monday, September 18, 2023</strong>: Do a second brownout (temporarily enforce this deprecation) for 24 hours: 00:01 PST to 23:59 PST (midnight)</li>
12+
<li><strong> Monday, October 2, 2023</strong>: Do a third and final brownout (temporarily enforce this deprecation) for 48 hours: 00:01 PST to October 3, 2023 23:59 PST (midnight)</li>
13+
<li><strong> Monday, October 16, 2023</strong>: Fully remove support for building documentation using "build.image" on the configuration file</li>
14+
</ul>
15+
16+
We have identified that the following projects which you maintain, and were built in the last year, are impacted by this deprecation:
17+
18+
<ul>
19+
{% for project in projects|slice:":15" %}
20+
<li><a href="{{ production_uri }}{{ project.get_absolute_url }}">{{ project.slug }}</a></li>
21+
{% endfor %}
22+
{% if projects.count > 15 %}
23+
<li>... and {{ projects.count|add:"-15" }} more projects.</li>
24+
{% endif %}
25+
</ul>
26+
27+
Please use <code>build.os</code> on your configuration file to ensure that they continue building successfully and to stop receiving these notifications.
28+
If you want to opt-out from these emails, you can <a href="https://readthedocs.org/accounts/edit/"> edit your preferences in your account settings</a>.
29+
30+
For more information on how to use <code>build.os</code>,
31+
<a href="https://blog.readthedocs.com/use-build-os-config/">read our blog post</a>
32+
33+
Get in touch with us <a href="{{ production_uri }}{% url 'support' %}">via our support</a>
34+
and let us know if you are unable to use a configuration file for any reason.
35+
{% endblock %}

0 commit comments

Comments
 (0)