Skip to content

Deprecation: codify browndates for "no config file deprecation" #10612

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 12 commits into from
Aug 9, 2023
Merged
Show file tree
Hide file tree
Changes from 11 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions readthedocs/builds/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -1104,6 +1104,21 @@ def deprecated_config_used(self):

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

def deprecated_build_image_used(self):
"""
Check whether this particular build is using the deprecated "build.image" config.

Note we are using this to communicate deprecation of "build.image".
See https://github.com/readthedocs/meta/discussions/48
"""
if not self.config:
# Don't notify users without a config file.
# We hope they will migrate to `build.os` in the process of adding a `.readthedocs.yaml`
return False

build_config_key = self.config.get("build", {})
return "image" in build_config_key

def reset(self):
"""
Reset the build so it can be re-used when re-trying.
Expand Down
5 changes: 4 additions & 1 deletion readthedocs/core/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,10 @@ class Meta:
model = UserProfile
# Don't allow users edit someone else's user page
profile_fields = ["first_name", "last_name", "homepage"]
optout_email_fields = ["optout_email_config_file_deprecation"]
optout_email_fields = [
"optout_email_config_file_deprecation",
"optout_email_build_image_deprecation",
]
fields = (
*profile_fields,
*optout_email_fields,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
# Generated by Django 3.2.20 on 2023-08-01 13:21

from django.db import migrations, models


class Migration(migrations.Migration):
dependencies = [
("core", "0013_add_optout_email_config_file_deprecation"),
]

operations = [
migrations.AddField(
model_name="historicaluserprofile",
name="optout_email_build_image_deprecation",
field=models.BooleanField(
default=False,
null=True,
verbose_name="Opt-out from email about '\"build.image\" config key deprecation'",
),
),
migrations.AddField(
model_name="userprofile",
name="optout_email_build_image_deprecation",
field=models.BooleanField(
default=False,
null=True,
verbose_name="Opt-out from email about '\"build.image\" config key deprecation'",
),
),
]
7 changes: 7 additions & 0 deletions readthedocs/core/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,13 @@ class UserProfile(TimeStampedModel):
default=False,
null=True,
)
# NOTE: this is a temporary field that we can remove after October 16, 2023
# See https://blog.readthedocs.com/use-build-os-config/
optout_email_build_image_deprecation = models.BooleanField(
_("Opt-out from email about '\"build.image\" config key deprecation'"),
default=False,
null=True,
)

# Model history
history = ExtraHistoricalRecords()
Expand Down
39 changes: 35 additions & 4 deletions readthedocs/doc_builder/director.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
import structlog
import yaml
from django.conf import settings
from django.utils import timezone
from django.utils.translation import gettext_lazy as _

from readthedocs.builds.constants import EXTERNAL
Expand All @@ -23,7 +24,6 @@
from readthedocs.doc_builder.python_environments import Conda, Virtualenv
from readthedocs.projects.constants import BUILD_COMMANDS_OUTPUT_PATH_HTML
from readthedocs.projects.exceptions import RepositoryError
from readthedocs.projects.models import Feature
from readthedocs.projects.signals import after_build, before_build, before_vcs
from readthedocs.storage import build_tools_storage

Expand Down Expand Up @@ -249,12 +249,43 @@ def checkout(self):
self.data.build["config"] = self.data.config.as_dict()
self.data.build["readthedocs_yaml_path"] = custom_config_file

now = timezone.now()

# fmt: off
# These browndates matches https://blog.readthedocs.com/migrate-configuration-v2/
browndates = any([
timezone.datetime(2023, 7, 14, 0, 0, 0, tzinfo=timezone.utc) < now < timezone.datetime(2023, 7, 14, 12, 0, 0, tzinfo=timezone.utc), # First, 12hs
timezone.datetime(2023, 8, 14, 0, 0, 0, tzinfo=timezone.utc) < now < timezone.datetime(2023, 8, 15, 0, 0, 0, tzinfo=timezone.utc), # Second, 24hs
timezone.datetime(2023, 9, 4, 0, 0, 0, tzinfo=timezone.utc) < now < timezone.datetime(2023, 9, 6, 0, 0, 0, tzinfo=timezone.utc), # Third, 24hs
timezone.datetime(2023, 9, 25, 0, 0, 0, tzinfo=timezone.utc) < now, # Fully removal
])
# fmt: on

# Raise a build error if the project is not using a config file or using v1
if self.data.project.has_feature(
Feature.NO_CONFIG_FILE_DEPRECATED
) and self.data.config.version not in ("2", 2):
if browndates and self.data.config.version not in ("2", 2):
raise BuildUserError(BuildUserError.NO_CONFIG_FILE_DEPRECATED)

# Raise a build error if the project is using "build.image" on their config file

# fmt: off
# These browndates matches https://blog.readthedocs.com/use-build-os-config/
browndates = any([
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
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
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
timezone.datetime(2023, 10, 16, 0, 0, 0, tzinfo=timezone.utc) < now, # Fully removal
])
# fmt: on

if browndates:
build_config_key = self.data.config.source_config.get("build", {})
if "image" in build_config_key:
raise BuildUserError(BuildUserError.BUILD_IMAGE_CONFIG_KEY_DEPRECATED)

# TODO: move this validation to the Config object once we are settled here
if "image" not in build_config_key and "os" not in build_config_key:
raise BuildUserError(BuildUserError.BUILD_OS_REQUIRED)

if self.vcs_repository.supports_submodules:
self.vcs_repository.update_submodules(self.data.config)

Expand Down
9 changes: 9 additions & 0 deletions readthedocs/doc_builder/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,15 @@ class BuildUserError(BuildBaseException):
"Add a configuration file to your project to make it build successfully. "
"Read more at https://docs.readthedocs.io/en/stable/config-file/v2.html"
)
BUILD_IMAGE_CONFIG_KEY_DEPRECATED = gettext_noop(
'The configuration key "build.image" is deprecated. '
'Use "build.os" instead to continue building your project. '
"Read more at https://docs.readthedocs.io/en/stable/config-file/v2.html#build-os"
)
BUILD_OS_REQUIRED = gettext_noop(
'The configuration key "build.os" is required to build your documentation. '
"Read more at https://docs.readthedocs.io/en/stable/config-file/v2.html#build-os"
)


class BuildUserSkip(BuildUserError):
Expand Down
5 changes: 0 additions & 5 deletions readthedocs/projects/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -1949,7 +1949,6 @@ def add_features(sender, **kwargs):
# Build related features
GIT_CLONE_FETCH_CHECKOUT_PATTERN = "git_clone_fetch_checkout_pattern"
HOSTING_INTEGRATIONS = "hosting_integrations"
NO_CONFIG_FILE_DEPRECATED = "no_config_file"
SCALE_IN_PROTECTION = "scale_in_prtection"

FEATURES = (
Expand Down Expand Up @@ -2089,10 +2088,6 @@ def add_features(sender, **kwargs):
"Proxito: Inject 'readthedocs-addons.js' as <script> HTML tag in responses."
),
),
(
NO_CONFIG_FILE_DEPRECATED,
_("Build: Building without a configuration file is deprecated."),
),
(
SCALE_IN_PROTECTION,
_("Build: Set scale-in protection before/after building."),
Expand Down
154 changes: 154 additions & 0 deletions readthedocs/projects/tasks/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -323,6 +323,160 @@ def deprecated_config_file_used_notification():
)


class DeprecatedBuildImageSiteNotification(SiteNotification):
failure_message = _(
'Your project(s) "{{ project_slugs }}" are using the deprecated "build.image" '
'config on their ".readthedocs.yaml" file. '
'This config is deprecated in favor of "build.os" and <strong>will be removed on October 16, 2023</strong>. ' # noqa
'<a href="https://blog.readthedocs.com/build-image-config-deprecated/">Read our blog post to migrate to "build.os"</a> ' # noqa
"and ensure your project continues building successfully."
)
failure_level = WARNING_PERSISTENT


class DeprecatedBuildImageEmailNotification(Notification):
app_templates = "projects"
name = "deprecated_build_image_used"
subject = '[Action required] Update your ".readthedocs.yaml" file to use "build.os"'
level = REQUIREMENT

def send(self):
"""Method overwritten to remove on-site backend."""
backend = EmailBackend(self.request)
backend.send(self)


@app.task(queue="web")
def deprecated_build_image_notification():
"""
Send an email notification about using "build.image" to all maintainers of the project.

