Skip to content

Commit f11eb50

Browse files
authored
Build: show the command that's currently being executed (#12243)
- Allow updating build commands via the API (only creation was allowed) - Start time, end time, and exit code can now be null, as the commands are now created before they are finished. Closes #4288
1 parent a0bf638 commit f11eb50

File tree

7 files changed

+153
-28
lines changed

7 files changed

+153
-28
lines changed

readthedocs/api/v2/serializers.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -197,6 +197,12 @@ class Meta:
197197
model = BuildCommandResult
198198
exclude = []
199199

200+
def update(self, instance, validated_data):
201+
# Build isn't allowed to be updated after creation
202+
# (e.g. to avoid moving commands to another build).
203+
validated_data.pop("build", None)
204+
return super().update(instance, validated_data)
205+
200206

201207
class BuildCommandReadOnlySerializer(BuildCommandSerializer):
202208
"""

readthedocs/api/v2/views/model_views.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -388,7 +388,9 @@ def credentials_for_storage(self, request, **kwargs):
388388
return Response({"s3": asdict(credentials)})
389389

390390

391-
class BuildCommandViewSet(DisableListEndpoint, CreateModelMixin, UserSelectViewSet):
391+
class BuildCommandViewSet(
392+
DisableListEndpoint, CreateModelMixin, UpdateModelMixin, UserSelectViewSet
393+
):
392394
parser_classes = [JSONParser, MultiPartParser]
393395
permission_classes = [HasBuildAPIKey | ReadOnlyPermission]
394396
renderer_classes = (JSONRenderer,)
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
# Generated by Django 5.2.1 on 2025-06-09 22:47
2+
3+
from django.db import migrations
4+
from django.db import models
5+
from django_safemigrate import Safe
6+
7+
8+
class Migration(migrations.Migration):
9+
safe = Safe.before_deploy()
10+
11+
dependencies = [
12+
("builds", "0062_rename_indexes"),
13+
]
14+
15+
operations = [
16+
migrations.AlterField(
17+
model_name="buildcommandresult",
18+
name="start_time",
19+
field=models.DateTimeField(blank=True, null=True, verbose_name="Start time"),
20+
),
21+
migrations.AlterField(
22+
model_name="buildcommandresult",
23+
name="end_time",
24+
field=models.DateTimeField(blank=True, null=True, verbose_name="End time"),
25+
),
26+
migrations.AlterField(
27+
model_name="buildcommandresult",
28+
name="exit_code",
29+
field=models.IntegerField(blank=True, null=True, verbose_name="Command exit code"),
30+
),
31+
]

readthedocs/builds/models.py

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -997,6 +997,15 @@ def failed(self):
997997
"""
998998
return not self.successful
999999

1000+
@property
1001+
def finished(self):
1002+
"""
1003+
Check if the command has finished running.
1004+
1005+
This is determined by checking if the `end_time` is not None.
1006+
"""
1007+
return self.end_time is not None
1008+
10001009

10011010
class BuildCommandResult(BuildCommandResultMixin, models.Model):
10021011
"""Build command for a ``Build``."""
@@ -1011,10 +1020,10 @@ class BuildCommandResult(BuildCommandResultMixin, models.Model):
10111020
command = models.TextField(_("Command"))
10121021
description = models.TextField(_("Description"), blank=True)
10131022
output = models.TextField(_("Command output"), blank=True)
1014-
exit_code = models.IntegerField(_("Command exit code"))
1023+
exit_code = models.IntegerField(_("Command exit code"), null=True, blank=True)
10151024

1016-
start_time = models.DateTimeField(_("Start time"))
1017-
end_time = models.DateTimeField(_("End time"))
1025+
start_time = models.DateTimeField(_("Start time"), null=True, blank=True)
1026+
end_time = models.DateTimeField(_("End time"), null=True, blank=True)
10181027

10191028
class Meta:
10201029
ordering = ["start_time"]

