From 1e427961c4d501d0fff2466506083caed92d35d5 Mon Sep 17 00:00:00 2001 From: Michael Vasseur <14887731+vmcj@users.noreply.github.com> Date: Thu, 13 Nov 2025 18:33:56 +0100 Subject: [PATCH 01/16] [Remove later] Use static link for compare scripts In case you have different GLIBC versions inside and outside of the chroot we would get errors. As dynamic linking saves a bit of space we shouldn't always do this but as we only do this for compare scripts and not the submissions this is quite a small diskoverhead. --- judge/judgedaemon.main.php | 4 ++-- sql/files/defaultdata/compare/build | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/judge/judgedaemon.main.php b/judge/judgedaemon.main.php index eb0574fdf0..bf8fcc8569 100644 --- a/judge/judgedaemon.main.php +++ b/judge/judgedaemon.main.php @@ -463,10 +463,10 @@ function fetch_executable_internal( } switch ($execlang) { case 'c': - $buildscript .= "gcc -Wall -O2 -std=gnu11 $source -o run -lm\n"; + $buildscript .= "gcc -Wall -O2 -static -std=gnu11 $source -o run -lm\n"; break; case 'cpp': - $buildscript .= "g++ -Wall -O2 -std=gnu++20 $source -o run\n"; + $buildscript .= "g++ -Wall -O2 -static -std=gnu++20 $source -o run\n"; break; case 'java': $buildscript .= "javac -cp . -d . $source\n"; diff --git a/sql/files/defaultdata/compare/build b/sql/files/defaultdata/compare/build index 31bb255097..f9275e2544 100755 --- a/sql/files/defaultdata/compare/build +++ b/sql/files/defaultdata/compare/build @@ -1,2 +1,2 @@ #!/bin/sh -g++ -std=c++11 -pedantic -g -O1 -Wall -fstack-protector -D_FORTIFY_SOURCE=2 -fPIE -Wformat -Wformat-security -fPIE -Wl,-z,relro -Wl,-z,now compare.cc -o run +g++ -std=c++11 -pedantic -g -O1 -static -Wall -fstack-protector -D_FORTIFY_SOURCE=2 -fPIE -Wformat -Wformat-security -fPIE -Wl,-z,relro -Wl,-z,now compare.cc -o run From 101dc7ff264ca4de800e84313492a909dd52c70f Mon Sep 17 00:00:00 2001 From: Michael Vasseur <14887731+vmcj@users.noreply.github.com> Date: Sat, 15 Nov 2025 16:35:13 +0100 Subject: [PATCH 02/16] Add some simple `generic_task`s for testing --- sql/files/defaultdata/chroot_upgrade/build | 2 + sql/files/defaultdata/chroot_upgrade/run | 13 +++++++ sql/files/defaultdata/judgehost_info/build | 2 + sql/files/defaultdata/judgehost_info/run | 45 ++++++++++++++++++++++ 4 files changed, 62 insertions(+) create mode 100755 sql/files/defaultdata/chroot_upgrade/build create mode 100755 sql/files/defaultdata/chroot_upgrade/run create mode 100755 sql/files/defaultdata/judgehost_info/build create mode 100755 sql/files/defaultdata/judgehost_info/run diff --git a/sql/files/defaultdata/chroot_upgrade/build b/sql/files/defaultdata/chroot_upgrade/build new file mode 100755 index 0000000000..3ad1806766 --- /dev/null +++ b/sql/files/defaultdata/chroot_upgrade/build @@ -0,0 +1,2 @@ +#!/bin/sh +# nothing to compile diff --git a/sql/files/defaultdata/chroot_upgrade/run b/sql/files/defaultdata/chroot_upgrade/run new file mode 100755 index 0000000000..8d79a8f3d8 --- /dev/null +++ b/sql/files/defaultdata/chroot_upgrade/run @@ -0,0 +1,13 @@ +#!/bin/bash +# This is an example to upgrade the chroot packages +# The basic calling convention of these scripts is: +# ./script + +output_file="$1" + +exec &>> "${output_file}" + +bin/dj_run_chroot "apt-get update" +bin/dj_run_chroot "apt-get full-upgrade -y" + +exit 0 diff --git a/sql/files/defaultdata/judgehost_info/build b/sql/files/defaultdata/judgehost_info/build new file mode 100755 index 0000000000..3ad1806766 --- /dev/null +++ b/sql/files/defaultdata/judgehost_info/build @@ -0,0 +1,2 @@ +#!/bin/sh +# nothing to compile diff --git a/sql/files/defaultdata/judgehost_info/run b/sql/files/defaultdata/judgehost_info/run new file mode 100755 index 0000000000..dac31e7080 --- /dev/null +++ b/sql/files/defaultdata/judgehost_info/run @@ -0,0 +1,45 @@ +#!/bin/sh +# This is the default script to retrieve a the configuration of both +# software & hardware of the judgehost. The basic +# calling convention of these scripts is: +# ./script + +output_file="$1" + +# Source - https://stackoverflow.com/a +# Posted by fjarlq, modified by community. See post 'Timeline' for change history +# Retrieved 2025-11-15, License - CC BY-SA 4.0 + +if [ "${SHELL#*"bash"}" != "${SHELL}" ]; then + exec &>> "${output_file}" +else + # We can't use &>> as it's not POSIX, + # this does introduce a racecondition + exec 1>> "${output_file}" + exec 2>> "${output_file}" +fi + +# Generic linux/distro information +uname -a +cat /etc/os-release + +# Information about the php version +php -v + +# Generic hardware information +lsusb +lspci +lscpu +cat /proc/cpuinfo +free -h +cat /proc/meminfo + +if [ command -v hwinfo ]; then + hwinfo --short +fi + +if [ command -v inxi ]; then + inxi -Fx +fi + +exit 0 From 884ccd99db99526606a01d3d93a9dcfda5d17959 Mon Sep 17 00:00:00 2001 From: Michael Vasseur <14887731+vmcj@users.noreply.github.com> Date: Sat, 15 Nov 2025 17:29:26 +0100 Subject: [PATCH 03/16] Fix detection of methods in PhpStorm --- webapp/src/Controller/Jury/ExecutableController.php | 3 +++ 1 file changed, 3 insertions(+) diff --git a/webapp/src/Controller/Jury/ExecutableController.php b/webapp/src/Controller/Jury/ExecutableController.php index e08e1b4afa..bc9a2e6b9d 100644 --- a/webapp/src/Controller/Jury/ExecutableController.php +++ b/webapp/src/Controller/Jury/ExecutableController.php @@ -60,6 +60,9 @@ public function indexAction(Request $request): Response ->addOrderBy('e.type', 'ASC') ->addOrderBy('e.execid', 'ASC') ->getQuery()->getResult(); + // PhpStorm doesn't pick this up without, + // based on a dump this is `"c" => App\Entity\Executable`. + /** @var Executable[] $executables */ $executables = array_column($executables, 'executable', 'execid'); $table_fields = [ 'icon' => ['title' => 'type', 'sort' => false], From 91f797e68ee22eb2595b43d8cc8d2a9a05a61182 Mon Sep 17 00:00:00 2001 From: Michael Vasseur <14887731+vmcj@users.noreply.github.com> Date: Sat, 15 Nov 2025 17:30:01 +0100 Subject: [PATCH 04/16] Display generic tasks with their own icon In a future commit we'll display those on the judgehost pages as a simple way to run some shell commands on judgehosts. --- webapp/src/Controller/Jury/ExecutableController.php | 3 +++ .../src/DataFixtures/DefaultData/ExecutableFixture.php | 10 ++++++---- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/webapp/src/Controller/Jury/ExecutableController.php b/webapp/src/Controller/Jury/ExecutableController.php index bc9a2e6b9d..6117feadb5 100644 --- a/webapp/src/Controller/Jury/ExecutableController.php +++ b/webapp/src/Controller/Jury/ExecutableController.php @@ -142,6 +142,9 @@ public function indexAction(Request $request): Response case 'debug': $execdata['icon']['icon'] = 'bug'; break; + case 'generic_task': + $execdata['icon']['icon'] = 'check-double'; + break; case 'run': $execdata['icon']['icon'] = 'person-running'; break; diff --git a/webapp/src/DataFixtures/DefaultData/ExecutableFixture.php b/webapp/src/DataFixtures/DefaultData/ExecutableFixture.php index 87ad229594..b8ffeec871 100644 --- a/webapp/src/DataFixtures/DefaultData/ExecutableFixture.php +++ b/webapp/src/DataFixtures/DefaultData/ExecutableFixture.php @@ -23,10 +23,12 @@ public function load(ObjectManager $manager): void { $data = [ // ID, description, type - ['compare', 'default compare script', 'compare'], - ['full_debug', 'default full debug script', 'debug'], - ['java_javac', 'java_javac', 'compile'], - ['run', 'default run script', 'run'], + ['compare', 'default compare script', 'compare'], + ['full_debug', 'default full debug script', 'debug'], + ['java_javac', 'java_javac', 'compile'], + ['judgehost_info', 'generic information about the judgehost', 'generic_task'], + ['chroot_upgrade', 'upgrade chroot packages', 'generic_task'], + ['run', 'default run script', 'run'], ]; foreach ($data as $item) { From 094b2864bcf93d2f2685e8c01fd229797eb6a319 Mon Sep 17 00:00:00 2001 From: Michael Vasseur <14887731+vmcj@users.noreply.github.com> Date: Sat, 15 Nov 2025 17:31:43 +0100 Subject: [PATCH 05/16] Discussion point Maybe we should run generic tasks with a dedicated user, that way we can limit the allowed commands or even allow commands which need sudo. Something like this: https://at.magma-soft.at/sw/blog/posts/The_Only_Way_For_SSH_Forced_Commands/ or this: https://stackoverflow.com/a/50067008 From ca86bc0d27213da3e107571d6f7194e27ba87e7b Mon Sep 17 00:00:00 2001 From: Michael Vasseur <14887731+vmcj@users.noreply.github.com> Date: Sat, 15 Nov 2025 17:59:42 +0100 Subject: [PATCH 06/16] Allow admins to run predefined scripts on the (visible) judgehost(s) I've now limited it to admins, but I wonder if this is something which any jury member should be able to do. --- .../Controller/Jury/JudgehostController.php | 58 +++++++++++++++++++ 1 file changed, 58 insertions(+) diff --git a/webapp/src/Controller/Jury/JudgehostController.php b/webapp/src/Controller/Jury/JudgehostController.php index 58e9eb103a..cf84ad6cfe 100644 --- a/webapp/src/Controller/Jury/JudgehostController.php +++ b/webapp/src/Controller/Jury/JudgehostController.php @@ -296,6 +296,64 @@ public function viewAction(Request $request, int $judgehostid): Response } } + private function helperGenericTask(int $execid, ?JudgeHost $judgehost = null): void { + $executable = $this->em->getRepository(Executable::class)->findOneBy(['execid' => $execid]); + if (!$executable) { + throw new NotFoundHttpException(sprintf('Executable with ID %d not found', $execid)); + } + + $executable = $executable->getImmutableExecutable(); + + $judgehosts = []; + if ($judgehost) { + $judgehosts[] = $judgehost; + } else { + /** @var Judgehost[] $judgehosts */ + $judgehosts = $this->em->createQueryBuilder() + ->from(Judgehost::class, 'j') + ->select('j') + ->andWhere('j.hidden = 0') + ->getQuery()->getResult(); + } + foreach ($judgehosts as $judgehost) { + $judgeTask = new JudgeTask(); + $judgeTask + ->setType(JudgeTaskType::GENERIC_TASK) + ->setPriority(JudgeTask::PRIORITY_HIGH) + ->setRunScriptId($executable->getImmutableExecId()) + ->setRunConfig(Utils::jsonEncode(['hash' => $executable->getHash()])); + $this->em->persist($judgeTask); + } + $this->em->flush(); + } + + #[IsGranted('ROLE_ADMIN')] + #[Route(path: '/{judgehostid}/request-generic-task/{execid}', name: 'jury_request_judgehost_generic')] + public function requestGenericTaskJudgehost(Request $request, int $judgehostid, int $execid): RedirectResponse + { + $judgehost = $this->em->getRepository(Judgehost::class)->find($judgehostid); + if (!$judgehost) { + throw new NotFoundHttpException(sprintf('Judgehost with ID %d not found', $judgehostid)); + } + + $this->helperGenericTask($execid, $judgehost); + + return $this->redirectToRoute('jury_judgehost', [ + 'judgehostid' => $judgehostid + ]); + } + + // TODO: Does the ordering matter in the file. + #[IsGranted('ROLE_ADMIN')] + #[Route(path: '/request-generic-task/{execid}', name: 'jury_request_generic')] + public function requestGenericTask(Request $request, int $execid): RedirectResponse + { + $this->helperGenericTask($execid); + return $this->redirectToRoute('jury_judgehost', [ + 'judgehostid' => $judgehostid + ]); + } + /** * @throws DBALException * @throws NoResultException From c21737ee4eb89fd74c5f7933a1d5da92b1204914 Mon Sep 17 00:00:00 2001 From: Michael Vasseur <14887731+vmcj@users.noreply.github.com> Date: Sun, 16 Nov 2025 00:17:24 +0100 Subject: [PATCH 07/16] Display a simple selection menu to trigger the task We can either pass the externalid or the id of the immutableexecutable. For now I went with the externalid as the immutable makes that it can be cached and we can use the wrong URL bij accident. The advantage of using the immutable is that we get some extra info about which versions were ran via the log. The same thing can be extracted from the judgetask table. --- .../Controller/Jury/JudgehostController.php | 31 ++++++++++++++++--- webapp/templates/jury/judgehost.html.twig | 9 ++++++ webapp/templates/jury/judgehosts.html.twig | 31 +++++++++++++++++++ 3 files changed, 67 insertions(+), 4 deletions(-) diff --git a/webapp/src/Controller/Jury/JudgehostController.php b/webapp/src/Controller/Jury/JudgehostController.php index cf84ad6cfe..2ee1ad930f 100644 --- a/webapp/src/Controller/Jury/JudgehostController.php +++ b/webapp/src/Controller/Jury/JudgehostController.php @@ -4,6 +4,7 @@ use App\Controller\BaseController; use App\Doctrine\DBAL\Types\JudgeTaskType; +use App\Entity\Executable; use App\Entity\Judgehost; use App\Entity\JudgeTask; use App\Entity\Judging; @@ -210,8 +211,19 @@ public function indexAction(Request $request): Response return strnatcasecmp($a['data']['hostname']['value'], $b['data']['hostname']['value']); }); + /** @var Executable[] $executables */ + $executables = $this->em->createQueryBuilder() + ->select('e as executable, e.execid as execid') + ->from(Executable::class, 'e') + ->addOrderBy('e.type', 'ASC') + ->addOrderBy('e.execid', 'ASC') + ->andWhere('e.type = :type') + ->setParameter('type', JudgeTaskType::GENERIC_TASK) + ->getQuery()->getResult(); + $data = [ 'judgehosts' => $judgehosts_table, + 'executables' => $executables, 'table_fields' => $table_fields, 'all_checked_in_recently' => $all_checked_in_recently, 'refresh' => [ @@ -278,7 +290,18 @@ public function viewAction(Request $request, int $judgehostid): Response ->getResult(); } + /** @var Executable[] $executables */ + $executables = $this->em->createQueryBuilder() + ->select('e as executable, e.execid as execid') + ->from(Executable::class, 'e') + ->addOrderBy('e.type', 'ASC') + ->addOrderBy('e.execid', 'ASC') + ->andWhere('e.type = :type') + ->setParameter('type', JudgeTaskType::GENERIC_TASK) + ->getQuery()->getResult(); + $data = [ + 'executables' => $executables, 'judgehost' => $judgehost, 'status' => $status, 'statusIcon' => $statusIcon, @@ -296,10 +319,10 @@ public function viewAction(Request $request, int $judgehostid): Response } } - private function helperGenericTask(int $execid, ?JudgeHost $judgehost = null): void { + private function helperGenericTask(string $execid, ?JudgeHost $judgehost = null): void { $executable = $this->em->getRepository(Executable::class)->findOneBy(['execid' => $execid]); if (!$executable) { - throw new NotFoundHttpException(sprintf('Executable with ID %d not found', $execid)); + throw new NotFoundHttpException(sprintf('Executable with ID %s not found', $execid)); } $executable = $executable->getImmutableExecutable(); @@ -329,7 +352,7 @@ private function helperGenericTask(int $execid, ?JudgeHost $judgehost = null): v #[IsGranted('ROLE_ADMIN')] #[Route(path: '/{judgehostid}/request-generic-task/{execid}', name: 'jury_request_judgehost_generic')] - public function requestGenericTaskJudgehost(Request $request, int $judgehostid, int $execid): RedirectResponse + public function requestGenericTaskJudgehost(Request $request, int $judgehostid, string $execid): RedirectResponse { $judgehost = $this->em->getRepository(Judgehost::class)->find($judgehostid); if (!$judgehost) { @@ -346,7 +369,7 @@ public function requestGenericTaskJudgehost(Request $request, int $judgehostid, // TODO: Does the ordering matter in the file. #[IsGranted('ROLE_ADMIN')] #[Route(path: '/request-generic-task/{execid}', name: 'jury_request_generic')] - public function requestGenericTask(Request $request, int $execid): RedirectResponse + public function requestGenericTask(Request $request, string $execid): RedirectResponse { $this->helperGenericTask($execid); return $this->redirectToRoute('jury_judgehost', [ diff --git a/webapp/templates/jury/judgehost.html.twig b/webapp/templates/jury/judgehost.html.twig index 527fa92b56..d87286f498 100644 --- a/webapp/templates/jury/judgehost.html.twig +++ b/webapp/templates/jury/judgehost.html.twig @@ -67,4 +67,13 @@ {% include 'jury/partials/judgehost_judgings.html.twig' %} +
+