This is a scheduled task to be executed on the webs.
Note the code uses `.iterator` and `.only` to avoid killing the db with this query.
Besdies, it excludes projects with enough spam score to be skipped.
"""
# Skip projects with a spam score bigger than this value.
# Currently, this gives us ~250k in total (from ~550k we have in our database)
spam_score = 300

projects = set()
start_datetime = datetime.datetime.now()
queryset = Project.objects.exclude(users__profile__banned=True)
if settings.ALLOW_PRIVATE_REPOS:
# Only send emails to active customers
queryset = queryset.filter(
organizations__stripe_subscription__status=SubscriptionStatus.active
)
else:
# Take into account spam score on community
queryset = queryset.annotate(spam_score=Sum("spam_rules__value")).filter(
Q(spam_score__lt=spam_score) | Q(is_spam=False)
)
queryset = queryset.only("slug", "default_version").order_by("id")
n_projects = queryset.count()

for i, project in enumerate(queryset.iterator()):
if i % 500 == 0:
log.info(
'Finding projects using "build.image" config key.',
progress=f"{i}/{n_projects}",
current_project_pk=project.pk,
current_project_slug=project.slug,
projects_found=len(projects),
time_elapsed=(datetime.datetime.now() - start_datetime).seconds,
)

# Only check for the default version because if the project is using tags
# they won't be able to update those and we will send them emails forever.
# We can update this query if we consider later.
version = (
project.versions.filter(slug=project.default_version).only("id").first()
)
if version:
# Use a fixed date here to avoid changing the date on each run
years_ago = timezone.datetime(2022, 8, 1)
build = (
version.builds.filter(success=True, date__gt=years_ago)
.only("_config")
.order_by("-date")
.first()
)
if build and build.deprecated_build_image_used():
projects.add(project.slug)

# Store all the users we want to contact
users = set()

n_projects = len(projects)
queryset = Project.objects.filter(slug__in=projects).order_by("id")
for i, project in enumerate(queryset.iterator()):
if i % 500 == 0:
log.info(
'Querying all the users we want to contact about "build.image" deprecation.',
progress=f"{i}/{n_projects}",
current_project_pk=project.pk,
current_project_slug=project.slug,
users_found=len(users),
time_elapsed=(datetime.datetime.now() - start_datetime).seconds,
)

users.update(AdminPermission.owners(project).values_list("username", flat=True))

# Only send 1 email per user,
# even if that user has multiple projects using "build.image".
# The notification will mention all the projects.
queryset = User.objects.filter(
username__in=users,
profile__banned=False,
profile__optout_email_build_image_deprecation=False,
).order_by("id")

n_users = queryset.count()
for i, user in enumerate(queryset.iterator()):
if i % 500 == 0:
log.info(
'Sending deprecated "build.image" config key notification to users.',
progress=f"{i}/{n_users}",
current_user_pk=user.pk,
current_user_username=user.username,
time_elapsed=(datetime.datetime.now() - start_datetime).seconds,
)

# All the projects for this user that are using "build.image".
# Use set() intersection in Python that's pretty quick since we only need the slugs.
# Otherwise we have to pass 82k slugs to the DB query, which makes it pretty slow.
user_projects = AdminPermission.projects(user, admin=True).values_list(
"slug", flat=True
)
user_projects_slugs = list(set(user_projects) & projects)
user_projects = Project.objects.filter(slug__in=user_projects_slugs)

# Create slug string for onsite notification
user_project_slugs = ", ".join(user_projects_slugs[:5])
if len(user_projects) > 5:
user_project_slugs += " and others..."

n_site = DeprecatedBuildImageSiteNotification(
user=user,
context_object=user,
extra_context={"project_slugs": user_project_slugs},
success=False,
)
n_site.send()

n_email = DeprecatedBuildImageEmailNotification(
user=user,
context_object=user,
extra_context={"projects": user_projects},
)
n_email.send()

log.info(
'Finish sending deprecated "build.image" config key notifications.',
time_elapsed=(datetime.datetime.now() - start_datetime).seconds,
)


@app.task(queue="web")
def set_builder_scale_in_protection(instance, protected_from_scale_in):
"""
Expand Down
5 changes: 5 additions & 0 deletions readthedocs/settings/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -536,6 +536,11 @@ def TEMPLATES(self):
'schedule': crontab(day_of_week='wednesday', hour=11, minute=15),
'options': {'queue': 'web'},
},
'weekly-build-image-notification': {
'task': 'readthedocs.projects.tasks.utils.deprecated_build_image_notification',
'schedule': crontab(day_of_week='wednesday', hour=9, minute=15),
'options': {'queue': 'web'},
},
}