readthedocs/doc_builder/environments.py

Lines changed: 52 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@ def __init__(
7575
demux=False,
7676
**kwargs,
7777
):
78+
self.id = None
7879
self.command = command
7980
self.shell = shell
8081
self.cwd = cwd or settings.RTD_DOCKER_WORKDIR
@@ -239,11 +240,22 @@ def get_command(self):
239240
return self.command
240241

241242
def save(self, api_client):
242-
"""Save this command and result via the API."""
243+
"""
244+
Save this command and result via the API.
245+
246+
The command can be saved before or after it has been run,
247+
if it's saved before it has been run, the exit_code,
248+
start_time, and end_time will be None.
249+
250+
If the command is saved twice (before and after it has been run),
251+
the second save will update the command instead of creating a new one.
252+
The id of the command will be set the first time it is saved,
253+
so it can be used to update the command later.
254+
"""
243255
# Force record this command as success to avoid Build reporting errors
244256
# on commands that are just for checking purposes and do not interferes
245257
# in the Build
246-
if self.record_as_success:
258+
if self.record_as_success and self.exit_code is not None:
247259
log.warning("Recording command exit_code as success")
248260
self.exit_code = 0
249261

@@ -256,22 +268,39 @@ def save(self, api_client):
256268
"end_time": self.end_time,
257269
}
258270

271+
# If the command has an id, it means it has been saved before,
272+
# so we update it instead of creating a new one.
259273
if self.build_env.project.has_feature(Feature.API_LARGE_DATA):
260274
# Don't use slumber directly here. Slumber tries to enforce a string,
261275
# which will break our multipart encoding here.
262276
encoder = MultipartEncoder({key: str(value) for key, value in data.items()})
263-
resource = api_client.command
264-
resp = resource._store["session"].post(
265-
resource._store["base_url"] + "/",
266-
data=encoder,
267-
headers={
268-
"Content-Type": encoder.content_type,
269-
},
270-
)
271-
log.debug("Post response via multipart form.", response=resp)
277+
if self.id:
278+
resource = api_client.command(self.id).patch
279+
resp = resource._store["session"].patch(
280+
resource._store["url"],
281+
data=encoder,
282+
headers={
283+
"Content-Type": encoder.content_type,
284+
},
285+
)
286+
else:
287+
resource = api_client.command.post
288+
resp = resource._store["session"].post(
289+
resource._store["base_url"],
290+
data=encoder,
291+
headers={
292+
"Content-Type": encoder.content_type,
293+
},
294+
)
295+
log.debug("Response via multipart form.", response=resp)
272296
else:
273-
resp = api_client.command.post(data)
274-
log.debug("Post response via JSON encoded data.", response=resp)
297+
if self.id:
298+
resp = api_client.command(self.id).patch(data)
299+
else:
300+
resp = api_client.command.post(data)
301+
log.debug("Response via JSON encoded data.", response=resp)
302+
303+
self.id = resp.get("id")
275304

276305