Request generic actions

+ {% for executable in executables %} + + {{ executable.executable.description }} +
+ {% endfor %} +
+ {% endblock %} diff --git a/webapp/templates/jury/judgehosts.html.twig b/webapp/templates/jury/judgehosts.html.twig index 9dca8cd41e..60552be551 100644 --- a/webapp/templates/jury/judgehosts.html.twig +++ b/webapp/templates/jury/judgehosts.html.twig @@ -44,6 +44,37 @@ {{ button(path('jury_judgehost_edit'), 'Edit all judgehosts', 'secondary', 'edit') }} +
+ + + + + + + + + {% for judgehost in judgehosts %} + + {% endfor %} + + + + {% for executable in executables %} + + + + {% for judgehost in judgehosts %} + + {% endfor %} + + {% endfor %} + +
ExecutableJudgehost
All{{ judgehost.data.hostname.value }}
{{ executable.executable.description }} + All + + {{ judgehost.data.hostname.value }} +
+
{%- endif %} From c576c5e73e1412a68b8efc35fa019fb071ad6988 Mon Sep 17 00:00:00 2001 From: Michael Vasseur <14887731+vmcj@users.noreply.github.com> Date: Sun, 16 Nov 2025 15:46:52 +0100 Subject: [PATCH 08/16] [ToDo] Return to the page which started it This does not return to the page which indicated it when you use the single judgehost task and are at the judgehosts page. --- webapp/src/Controller/Jury/JudgehostController.php | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/webapp/src/Controller/Jury/JudgehostController.php b/webapp/src/Controller/Jury/JudgehostController.php index 2ee1ad930f..d15757221d 100644 --- a/webapp/src/Controller/Jury/JudgehostController.php +++ b/webapp/src/Controller/Jury/JudgehostController.php @@ -372,9 +372,7 @@ public function requestGenericTaskJudgehost(Request $request, int $judgehostid, public function requestGenericTask(Request $request, string $execid): RedirectResponse { $this->helperGenericTask($execid); - return $this->redirectToRoute('jury_judgehost', [ - 'judgehostid' => $judgehostid - ]); + return $this->redirectToRoute('jury_judgehosts'); } /** From 196095e5f9e1ad993649902870182b288cb9e3a1 Mon Sep 17 00:00:00 2001 From: Michael Vasseur <14887731+vmcj@users.noreply.github.com> Date: Mon, 17 Nov 2025 16:51:53 +0100 Subject: [PATCH 09/16] Prepare the database for the output --- webapp/migrations/Version20251117185929.php | 42 +++++ .../Controller/Jury/JudgehostController.php | 1 + webapp/src/Entity/GenericTask.php | 167 ++++++++++++++++++ webapp/src/Entity/GenericTaskOutput.php | 75 ++++++++ webapp/src/Entity/JudgeTask.php | 15 ++ 5 files changed, 300 insertions(+) create mode 100644 webapp/migrations/Version20251117185929.php create mode 100644 webapp/src/Entity/GenericTask.php create mode 100644 webapp/src/Entity/GenericTaskOutput.php diff --git a/webapp/migrations/Version20251117185929.php b/webapp/migrations/Version20251117185929.php new file mode 100644 index 0000000000..c3c1e13012 --- /dev/null +++ b/webapp/migrations/Version20251117185929.php @@ -0,0 +1,42 @@ +addSql('CREATE TABLE generic_task (taskid INT UNSIGNED AUTO_INCREMENT NOT NULL COMMENT \'Task ID\', judgetaskid INT UNSIGNED DEFAULT NULL COMMENT \'JudgeTask ID\', runtime DOUBLE PRECISION DEFAULT NULL COMMENT \'Running time for this task\', endtime NUMERIC(32, 9) UNSIGNED DEFAULT NULL COMMENT \'Time task ended\', start_time NUMERIC(32, 9) UNSIGNED DEFAULT NULL COMMENT \'Time task started\', INDEX IDX_680437B63CBA64F2 (judgetaskid), PRIMARY KEY(taskid)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB COMMENT = \'Result of a generic task\' '); + $this->addSql('CREATE TABLE generic_task_output (taskid INT UNSIGNED NOT NULL COMMENT \'Task ID\', output_task LONGBLOB DEFAULT NULL COMMENT \'Output of running the program(DC2Type:blobtext)\', output_error LONGBLOB DEFAULT NULL COMMENT \'Standard error output of the program(DC2Type:blobtext)\', INDEX taskid (taskid), PRIMARY KEY(taskid)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB COMMENT = \'Stores output of generic task\' '); + $this->addSql('ALTER TABLE generic_task ADD CONSTRAINT FK_680437B63CBA64F2 FOREIGN KEY (judgetaskid) REFERENCES judgetask (judgetaskid) ON DELETE CASCADE'); + $this->addSql('ALTER TABLE generic_task_output ADD CONSTRAINT FK_6425C7BE46CBEE95 FOREIGN KEY (taskid) REFERENCES generic_task (taskid) ON DELETE CASCADE'); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('ALTER TABLE generic_task DROP FOREIGN KEY FK_680437B63CBA64F2'); + $this->addSql('ALTER TABLE generic_task_output DROP FOREIGN KEY FK_6425C7BE46CBEE95'); + $this->addSql('DROP TABLE generic_task'); + $this->addSql('DROP TABLE generic_task_output'); + } + + public function isTransactional(): bool + { + return false; + } +} diff --git a/webapp/src/Controller/Jury/JudgehostController.php b/webapp/src/Controller/Jury/JudgehostController.php index d15757221d..6c90e37dfb 100644 --- a/webapp/src/Controller/Jury/JudgehostController.php +++ b/webapp/src/Controller/Jury/JudgehostController.php @@ -342,6 +342,7 @@ private function helperGenericTask(string $execid, ?JudgeHost $judgehost = null) $judgeTask = new JudgeTask(); $judgeTask ->setType(JudgeTaskType::GENERIC_TASK) + ->setJudgehost($judgehost) ->setPriority(JudgeTask::PRIORITY_HIGH) ->setRunScriptId($executable->getImmutableExecId()) ->setRunConfig(Utils::jsonEncode(['hash' => $executable->getHash()])); diff --git a/webapp/src/Entity/GenericTask.php b/webapp/src/Entity/GenericTask.php new file mode 100644 index 0000000000..3cee566a44 --- /dev/null +++ b/webapp/src/Entity/GenericTask.php @@ -0,0 +1,167 @@ + 'utf8mb4_unicode_ci', + 'charset' => 'utf8mb4', + 'comment' => 'Result of a generic task', +])] +class GenericTask extends BaseApiEntity +{ + #[ORM\Id] + #[ORM\GeneratedValue] + #[ORM\Column(options: ['comment' => 'Task ID', 'unsigned' => true])] + #[Serializer\SerializedName('id')] + #[Serializer\Type('string')] + protected int $taskid; + + #[ORM\Column( + nullable: true, + options: ['comment' => 'JudgeTask ID', 'unsigned' => true, 'default' => null] + )] + #[Serializer\Exclude] + private ?int $judgetaskid = null; + + #[ORM\Column( + nullable: true, + options: ['comment' => 'Running time for this task'] + )] + #[Serializer\Exclude] + private string|float|null $runtime = null; + + #[ORM\Column( + type: 'decimal', + precision: 32, + scale: 9, + nullable: true, + options: ['comment' => 'Time task ended', 'unsigned' => true] + )] + #[Serializer\Exclude] + private string|float|null $endtime = null; + + #[ORM\Column( + type: 'decimal', + precision: 32, + scale: 9, + nullable: true, + options: ['comment' => 'Time task started', 'unsigned' => true] + )] + #[Serializer\Exclude] + private string|float|null $startTime = null; + + /** + * @var Collection + * + * We use a OneToMany instead of a OneToOne here, because otherwise this + * relation will always be loaded. See the commit message of commit + * 9e421f96691ec67ed62767fe465a6d8751edd884 for a more elaborate explanation + */ + #[ORM\OneToMany(mappedBy: 'run', targetEntity: GenericTaskOutput::class, cascade: ['persist'], orphanRemoval: true)] + #[Serializer\Exclude] + private Collection $output; + + #[ORM\ManyToOne(inversedBy: 'judging_runs')] + #[ORM\JoinColumn(name: 'judgetaskid', referencedColumnName: 'judgetaskid', onDelete: 'CASCADE')] + #[Serializer\Exclude] + private ?JudgeTask $judgetask = null; + + public function __construct() + { + $this->output = new ArrayCollection(); + } + + public function getTaskid(): int + { + return $this->taskid; + } + + public function setJudgeTaskId(int $judgetaskid): GenericTask + { + $this->judgetaskid = $judgetaskid; + return $this; + } + + public function getJudgeTaskId(): ?int + { + return $this->judgetaskid; + } + + public function getJudgeTask(): ?JudgeTask + { + return $this->judgetask; + } + + public function setJudgeTask(JudgeTask $judgeTask): GenericTask + { + $this->judgetask = $judgeTask; + return $this; + } + + public function setRuntime(string|float $runtime): GenericTask + { + $this->runtime = $runtime; + return $this; + } + + #[Serializer\VirtualProperty] + #[Serializer\SerializedName('run_time')] + #[Serializer\Type('float')] + public function getRuntime(): string|float|null + { + return Utils::roundedFloat($this->runtime); + } + + public function setEndtime(string|float $endtime): GenericTask + { + $this->endtime = $endtime; + return $this; + } + + public function setStarttime(string|float $startTime): GenericTask + { + $this->startTime = $startTime; + return $this; + } + + public function getStarttime(): string|float|null + { + return $this->startTime; + } + + public function getEndtime(): string|float|null + { + return $this->endtime; + } + + #[Serializer\VirtualProperty] + #[Serializer\SerializedName('time')] + #[Serializer\Type('string')] + public function getAbsoluteEndTime(): string + { + return Utils::absTime($this->getEndtime()); + } + + public function setOutput(GenericTaskOutput $output): GenericTask + { + $this->output->clear(); + $this->output->add($output); + $output->setRun($this); + + return $this; + } + + public function getOutput(): ?GenericTaskOutput + { + return $this->output->first() ?: null; + } +} diff --git a/webapp/src/Entity/GenericTaskOutput.php b/webapp/src/Entity/GenericTaskOutput.php new file mode 100644 index 0000000000..6b41392d56 --- /dev/null +++ b/webapp/src/Entity/GenericTaskOutput.php @@ -0,0 +1,75 @@ + 'utf8mb4_unicode_ci', + 'charset' => 'utf8mb4', + 'comment' => 'Stores output of generic task', +])] +#[ORM\Index(columns: ['taskid'], name: 'taskid')] +class GenericTaskOutput +{ + /** + * We use a ManyToOne instead of a OneToOne here, because otherwise the + * reverse of this relation will always be loaded. See the commit message of commit + * 9e421f96691ec67ed62767fe465a6d8751edd884 for a more elaborate explanation + */ + #[ORM\Id] + #[ORM\ManyToOne(inversedBy: 'output')] + #[ORM\JoinColumn(name: 'taskid', referencedColumnName: 'taskid', onDelete: 'CASCADE')] + private GenericTask $task; + + #[ORM\Column( + type: 'blobtext', + nullable: true, + options: ['comment' => 'Output of running the program'] + )] + private ?string $output_task = null; + + #[ORM\Column( + type: 'blobtext', + nullable: true, + options: ['comment' => 'Standard error output of the program'] + )] + private ?string $output_error = null; + + public function setGenericTask(GenericTask $task): GenericTaskOutput + { + $this->task = $task; + return $this; + } + + public function getGenericTask(): GenericTask + { + return $this->task; + } + + public function setOutputTask(?string $outputTask): GenericTaskOutput + { + $this->output_task = $outputTask; + return $this; + } + + public function getOutputTask(): string + { + return $this->output_task; + } + + public function setOutputError(string $outputError): GenericTaskOutput + { + $this->output_error = $outputError; + return $this; + } + + public function getOutputError(): string + { + return $this->output_error; + } +} diff --git a/webapp/src/Entity/JudgeTask.php b/webapp/src/Entity/JudgeTask.php index 513efebee1..a8b722ce25 100644 --- a/webapp/src/Entity/JudgeTask.php +++ b/webapp/src/Entity/JudgeTask.php @@ -169,6 +169,7 @@ public function getSubmitid(): ?int public function __construct() { $this->judging_runs = new ArrayCollection(); + $this->generic_tasks = new ArrayCollection(); } public function getJudgetaskid(): int @@ -375,6 +376,20 @@ public function getJudgingRuns(): Collection return $this->judging_runs; } + public function addGenericTask(GenericTask $genericTask): GenericTask + { + $this->generic_tasks[] = $genericTask; + return $this; + } + + /** + * @return Collection + */ + public function getGenericTasks(): Collection + { + return $this->generic_tasks; + } + /** * Gets the first judging run for this judgetask. * From c9910d85cb7d1513525347b9f9ee6bf862f93816 Mon Sep 17 00:00:00 2001 From: Michael Vasseur <14887731+vmcj@users.noreply.github.com> Date: Mon, 17 Nov 2025 16:57:41 +0100 Subject: [PATCH 10/16] Show all generic tasks as used as they are exposed --- webapp/src/Entity/Executable.php | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/webapp/src/Entity/Executable.php b/webapp/src/Entity/Executable.php index 2beff3a635..4c0324f549 100644 --- a/webapp/src/Entity/Executable.php +++ b/webapp/src/Entity/Executable.php @@ -31,7 +31,7 @@ class Executable private ?string $description = null; #[ORM\Column(length: 32, options: ['comment' => 'Type of executable'])] - #[Assert\Choice(['compare', 'compile', 'debug', 'run'])] + #[Assert\Choice(['compare', 'compile', 'debug', 'run', 'generic_task'])] private string $type; #[ORM\OneToOne(targetEntity: ImmutableExecutable::class)] @@ -187,6 +187,9 @@ public function getZipfileContent(string $tempdir): string */ public function checkUsed(array $configScripts): bool { + if ($this->getType() === 'generic_task') { + return true; + } if (in_array($this->execid, $configScripts, true)) { return true; } From e75ed36163a713a5b6e7096e2c1dd4a918045293 Mon Sep 17 00:00:00 2001 From: Michael Vasseur <14887731+vmcj@users.noreply.github.com> Date: Mon, 17 Nov 2025 20:54:15 +0100 Subject: [PATCH 11/16] Fetch either debug or generic tasks first --- webapp/src/Controller/API/JudgehostController.php | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/webapp/src/Controller/API/JudgehostController.php b/webapp/src/Controller/API/JudgehostController.php index e9b184606f..8950b40a21 100644 --- a/webapp/src/Controller/API/JudgehostController.php +++ b/webapp/src/Controller/API/JudgehostController.php @@ -1581,9 +1581,10 @@ public function getJudgeTasksAction(Request $request): array ->andWhere('jt.judgehost = :judgehost') ->andWhere('jt.starttime IS NULL') ->andWhere('jt.valid = 1') - ->andWhere('jt.type = :type') + ->andWhere('jt.type in (:type)') ->setParameter('judgehost', $judgehost) - ->setParameter('type', JudgeTaskType::DEBUG_INFO) + ->setParameter('type', [JudgeTaskType::DEBUG_INFO, JudgeTaskType::GENERIC_TASK], + ArrayParameterType::STRING) ->addOrderBy('jt.priority') ->addOrderBy('jt.judgetaskid') ->setMaxResults(1) From 549adad0532e26db0ead0a8461537cb6ee180e41 Mon Sep 17 00:00:00 2001 From: Michael Vasseur <14887731+vmcj@users.noreply.github.com> Date: Mon, 17 Nov 2025 21:17:02 +0100 Subject: [PATCH 12/16] Request the generic task in the judgedaemon --- judge/judgedaemon.main.php | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/judge/judgedaemon.main.php b/judge/judgedaemon.main.php index bf8fcc8569..b0d0651958 100644 --- a/judge/judgedaemon.main.php +++ b/judge/judgedaemon.main.php @@ -891,6 +891,40 @@ function fetch_executable_internal( } logmsg(LOG_INFO, " 🔥 Pre-heating judgehost completed."); continue; + } elseif ($type == 'generic_task') { + foreach ($row as $judgeTask) { + if (!(isset($judgeTask['run_script_id']) && isset($judgeTask['run_config']))) { + // TODO: Should we actually exit here, we do for malformed above but the mistake is not on our side. + error("Received judgehost_check task without run_script_id/run_config."); + } + + $run_config = dj_json_decode($judgeTask['run_config']); + $tmpfile = tempnam(TMPDIR, 'generic_task_'); + [$runpath, $error] = fetch_executable( + $workdirpath, + 'generic_task', + $judgeTask['run_script_id'], + $run_config['hash'], + $judgeTask['judgetaskid'] + ); + + if (!run_command_safe([$runpath, $tmpfile])) { + disable('run_script', 'run_script_id', $judgeTask['run_script_id'], "Running '$runpath' failed."); + } + + request( + sprintf('judgehosts/add-generic-task/%s/%s', urlencode($myhost), urlencode((string)$judgeTask['judgetaskid'])), + 'POST', + ['generic_task' => rest_encode_file($tmpfile, false)], + false + ); + + unlink($tmpfile); + logmsg(LOG_INFO, " ⇡ Uploading task output."); + } + + logmsg(LOG_INFO, " 🔥 Running generic judgehost tasks completed."); + continue; } // Create workdir for judging. From 8e172b2f627946fe8e2a6fce082bb890ea2f7559 Mon Sep 17 00:00:00 2001 From: Michael Vasseur <14887731+vmcj@users.noreply.github.com> Date: Mon, 17 Nov 2025 22:02:53 +0100 Subject: [PATCH 13/16] Store the output of the script in the database --- .../Controller/API/JudgehostController.php | 61 ++++++++++++++++++- webapp/src/Entity/GenericTask.php | 2 +- webapp/src/Entity/JudgeTask.php | 7 +++ 3 files changed, 68 insertions(+), 2 deletions(-) diff --git a/webapp/src/Controller/API/JudgehostController.php b/webapp/src/Controller/API/JudgehostController.php index 8950b40a21..25f5d84571 100644 --- a/webapp/src/Controller/API/JudgehostController.php +++ b/webapp/src/Controller/API/JudgehostController.php @@ -8,6 +8,8 @@ use App\Entity\DebugPackage; use App\Entity\Executable; use App\Entity\ExecutableFile; +use App\Entity\GenericTask; +use App\Entity\GenericTaskOutput; use App\Entity\InternalError; use App\Entity\Judgehost; use App\Entity\JudgeTask; @@ -553,6 +555,63 @@ public function addDebugInfo( $this->em->flush(); } + /** + * Add generic task output. + */ + #[IsGranted('ROLE_JUDGEHOST')] + #[Rest\Post('/add-generic-task/{hostname}/{judgeTaskId<\d+>}')] + #[OA\Response(response: 200, description: 'When the task output has been added')] + public function addGenericTaskOutput( + Request $request, + #[OA\PathParameter(description: 'The hostname of the judgehost that wants to add the task output')] + string $hostname, + #[OA\PathParameter(description: 'The ID of the judgetask to add', schema: new OA\Schema(type: 'integer'))] + int $judgeTaskId + ): void { + $judgeTask = $this->em->getRepository(JudgeTask::class)->find($judgeTaskId); + if ($judgeTask === null) { + throw new BadRequestHttpException( + 'Inconsistent data, no judgetask known with judgetaskid = ' . $judgeTaskId . '.'); + } + + $required = ['generic_task']; + foreach ($required as $argument) { + if (!$request->request->has($argument)) { + throw new BadRequestHttpException( + sprintf("Argument '%s' is mandatory", $argument)); + } + } + + $judgehost = $this->em->getRepository(Judgehost::class)->findOneBy(['hostname' => $hostname]); + if (!$judgehost) { + throw new BadRequestHttpException("Who are you and why are you sending us any data?"); + } + + $genericTask = $judgeTask->getGenericTasks(); + if (count($genericTask) === 0) { + $genericTask = new GenericTask(); + $genericTask + ->setJudgeTask($judgeTask) + ->setStarttime($judgeTask->getStarttime()) + ->setEndtime(Utils::now()); + $genericTaskOutput = new GenericTaskOutput(); + $genericTaskOutput->setGenericTask($genericTask); + $genericTask->setOutput($genericTaskOutput); + $this->em->persist($genericTask); + $this->em->persist($genericTaskOutput); + } elseif (count($genercTasks) !== 1) { + throw new BadRequestHttpException("There should be only one generic task for this judgetask."); + } else { + $genericTask = $genericTask->first(); + } + + $genericTask->setEndtime(Utils::now()); + $outputTask = base64_decode($request->request->get('generic_task')); + $genericTaskOutput = $genericTask->getOutput(); + $genericTaskOutput->setOutputTask($outputTask); + $this->em->flush(); + } + /** * Add one JudgingRun. When relevant, finalize the judging. * @throws DBALException @@ -1238,7 +1297,7 @@ public function getFilesAction( return match ($type) { 'source' => $this->getSourceFiles($id), 'testcase' => $this->getTestcaseFiles($id), - 'compare', 'compile', 'debug', 'run' => $this->getExecutableFiles($id), + 'compare', 'compile', 'debug', 'run', 'generic_task' => $this->getExecutableFiles($id), default => throw new BadRequestHttpException('Unknown type requested.'), }; } diff --git a/webapp/src/Entity/GenericTask.php b/webapp/src/Entity/GenericTask.php index 3cee566a44..a081b69084 100644 --- a/webapp/src/Entity/GenericTask.php +++ b/webapp/src/Entity/GenericTask.php @@ -155,7 +155,7 @@ public function setOutput(GenericTaskOutput $output): GenericTask { $this->output->clear(); $this->output->add($output); - $output->setRun($this); + $output->setGenericTask($this); return $this; } diff --git a/webapp/src/Entity/JudgeTask.php b/webapp/src/Entity/JudgeTask.php index a8b722ce25..800ea1b193 100644 --- a/webapp/src/Entity/JudgeTask.php +++ b/webapp/src/Entity/JudgeTask.php @@ -161,6 +161,13 @@ public function getSubmitid(): ?int #[Serializer\Exclude] private Collection $judging_runs; + /** + * @var Collection + */ + #[ORM\OneToMany(mappedBy: 'judgetask', targetEntity: GenericTask::class)] + #[Serializer\Exclude] + private Collection $generic_tasks; + #[ORM\ManyToOne(inversedBy: 'judgeTasks')] #[ORM\JoinColumn(name: 'versionid', referencedColumnName: 'versionid', onDelete: 'SET NULL')] #[Serializer\Exclude] From 6b42434419b262965bff0f46dd7e78f0a0e59eaa Mon Sep 17 00:00:00 2001 From: Michael Vasseur <14887731+vmcj@users.noreply.github.com> Date: Mon, 17 Nov 2025 22:05:57 +0100 Subject: [PATCH 14/16] Next step would be to render this output over multiple judgehosts For some of those tasks it makes sense to get a diff between all judgehosts to see how different those are. From 891ec2a7c5878eaf2a9821d8f1dc336af6179e84 Mon Sep 17 00:00:00 2001 From: Michael Vasseur <14887731+vmcj@users.noreply.github.com> Date: Mon, 17 Nov 2025 22:19:22 +0100 Subject: [PATCH 15/16] Fix PHPStan errors --- webapp/src/Controller/API/JudgehostController.php | 2 +- webapp/src/Entity/GenericTask.php | 2 +- webapp/src/Entity/JudgeTask.php | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/webapp/src/Controller/API/JudgehostController.php b/webapp/src/Controller/API/JudgehostController.php index 25f5d84571..e2cf3b712f 100644 --- a/webapp/src/Controller/API/JudgehostController.php +++ b/webapp/src/Controller/API/JudgehostController.php @@ -599,7 +599,7 @@ public function addGenericTaskOutput( $genericTask->setOutput($genericTaskOutput); $this->em->persist($genericTask); $this->em->persist($genericTaskOutput); - } elseif (count($genercTasks) !== 1) { + } elseif (count($genericTask) !== 1) { throw new BadRequestHttpException("There should be only one generic task for this judgetask."); } else { $genericTask = $genericTask->first(); diff --git a/webapp/src/Entity/GenericTask.php b/webapp/src/Entity/GenericTask.php index a081b69084..e9c0c1f86e 100644 --- a/webapp/src/Entity/GenericTask.php +++ b/webapp/src/Entity/GenericTask.php @@ -60,7 +60,7 @@ class GenericTask extends BaseApiEntity private string|float|null $startTime = null; /** - * @var Collection + * @var Collection * * We use a OneToMany instead of a OneToOne here, because otherwise this * relation will always be loaded. See the commit message of commit diff --git a/webapp/src/Entity/JudgeTask.php b/webapp/src/Entity/JudgeTask.php index 800ea1b193..e72d9cd762 100644 --- a/webapp/src/Entity/JudgeTask.php +++ b/webapp/src/Entity/JudgeTask.php @@ -383,7 +383,7 @@ public function getJudgingRuns(): Collection return $this->judging_runs; } - public function addGenericTask(GenericTask $genericTask): GenericTask + public function addGenericTask(GenericTask $genericTask): JudgeTask { $this->generic_tasks[] = $genericTask; return $this; From 66588864156c0de541a78778567787546f0ac7a5 Mon Sep 17 00:00:00 2001 From: Michael Vasseur <14887731+vmcj@users.noreply.github.com> Date: Mon, 17 Nov 2025 22:26:55 +0100 Subject: [PATCH 16/16] Simplify the default script --- sql/files/defaultdata/judgehost_info/run | 20 ++++---------------- 1 file changed, 4 insertions(+), 16 deletions(-) diff --git a/sql/files/defaultdata/judgehost_info/run b/sql/files/defaultdata/judgehost_info/run index dac31e7080..9b0792f868 100755 --- a/sql/files/defaultdata/judgehost_info/run +++ b/sql/files/defaultdata/judgehost_info/run @@ -10,14 +10,10 @@ output_file="$1" # Posted by fjarlq, modified by community. See post 'Timeline' for change history # Retrieved 2025-11-15, License - CC BY-SA 4.0 -if [ "${SHELL#*"bash"}" != "${SHELL}" ]; then - exec &>> "${output_file}" -else - # We can't use &>> as it's not POSIX, - # this does introduce a racecondition - exec 1>> "${output_file}" - exec 2>> "${output_file}" -fi +# We can't use &>> as it's not POSIX, +# this does introduce a racecondition +exec 1>> "${output_file}" +exec 2>> "${output_file}" # Generic linux/distro information uname -a @@ -34,12 +30,4 @@ cat /proc/cpuinfo free -h cat /proc/meminfo -if [ command -v hwinfo ]; then - hwinfo --short -fi - -if [ command -v inxi ]; then - inxi -Fx -fi - exit 0