# Sentry
Expand Down
26 changes: 19 additions & 7 deletions readthedocs/templates/builds/build_detail.html
Original file line number Diff line number Diff line change
Expand Up @@ -163,17 +163,29 @@
{% endif %}

{# This message is not dynamic and only appears when loading the page after the build has finished #}
{% if build.finished and build.deprecated_config_used %}
<div class="build-ideas">
{% if build.finished and build.deprecated_build_image_used %}
<div class="build-ideas">
<p>
{% blocktrans trimmed with config_file_link="https://blog.readthedocs.com/migrate-configuration-v2/" %}
{% blocktrans trimmed with config_file_link="https://blog.readthedocs.com/build-image-config-deprecated/" %}
<strong>Your builds will stop working soon!</strong><br/>
Configuration files will <strong>soon be required</strong> by projects, and will no longer be optional.
<a href="{{ config_file_link }}">Read our blog post to create one</a>
"build.image" config key is deprecated and it will be removed soon.
<a href="{{ config_file_link }}">Read our blog post to know how to migrate to new key "build.os"</a>
and ensure your project continues building successfully.
{% endblocktrans %}
{% endblocktrans %}
</p>
</div>
</div>
{% endif %}
{% if build.finished and build.deprecated_config_used %}
<div class="build-ideas">
<p>
{% blocktrans trimmed with config_file_link="https://blog.readthedocs.com/migrate-configuration-v2/" %}
<strong>Your builds will stop working soon!</strong><br/>
Configuration files will <strong>soon be required</strong> by projects, and will no longer be optional.
<a href="{{ config_file_link }}">Read our blog post to create one</a>
and ensure your project continues building successfully.
{% endblocktrans %}
</p>
</div>
{% endif %}

{% endif %}
Expand Down
Loading