277306
class DockerBuildCommand(BuildCommand):
@@ -498,20 +527,25 @@ def run_command_class(
498527
kwargs["environment"] = environment
499528
kwargs["build_env"] = self
500529
build_cmd = cls(cmd, **kwargs)
501-
build_cmd.run()
502530

531+
# Save the command that's running before it starts,
532+
# then we will update the results after it has run.
503533
if record:
504-
# TODO: I don't like how it's handled this entry point here since
505-
# this class should know nothing about a BuildCommand (which are the
506-
# only ones that can be saved/recorded)
507534
self.record_command(build_cmd)
508-
509535
# We want append this command to the list of commands only if it has
510536
# to be recorded in the database (to keep consistency) and also, it
511537
# has to be added after ``self.record_command`` since its
512538
# ``exit_code`` can be altered because of ``record_as_success``
513539
self.commands.append(build_cmd)
514540

541+
build_cmd.run()
542+
543+
if record:
544+
# TODO: I don't like how it's handled this entry point here since
545+
# this class should know nothing about a BuildCommand (which are the
546+
# only ones that can be saved/recorded)
547+
self.record_command(build_cmd)
548+
515549
if build_cmd.failed:
516550
if warn_only:
517551
msg = "Command failed"

readthedocs/rtd_tests/tests/test_api.py

Lines changed: 34 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1178,14 +1178,43 @@ def test_build_commands_read_and_write_endpoints_for_build_api_token(self):
11781178
resp = client.get(f"/api/v2/command/{command.pk}/")
11791179
self.assertEqual(resp.status_code, 200)
11801180

1181+
# And updating them.
1182+
resp = client.patch(
1183+
f"/api/v2/command/{command.pk}/",
1184+
{
1185+
"command": "test2",
1186+
"exit_code": 1,
1187+
"output": "test2",
1188+
"end_time": None,
1189+
"start_time": None,
1190+
},
1191+
)
1192+
assert resp.status_code == 200
1193+
command.refresh_from_db()
1194+
assert command.command == "test2"
1195+
assert command.exit_code == 1
1196+
assert command.output == "test2"
1197+
assert command.start_time is None
1198+
assert command.end_time is None
1199+
1200+
# Isn't possible to update the build the command belongs to.
1201+
another_build = get(
1202+
Build, project=project_b, version=project_b.versions.first()
1203+
)
1204+
resp = client.patch(
1205+
f"/api/v2/command/{command.pk}/",
1206+
{
1207+
"build": another_build.pk,
1208+
},
1209+
)
1210+
assert resp.status_code == 200
1211+
command.refresh_from_db()
1212+
assert command.build == build
1213+
11811214
# We don't allow deleting commands.
11821215
resp = client.delete(f"/api/v2/command/{command.pk}/")
11831216
self.assertEqual(resp.status_code, 405)
11841217

1185-
# Neither updating them.
1186-
resp = client.patch(f"/api/v2/command/{command.pk}/")
1187-
self.assertEqual(resp.status_code, 405)
1188-
11891218
disallowed_builds = [
11901219
get(Build, project=project_b, version=project_b.versions.first()),
11911220
get(Build, project=project_c, version=project_c.versions.first()),
@@ -1214,7 +1243,7 @@ def test_build_commands_read_and_write_endpoints_for_build_api_token(self):
12141243
self.assertEqual(resp.status_code, 405)
12151244

12161245
resp = client.patch(f"/api/v2/command/{command.pk}/")
1217-
self.assertEqual(resp.status_code, 405)
1246+
self.assertEqual(resp.status_code, 404)
12181247

12191248
def test_versions_read_only_endpoints_for_normal_user(self):
12201249
user_normal = get(User, is_staff=False)

readthedocs/rtd_tests/tests/test_doc_building.py

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,9 @@ def test_command_not_recorded(self):
3838

3939
def test_record_command_as_success(self):
4040
api_client = mock.MagicMock()
41+
api_client.command().patch.return_value = {
42+
"id": 1,
43+
}
4144
project = get(Project)
4245
build_env = LocalBuildEnvironment(
4346
project=project,
@@ -57,8 +60,19 @@ def test_record_command_as_success(self):
5760
self.assertEqual(len(build_env.commands), 1)
5861

5962
command = build_env.commands[0]
60-
self.assertEqual(command.exit_code, 0)
63+
assert command.exit_code == 0
64+
assert command.id == 1
6165
api_client.command.post.assert_called_once_with(
66+
{
67+
"build": mock.ANY,
68+
"command": command.get_command(),
69+
"output": "",
70+
"exit_code": None,
71+
"start_time": None,
72+
"end_time": None,
73+
}
74+
)
75+
api_client.command().patch.assert_called_once_with(
6276
{
6377
"build": mock.ANY,
6478
"command": command.get_command(),

0 commit comments

Comments
 (0)