Script 'mail_helper' called by obssrc Hello community, here is the log from the commit of package openQA for openSUSE:Factory checked in at 2026-09-22 15:50:44 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ Comparing /work/SRC/openSUSE:Factory/openQA (Old) and /work/SRC/openSUSE:Factory/.openQA.new.383539 (New) ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Package is "openQA" Tue Sep 22 15:50:44 2026 rev:880 rq:1379510 version:5.1790003470.1faa32c9 Changes: -------- --- /work/SRC/openSUSE:Factory/openQA/openQA.changes 2026-09-19 22:24:52.623949874 +0200 +++ /work/SRC/openSUSE:Factory/.openQA.new.383539/openQA.changes 2026-09-22 15:51:02.821821112 +0200 @@ -1,0 +2,17 @@ +Mon Sep 21 16:32:24 UTC 2026 - [email protected] + +- Update to version 5.1790003470.1faa32c9: + * feat(workers): Add worker reservation system + * fix: Fix typo in NGINX config + * fix: Include missing `PATCH` in rate limiting config example + * fix(test): print developer console log on timeout failure + * refactor(test): isolate concurrent full-stack test databases + * feat: Add expand/collapse all rows buttons + +------------------------------------------------------------------- +Mon Sep 21 08:58:40 UTC 2026 - [email protected] + +- Update to version 5.1789981105.050ec7ba: + * refactor(test): isolate concurrent full-stack test databases + +------------------------------------------------------------------- Old: ---- openQA-5.1789770186.a0963169.obscpio New: ---- openQA-5.1790003470.1faa32c9.obscpio ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ Other differences: ------------------ ++++++ openQA-client-test.spec ++++++ --- /var/tmp/diff_new_pack.2MzyDe/_old 2026-09-22 15:51:04.576894332 +0200 +++ /var/tmp/diff_new_pack.2MzyDe/_new 2026-09-22 15:51:04.578894416 +0200 @@ -18,7 +18,7 @@ %define short_name openQA-client Name: %{short_name}-test -Version: 5.1789770186.a0963169 +Version: 5.1790003470.1faa32c9 Release: 0 Summary: Test package for %{short_name} License: GPL-2.0-or-later ++++++ openQA-devel-test.spec ++++++ --- /var/tmp/diff_new_pack.2MzyDe/_old 2026-09-22 15:51:04.615895959 +0200 +++ /var/tmp/diff_new_pack.2MzyDe/_new 2026-09-22 15:51:04.617896043 +0200 @@ -18,7 +18,7 @@ %define short_name openQA-devel Name: %{short_name}-test -Version: 5.1789770186.a0963169 +Version: 5.1790003470.1faa32c9 Release: 0 Summary: Test package for %{short_name} License: GPL-2.0-or-later ++++++ openQA-test.spec ++++++ --- /var/tmp/diff_new_pack.2MzyDe/_old 2026-09-22 15:51:04.646897252 +0200 +++ /var/tmp/diff_new_pack.2MzyDe/_new 2026-09-22 15:51:04.648897336 +0200 @@ -18,7 +18,7 @@ %define short_name openQA Name: %{short_name}-test -Version: 5.1789770186.a0963169 +Version: 5.1790003470.1faa32c9 Release: 0 Summary: Test package for openQA License: GPL-2.0-or-later ++++++ openQA-worker-test.spec ++++++ --- /var/tmp/diff_new_pack.2MzyDe/_old 2026-09-22 15:51:04.679898629 +0200 +++ /var/tmp/diff_new_pack.2MzyDe/_new 2026-09-22 15:51:04.684898838 +0200 @@ -18,7 +18,7 @@ %define short_name openQA-worker Name: %{short_name}-test -Version: 5.1789770186.a0963169 +Version: 5.1790003470.1faa32c9 Release: 0 Summary: Test package for %{short_name} License: GPL-2.0-or-later ++++++ openQA.spec ++++++ --- /var/tmp/diff_new_pack.2MzyDe/_old 2026-09-22 15:51:04.718900257 +0200 +++ /var/tmp/diff_new_pack.2MzyDe/_new 2026-09-22 15:51:04.720900340 +0200 @@ -104,7 +104,7 @@ %define devel_requires %devel_no_selenium_requires chromedriver Name: openQA -Version: 5.1789770186.a0963169 +Version: 5.1790003470.1faa32c9 Release: 0 Summary: Framework for automated system-level testing (web-frontend, scheduler and tools) Group: Development/Tools/Other ++++++ openQA-5.1789770186.a0963169.obscpio -> openQA-5.1790003470.1faa32c9.obscpio ++++++ diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/openQA-5.1789770186.a0963169/assets/javascripts/admin_worker.js new/openQA-5.1790003470.1faa32c9/assets/javascripts/admin_worker.js --- old/openQA-5.1789770186.a0963169/assets/javascripts/admin_worker.js 2026-09-19 00:23:06.000000000 +0200 +++ new/openQA-5.1790003470.1faa32c9/assets/javascripts/admin_worker.js 2026-09-21 17:11:10.000000000 +0200 @@ -20,6 +20,14 @@ $('#previous_jobs_filter').hide(); } +// a reserved worker running a job is labelled "Working (Reserved)" and matches both filters on purpose +const WORKER_STATUS_FILTERS = ['Idle', 'Offline', 'Working', 'Unavailable', 'Reserved']; +const DEFAULT_WORKER_STATUS_FILTER = 'Idle'; + +function filterWorkerStatus(column, status) { + column.search(status ? '\\b' + status + '\\b' : '', true, false).draw(); +} + function loadWorkerTable() { const table = $('#workers').DataTable({ initComplete: function () { @@ -27,29 +35,16 @@ .columns() .every(function () { const column = this; - const colheader = this.header(); - const title = $(colheader).text().trim(); - if (title !== 'Status') { - return false; - } - - const select = $('<select id="workers_online"><option value="">All</option></select>') + if ($(this.header()).text().trim() !== 'Status') return; + const options = WORKER_STATUS_FILTERS.map(s => `<option value="${s}">${s}</option>`).join(''); + $(`<select id="workers_online"><option value="">All</option>${options}</select>`) .appendTo($(column.header()).empty()) .on('change', function () { - const val = $.fn.dataTable.util.escapeRegex($(this).val()); - column - // .search( val ? '^'+val+'$' : '', true, false ) - .search(val ? val : '', true, false) - .draw(); - }); - - select.append('<option value="Idle">Idle</option>'); - select.append('<option value="Offline">Offline</option>'); - select.append('<option value="Working">Working</option>'); - select.append('<option value="Unavailable">Unavailable</option>'); - select.val('Idle'); + filterWorkerStatus(column, $(this).val()); + }) + .val(DEFAULT_WORKER_STATUS_FILTER); + filterWorkerStatus(column, DEFAULT_WORKER_STATUS_FILTER); }); - this.api().column(4).search('Idle').draw(); } }); @@ -65,19 +60,67 @@ }); } -function deleteWorker(deleteBtn) { - const post_url = $(deleteBtn).attr('post_delete_url'); - fetchWithCSRF(post_url, {method: 'DELETE'}) - .then(response => { - return response.json(); - }) +function requestWorkerChange(url, options, failureMessage, onSuccess) { + fetchWithCSRF(url, options) + .then(response => response.json()) .then(response => { if (response.error) throw response.error; - const table = $('#workers').DataTable(); - table.row($(deleteBtn).parents('tr')).remove().draw(); - addFlash('info', response.message); + onSuccess(response); }) .catch(error => { - addFlash('danger', "The worker couldn't be deleted: " + error); + addFlash('danger', failureMessage + error); }); } + +function reservationUrl(workerId) { + return '/api/v1/workers/' + workerId + '/reservation'; +} + +function openReserveModal(reserveBtn) { + const duration = document.getElementById('reserveWorkerDuration'); + document.getElementById('reserveWorkerId').value = reserveBtn.dataset.workerId; + document.getElementById('reserveWorkerName').value = reserveBtn.dataset.workerName; + document.getElementById('reserveWorkerComment').value = ''; + duration.value = duration.dataset.defaultDuration; + const force = document.getElementById('reserveWorkerForce'); + if (force) force.checked = false; + new bootstrap.Modal(document.getElementById('reserveWorkerModal')).show(); +} + +function submitReserve(event) { + event.preventDefault(); + const force = document.getElementById('reserveWorkerForce'); + const body = new URLSearchParams({ + comment: document.getElementById('reserveWorkerComment').value, + duration: document.getElementById('reserveWorkerDuration').value, + force: force && force.checked ? 1 : 0 + }); + const options = {method: 'POST', headers: {'Content-Type': 'application/x-www-form-urlencoded'}, body}; + requestWorkerChange( + reservationUrl(document.getElementById('reserveWorkerId').value), + options, + "The worker couldn't be reserved: ", + () => window.location.reload() + ); +} + +function releaseWorker(releaseBtn) { + requestWorkerChange( + reservationUrl(releaseBtn.dataset.workerId), + {method: 'DELETE'}, + "The worker reservation couldn't be released: ", + () => window.location.reload() + ); +} + +function deleteWorker(deleteBtn) { + requestWorkerChange( + $(deleteBtn).attr('post_delete_url'), + {method: 'DELETE'}, + "The worker couldn't be deleted: ", + response => { + $('#workers').DataTable().row($(deleteBtn).parents('tr')).remove().draw(); + addFlash('info', response.message); + } + ); +} diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/openQA-5.1789770186.a0963169/assets/javascripts/test_result.js new/openQA-5.1790003470.1faa32c9/assets/javascripts/test_result.js --- old/openQA-5.1789770186.a0963169/assets/javascripts/test_result.js 2026-09-19 00:23:06.000000000 +0200 +++ new/openQA-5.1790003470.1faa32c9/assets/javascripts/test_result.js 2026-09-21 17:11:10.000000000 +0200 @@ -946,11 +946,42 @@ detailsNameFilter.on('keyup', applyFilterChanges); detailsFailedOnlyFilter.on('change', applyFilterChanges); - $('.details-filter-toggle').on('click', event => { - event.preventDefault(); - detailsFilter.toggleClass('hidden'); - applyFilterChanges(); - }); + const toggleAllRows = collapse => { + document.querySelectorAll('button.logview_expand_btn').forEach(btn => { + const resultTd = btn.closest('td'); + if (resultTd && (resultTd.getAttribute('mode') === 'log') === collapse) { + showTextBoxes(btn); + } + }); + }; + + const expandAllRows = document.getElementById('expand-all-rows'); + if (expandAllRows) { + expandAllRows.addEventListener('click', event => { + event.preventDefault(); + toggleAllRows(false); + }); + } + + const collapseAllRows = document.getElementById('collapse-all-rows'); + if (collapseAllRows) { + collapseAllRows.addEventListener('click', event => { + event.preventDefault(); + toggleAllRows(true); + }); + } + + const detailsFilterToggle = document.getElementById('details-filter-toggle'); + if (detailsFilterToggle) { + detailsFilterToggle.addEventListener('click', event => { + event.preventDefault(); + const detailsFilterElement = document.getElementById('details-filter'); + if (detailsFilterElement) { + detailsFilterElement.classList.toggle('hidden'); + } + applyFilterChanges(); + }); + } tabConfig._hasFilterHandlers = true; } diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/openQA-5.1789770186.a0963169/assets/stylesheets/test-details.scss new/openQA-5.1790003470.1faa32c9/assets/stylesheets/test-details.scss --- old/openQA-5.1789770186.a0963169/assets/stylesheets/test-details.scss 2026-09-19 00:23:06.000000000 +0200 +++ new/openQA-5.1790003470.1faa32c9/assets/stylesheets/test-details.scss 2026-09-21 17:11:10.000000000 +0200 @@ -467,10 +467,14 @@ display: inline-block; float: right; margin-top: -50px; + a { + display: inline-block; + margin-left: 10px; + } } #result_tabs { // ensure filter icon and last .nav-item for tabs don't overlap - padding-right: 40px; + padding-right: 100px; } // hide all tabs (actually their nav items) by default; they will be activated via JavaScript as needed diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/openQA-5.1789770186.a0963169/docs/Installing.md new/openQA-5.1790003470.1faa32c9/docs/Installing.md --- old/openQA-5.1789770186.a0963169/docs/Installing.md 2026-09-19 00:23:06.000000000 +0200 +++ new/openQA-5.1790003470.1faa32c9/docs/Installing.md 2026-09-21 17:11:10.000000000 +0200 @@ -1548,7 +1548,7 @@ - asset_register asset_delete - Workers: - - worker_register command_enqueue + - worker_register worker_delete worker_reserve worker_release command_enqueue - Jobs: - iso_create iso_delete iso_cancel diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/openQA-5.1789770186.a0963169/docs/UsersGuide.md new/openQA-5.1790003470.1faa32c9/docs/UsersGuide.md --- old/openQA-5.1789770186.a0963169/docs/UsersGuide.md 2026-09-19 00:23:06.000000000 +0200 +++ new/openQA-5.1790003470.1faa32c9/docs/UsersGuide.md 2026-09-21 17:11:10.000000000 +0200 @@ -1086,6 +1086,31 @@ If you need the literal string `<<` (for example as a value in the job settings), you have to quote it. +## Reserving workers + +Operators can reserve individual worker instances to take them out of the +scheduling rotation, for example for maintenance or manual testing. Reserving a +worker does not interrupt the job it is currently running, it only prevents new +jobs from being assigned. A reservation expires automatically after its +duration, releasing the worker again. + +Reservations are managed in the workers table of the admin area or via the REST +API: + +```bash +openqa-cli api -X POST workers/<id>/reservation comment="Manual testing" duration=5h +openqa-cli api -X DELETE workers/<id>/reservation +``` + +The duration accepts a plain number of seconds or a value with a unit suffix +such as `30m`, `5h` or `2d`. Only admins may use `0` for an indefinite +reservation, override a reservation held by somebody else via `force=1` or +release a reservation owned by somebody else. + +The comment requirement, the default duration and the maximum durations for +operators and admins are configured in the `[worker_reservation]` section of +`/etc/openqa/openqa.ini`. + <a id="rest_api"></a> ## Use of the REST API diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/openQA-5.1789770186.a0963169/etc/nginx/vhosts.d/openqa-assets.inc new/openQA-5.1790003470.1faa32c9/etc/nginx/vhosts.d/openqa-assets.inc --- old/openQA-5.1789770186.a0963169/etc/nginx/vhosts.d/openqa-assets.inc 2026-09-19 00:23:06.000000000 +0200 +++ new/openQA-5.1790003470.1faa32c9/etc/nginx/vhosts.d/openqa-assets.inc 2026-09-21 17:11:10.000000000 +0200 @@ -6,7 +6,7 @@ sendfile on; sendfile_max_chunk 1m; -# Enforce download of assets so HTML assets cannot highjack session +# Enforce download of assets so HTML assets cannot hijack session # note: Can be disabled when using the alternative of making a redirect to a # different domain mentioned in "openqa-locations.inc". add_header Content-Disposition 'attachment; filename="$1"'; diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/openQA-5.1789770186.a0963169/etc/nginx/vhosts.d/openqa-endpoints.inc new/openQA-5.1790003470.1faa32c9/etc/nginx/vhosts.d/openqa-endpoints.inc --- old/openQA-5.1789770186.a0963169/etc/nginx/vhosts.d/openqa-endpoints.inc 2026-09-19 00:23:06.000000000 +0200 +++ new/openQA-5.1790003470.1faa32c9/etc/nginx/vhosts.d/openqa-endpoints.inc 2026-09-21 17:11:10.000000000 +0200 @@ -70,7 +70,7 @@ # # Apply optional rate limiting (needs zone definition, see `openqa-limiting.inc`) # # Limit regular requests via webui_limit # limit_req zone=webui_limit burst=100 nodelay; -# # Limit POST/PUT/PATCH/DELETE requests via webui_light_limit so many worker slots can do uploads in parallel +# # Limit modifying requests via webui_light_limit so many worker slots can do uploads in parallel # limit_req zone=webui_light_limit burst=500 nodelay; # limit_req_status 429; # add_header Retry-After 1 always; diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/openQA-5.1789770186.a0963169/etc/nginx/vhosts.d/openqa-limiting.inc new/openQA-5.1790003470.1faa32c9/etc/nginx/vhosts.d/openqa-limiting.inc --- old/openQA-5.1789770186.a0963169/etc/nginx/vhosts.d/openqa-limiting.inc 2026-09-19 00:23:06.000000000 +0200 +++ new/openQA-5.1790003470.1faa32c9/etc/nginx/vhosts.d/openqa-limiting.inc 2026-09-21 17:11:10.000000000 +0200 @@ -1,10 +1,10 @@ # Define rate limiting maps -# Map request method to a rate limiting key for webui_limit (empty for POST/PUT/DELETE, $binary_remote_addr otherwise) +# Map request method to a rate limiting key for webui_limit (empty for modifying requests, $binary_remote_addr otherwise) map $request_method $webui_limit_key { - ~^(POST|PUT|DELETE)$ ""; + ~^(POST|PUT|PATCH|DELETE)$ ""; default $binary_remote_addr; } -# Map request method to a rate limiting key for webui_light_limit ($binary_remote_addr for POST/PUT/PATCH/DELETE or lightweight routes, empty otherwise) +# Map request method to a rate limiting key for webui_light_limit ($binary_remote_addr for modifying requests or lightweight routes, empty otherwise) map "$request_method:$uri" $webui_light_limit_key { ~^(POST|PUT|PATCH|DELETE): $binary_remote_addr; ~^[^:]+:/api/v1/jobs/[0-9]+/(mutex|barrier|artefact) $binary_remote_addr; @@ -14,7 +14,7 @@ # Define rate limiting zones for endpoints of the openQA web application (10 MB zone allows ~160,000 IPs) # low limit for heavy routes (like search and investigation) limit_req_zone $binary_remote_addr zone=webui_heavy_limit:10m rate=10r/s; -# high limit for lightweight routes and all POST/PUT/PATCH/DELETE routes (very frequently used by workers, tests and scripts) +# high limit for lightweight routes and all for modifying requests (very frequently used by workers, tests and scripts) limit_req_zone $webui_light_limit_key zone=webui_light_limit:10m rate=250r/s; # medium limit per second for all other routes limit_req_zone $webui_limit_key zone=webui_limit:10m rate=50r/s; diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/openQA-5.1789770186.a0963169/etc/openqa/openqa.ini new/openQA-5.1790003470.1faa32c9/etc/openqa/openqa.ini --- old/openQA-5.1789770186.a0963169/etc/openqa/openqa.ini 2026-09-19 00:23:06.000000000 +0200 +++ new/openQA-5.1790003470.1faa32c9/etc/openqa/openqa.ini 2026-09-21 17:11:10.000000000 +0200 @@ -446,6 +446,16 @@ ## "${OPENQA_ARCHIVEDIR:-${OPENQA_BASEDIR:-/var/lib}/openqa/archive}/testresults" #archive_preserved_important_jobs = 0 +[worker_reservation] +## Default duration for a worker reservation (e.g. 5h, 2d, 3600) +#default_duration = 5h +## Maximum duration a worker reservation can have for operator users +#max_duration = 5d +## Maximum duration a worker reservation can have for admin users (0 = unlimited) +#admin_max_duration = 0 +## Whether a comment explaining the reservation is required (1 = yes, 0 = no) +#comment_required = 1 + [job_details_archive] ## Directory to store cached ZIP archives #job_details_archive_cache_dir = /var/lib/openqa/webui/cache/archives diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/openQA-5.1789770186.a0963169/lib/OpenQA/Scheduler/Model/Jobs.pm new/openQA-5.1790003470.1faa32c9/lib/OpenQA/Scheduler/Model/Jobs.pm --- old/openQA-5.1789770186.a0963169/lib/OpenQA/Scheduler/Model/Jobs.pm 2026-09-19 00:23:06.000000000 +0200 +++ new/openQA-5.1790003470.1faa32c9/lib/OpenQA/Scheduler/Model/Jobs.pm 2026-09-21 17:11:10.000000000 +0200 @@ -30,7 +30,10 @@ has dynamic_limit => sub { OpenQA::Scheduler::DynamicLimit->new }; sub determine_online_workers ($shuffle = 0) { - my @online_workers = grep { !$_->dead } OpenQA::Schema->singleton->resultset('Workers')->search( + my $workers = OpenQA::Schema->singleton->resultset('Workers'); + # reserved workers stay online but are taken out of the scheduling rotation + my $reserved = $workers->reserved_worker_ids; + my @online_workers = grep { !$_->dead && !$reserved->{$_->id} } $workers->search( { error => undef, 'properties.key' => 'WEBSOCKET_API_VERSION', diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/openQA-5.1789770186.a0963169/lib/OpenQA/Schema/Result/Workers.pm new/openQA-5.1790003470.1faa32c9/lib/OpenQA/Schema/Result/Workers.pm --- old/openQA-5.1789770186.a0963169/lib/OpenQA/Schema/Result/Workers.pm 2026-09-19 00:23:06.000000000 +0200 +++ new/openQA-5.1790003470.1faa32c9/lib/OpenQA/Schema/Result/Workers.pm 2026-09-21 17:11:10.000000000 +0200 @@ -12,6 +12,9 @@ use OpenQA::WebSockets::Client; use OpenQA::Constants qw(WORKER_API_COMMANDS DB_TIMESTAMP_ACCURACY VNC_PORT WORKER_CLASS_LIMIT_REGEX); use OpenQA::Jobs::Constants; +use OpenQA::Utils 'parse_duration'; +use OpenQA::WorkerReservation + qw(RESERVATION_PROPERTIES RESERVATION_TIMESTAMP_FORMAT reservation_active reservation_error); use Mojo::JSON qw(encode_json decode_json); use List::Util qw(any); use Time::Seconds; @@ -114,6 +117,89 @@ } } +sub _reservation_properties ($self) { + return {map { $_->key => $_->value } $self->properties->search({key => {-in => [RESERVATION_PROPERTIES]}})->all}; +} + +sub is_reserved ($self) { + my $properties = $self->_reservation_properties; + return reservation_active($properties->{RESERVED_BY_ID}, $properties->{RESERVED_T_EXPIRES}); +} + +sub _username_for ($self, $user_id) { + my $user = $self->result_source->schema->resultset('Users')->find($user_id); + return $user ? $user->username : 'unknown'; +} + +sub _reserved_by_name ($self) { $self->_username_for($self->_reservation_properties->{RESERVED_BY_ID}) } + +sub _iso_timestamp ($epoch) { + $epoch ? DateTime->from_epoch(epoch => $epoch, time_zone => 'UTC')->strftime(RESERVATION_TIMESTAMP_FORMAT) : undef; +} + +sub _reservation_info ($self, $properties) { + { + user => $self->_username_for($properties->{RESERVED_BY_ID}), + comment => $properties->{RESERVED_COMMENT}, + t_created => _iso_timestamp($properties->{RESERVED_T_CREATED}), + t_expires => _iso_timestamp($properties->{RESERVED_T_EXPIRES}), + }; +} + +# the active reservation in the form exposed via the API, undef if the worker is not reserved; +# expired reservations are reported as absent so that stale properties are never observable +sub reservation ($self) { + my $properties = $self->_reservation_properties; + return undef unless reservation_active($properties->{RESERVED_BY_ID}, $properties->{RESERVED_T_EXPIRES}); + return $self->_reservation_info($properties); +} + +# returns the duration in seconds a reservation may last for, 0 meaning unlimited +sub _reservation_duration ($duration, $is_admin) { + my $config = OpenQA::App->singleton->config->{worker_reservation}; + my $seconds = defined $duration ? parse_duration($duration) : $config->{default_duration}; + die reservation_error(invalid => "Invalid duration format '$duration'") unless defined $seconds; + die reservation_error(forbidden => 'Indefinite reservations are only allowed for admins') + if $seconds == 0 && !$is_admin; + my $limit = $is_admin ? $config->{admin_max_duration} : $config->{max_duration}; + die reservation_error(invalid => "Duration of ${seconds}s exceeds the maximum of ${limit}s") + if $limit > 0 && $seconds > $limit; + return $seconds; +} + +sub reserve ($self, $user, $comment = undef, $duration = undef, $force = 0) { + my $is_admin = $user->is_admin; + die reservation_error(forbidden => 'Insufficient permissions to reserve a worker') + unless $is_admin || $user->is_operator; + die reservation_error(invalid => 'A comment is required for worker reservation') + if OpenQA::App->singleton->config->{worker_reservation}->{comment_required} + && (!defined $comment || $comment =~ /^\s*$/); + my $seconds = _reservation_duration($duration, $is_admin); + + # the conflict check and all property writes must be atomic, a partially written reservation + # without expiry would keep the worker reserved forever + my $now = time; + $self->result_source->schema->txn_do( + sub { + die reservation_error(conflict => 'Worker is already reserved by ' . $self->_reserved_by_name) + if $self->is_reserved && !($force && $is_admin); + $self->set_property(RESERVED_BY_ID => $user->id); + $self->set_property(RESERVED_COMMENT => $comment); + $self->set_property(RESERVED_T_CREATED => $now); + $self->set_property(RESERVED_T_EXPIRES => $seconds == 0 ? 0 : $now + $seconds); + }); + return $self; +} + +sub release ($self, $user) { + die reservation_error(invalid => 'Worker is not reserved') unless $self->is_reserved; + die reservation_error( + forbidden => 'Insufficient permissions to release reservation owned by ' . $self->_reserved_by_name) + unless $user->is_admin || $self->_reservation_properties->{RESERVED_BY_ID} == $user->id; + $self->delete_properties([RESERVATION_PROPERTIES]); + return $self; +} + sub dead ($self) { return 1 unless my $t_seen = $self->t_seen; my $dt = DateTime->now(time_zone => 'UTC'); @@ -143,10 +229,16 @@ $r->name if $r; } -sub status ($self) { +sub is_free ($self) { + return !$self->job_id && !defined $self->error; +} + +# $is_reserved can be passed by callers which already know the reservation state to save queries +sub status ($self, $is_reserved = undef) { return 'dead' if ($self->dead); return 'broken' if ($self->error); return 'running' if ($self->job); + return 'reserved' if ($is_reserved // $self->is_reserved); return 'idle'; } @@ -158,17 +250,18 @@ } sub info ($self) { + my %properties = map { $_->key => $_->value } $self->properties->all; + my $reserved = reservation_active($properties{RESERVED_BY_ID}, $properties{RESERVED_T_EXPIRES}); my $settings = { id => $self->id, host => $self->host, instance => $self->instance, - status => $self->status, + status => $self->status($reserved), error => $self->error, }; - $settings->{properties} = {}; - for my $p ($self->properties->all) { - $settings->{properties}->{$p->key} = $p->value; - } + $settings->{reservation} = $self->_reservation_info(\%properties) if $reserved; + # the reservation is exposed in structured form only, not as raw internal properties + $settings->{properties} = {%properties{grep { !/^RESERVED_/ } keys %properties}}; # puts job id in status, otherwise is idle my $job = $self->job; if ($job) { diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/openQA-5.1789770186.a0963169/lib/OpenQA/Schema/ResultSet/Workers.pm new/openQA-5.1790003470.1faa32c9/lib/OpenQA/Schema/ResultSet/Workers.pm --- old/openQA-5.1789770186.a0963169/lib/OpenQA/Schema/ResultSet/Workers.pm 2026-09-19 00:23:06.000000000 +0200 +++ new/openQA-5.1790003470.1faa32c9/lib/OpenQA/Schema/ResultSet/Workers.pm 2026-09-21 17:11:10.000000000 +0200 @@ -4,19 +4,34 @@ package OpenQA::Schema::ResultSet::Workers; use Mojo::Base 'DBIx::Class::ResultSet', -signatures; +use OpenQA::WorkerReservation qw(RESERVATION_PROPERTIES reservation_active); + +# maps the id of every worker holding a non-expired reservation to 1, using a single query so that +# callers filtering many workers do not have to query the properties of each of them individually +sub reserved_worker_ids ($self) { + my $properties = $self->result_source->schema->resultset('WorkerProperties') + ->search({key => {-in => [RESERVATION_PROPERTIES]}}, {columns => [qw(worker_id key value)]}); + my (%owner, %expires); + while (my $property = $properties->next) { + my $key = $property->key; + $owner{$property->worker_id} = $property->value if $key eq 'RESERVED_BY_ID'; + $expires{$property->worker_id} = $property->value if $key eq 'RESERVED_T_EXPIRES'; + } + return {map { $_ => 1 } grep { reservation_active($owner{$_}, $expires{$_}) } keys %owner}; +} + sub stats ($self) { my $total = $self->count; - my $total_online = grep { !$_->dead } $self->all(); - my $free_active_workers = grep { !$_->dead } $self->search({job_id => undef, error => undef})->all(); - my $free_broken_workers = grep { !$_->dead } $self->search({job_id => undef, error => {'!=' => undef}})->all(); - my $busy_workers = grep { !$_->dead } $self->search({job_id => {'!=' => undef}})->all(); + my @online = grep { !$_->dead } $self->all; + my $reserved = $self->reserved_worker_ids; return { total => $total, - total_online => $total_online, - free_active_workers => $free_active_workers, - free_broken_workers => $free_broken_workers, - busy_workers => $busy_workers, + total_online => scalar @online, + free_active_workers => scalar(grep { $_->is_free && !$reserved->{$_->id} } @online), + free_broken_workers => scalar(grep { !$_->job_id && defined $_->error } @online), + busy_workers => scalar(grep { $_->job_id } @online), + reserved_workers => scalar(grep { $_->is_free && $reserved->{$_->id} } @online), }; } diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/openQA-5.1789770186.a0963169/lib/OpenQA/Setup.pm new/openQA-5.1790003470.1faa32c9/lib/OpenQA/Setup.pm --- old/openQA-5.1789770186.a0963169/lib/OpenQA/Setup.pm 2026-09-19 00:23:06.000000000 +0200 +++ new/openQA-5.1790003470.1faa32c9/lib/OpenQA/Setup.pm 2026-09-21 17:11:10.000000000 +0200 @@ -27,6 +27,10 @@ my %CARRY_OVER_DEFAULTS = (lookup_depth => 10, state_changes_limit => 3); sub carry_over_defaults () { \%CARRY_OVER_DEFAULTS } +# durations in seconds, admin_max_duration 0 means unlimited +use constant WORKER_RESERVATION_DURATION_DEFAULTS => + {default_duration => 5 * ONE_HOUR, max_duration => 5 * ONE_DAY, admin_max_duration => 0}; + sub _read_config_file ($config, $config_file, $defaults, $mode_defaults) { for my $section (sort keys %$defaults) { my $section_defaults = $defaults->{$section}; @@ -326,6 +330,7 @@ archiving => { archive_preserved_important_jobs => 0, }, + worker_reservation => {%{+WORKER_RESERVATION_DURATION_DEFAULTS}, comment_required => 1}, job_details_archive => { job_details_archive_cache_dir => undef, job_details_archive_cache_limit_gb => 5, @@ -395,12 +400,27 @@ my $results = delete $global_config->{parallel_children_collapsable_results}; $global_config->{parallel_children_collapsable_results_sel} = ' .status' . (join '', map { ":not(.result_$_)" } split /\s+/, $results); + _validate_worker_reservation_config($app); _validate_worker_timeout($app); _validate_security_policy($app, $global_config); _set_default_storage_durations($_) for $config->{default_group_limits}, $config->{no_group_limits}; return $config; } +sub _validate_worker_reservation_config ($app) { + my $cfg = $app->config->{worker_reservation}; + my $defaults = WORKER_RESERVATION_DURATION_DEFAULTS; + for my $key (sort keys %$defaults) { + my $parsed = parse_duration($cfg->{$key}); + $app->log->warn("Invalid worker_reservation $key specified, defaulting to $defaults->{$key} seconds") + unless defined $parsed; + $cfg->{$key} = $parsed // $defaults->{$key}; + } + return undef if ($cfg->{comment_required} // '') =~ /^[01]$/; + $cfg->{comment_required} = 1; + $app->log->warn('Invalid worker_reservation comment_required specified, defaulting to 1'); +} + sub _validate_worker_timeout ($app) { my $global_config = $app->config->{global}; my $configured_worker_timeout = $global_config->{worker_timeout}; diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/openQA-5.1789770186.a0963169/lib/OpenQA/Utils.pm new/openQA-5.1790003470.1faa32c9/lib/OpenQA/Utils.pm --- old/openQA-5.1789770186.a0963169/lib/OpenQA/Utils.pm 2026-09-19 00:23:06.000000000 +0200 +++ new/openQA-5.1790003470.1faa32c9/lib/OpenQA/Utils.pm 2026-09-21 17:11:10.000000000 +0200 @@ -95,6 +95,10 @@ use constant RANDOM_STRING_DEFAULT_LENGTH => 16; use constant DEFAULT_OPENQA_BASE_PORT => 9526; +use constant DURATION_UNITS => {d => ONE_DAY, h => ONE_HOUR, m => ONE_MINUTE, s => 1}; +# ~317 years, high enough for any real duration and low enough to stay within integer precision +use constant MAX_DURATION_IN_SECONDS => 10_000_000_000; + our @EXPORT = ## no critic (Modules::ProhibitAutomaticExportation) qw( UNCONSTRAINED_BUGREF_REGEX @@ -146,6 +150,7 @@ service_port raw_service_port change_sec_to_word + parse_duration find_video_files fix_top_level_help looks_like_url_with_scheme @@ -884,22 +889,25 @@ sub change_sec_to_word ($second = undef) { return undef unless $second; return undef if ($second !~ /^[[:digit:]]+$/); - my %time_numbers = ( - d => ONE_DAY, - h => ONE_HOUR, - m => ONE_MINUTE, - s => 1 - ); + my $units = DURATION_UNITS; my $time_word = ''; for my $key (qw(d h m s)) { - $time_word .= int($second / $time_numbers{$key}) . $key . ' ' - if (int($second / $time_numbers{$key})); - $second = int($second % $time_numbers{$key}); + $time_word .= int($second / $units->{$key}) . $key . ' ' + if (int($second / $units->{$key})); + $second = int($second % $units->{$key}); } $time_word =~ s/\s$//g; return $time_word; } +# converts a duration like '5h', '2d' or a plain number of seconds into seconds, undef if unparsable +sub parse_duration ($str) { + return undef unless defined $str; + return undef unless my ($num, $unit) = $str =~ /^(\d+)([smhd]?)$/i; + my $seconds = $num * ($unit ? DURATION_UNITS->{lc $unit} : 1); + return $seconds > MAX_DURATION_IN_SECONDS ? undef : $seconds; +} + sub find_video_files ($dir) { path($dir)->list_tree->grep(VIDEO_FILE_NAME_REGEX) } # workaround https://github.com/mojolicious/mojo/issues/1629 diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/openQA-5.1789770186.a0963169/lib/OpenQA/WebAPI/Controller/API/V1/Worker.pm new/openQA-5.1790003470.1faa32c9/lib/OpenQA/WebAPI/Controller/API/V1/Worker.pm --- old/openQA-5.1789770186.a0963169/lib/OpenQA/WebAPI/Controller/API/V1/Worker.pm 2026-09-19 00:23:06.000000000 +0200 +++ new/openQA-5.1790003470.1faa32c9/lib/OpenQA/WebAPI/Controller/API/V1/Worker.pm 2026-09-21 17:11:10.000000000 +0200 @@ -13,6 +13,7 @@ use List::Util qw(min); use Feature::Compat::Try; use OpenQA::Constants 'WEBSOCKET_API_VERSION'; +use OpenQA::WorkerReservation 'reservation_error_status'; =pod @@ -46,6 +47,7 @@ my $validation = $self->validation; $validation->optional('limit')->num; $validation->optional('offset')->num; + $validation->optional('reserved')->num(0, 1); return $self->reply->validation_error({format => 'json'}) if $validation->has_error; my $limits = OpenQA::App->singleton->config->{misc_limits}; @@ -53,19 +55,17 @@ = min($limits->{admin_table_max_limit}, $validation->param('limit') // $limits->{admin_table_default_limit}); my $offset = $validation->param('offset') // 0; - my @all = $self->schema->resultset('Workers')->search({}, {rows => $limit + 1, offset => $offset})->all; - - # Pagination - pop @all if my $has_more = @all > $limit; + my $workers = $self->schema->resultset('Workers'); + my $reserved_param = $validation->param('reserved'); + my $condition + = defined $reserved_param + ? {id => {($reserved_param ? '-in' : '-not_in') => [keys %{$workers->reserved_worker_ids}]}} + : {}; + my @paged = $workers->search($condition, {rows => $limit + 1, offset => $offset, order_by => 'id'})->all; + pop @paged if my $has_more = @paged > $limit; $self->pagination_links_header($limit, $offset, $has_more); - my $ret = []; - for my $worker (@all) { - next unless $worker->id; - push @$ret, $worker->info; - } - - $self->render(json => {workers => $ret}); + $self->render(json => {workers => [map { $_->info } @paged]}); } =over 4 @@ -245,6 +245,9 @@ if (!$worker) { return $self->render(json => {error => 'Worker not found.'}, status => 404); } + if ($worker->is_reserved && !($self->param('force') && $self->current_user->is_admin)) { + return $self->render(json => {error => 'Cannot delete a reserved worker.'}, status => 400); + } if ($worker->status ne 'dead' || $worker->unfinished_jobs->count) { $message = 'Worker ' . $worker->name . ' status is not offline.'; return $self->render(json => {error => $message}, status => 400); @@ -257,4 +260,72 @@ $self->render(json => {message => $message}); } +# returns the addressed worker or renders a 404, the routes are already restricted to operators +sub _reservation_worker ($self) { + my $worker = $self->schema->resultset('Workers')->find($self->param('workerid')); + $self->render(json => {error => 'Worker not found.'}, status => 404) unless $worker; + return $worker; +} + +# runs $action, renders the error of a failed reservation operation and returns whether it succeeded +sub _apply_reservation ($self, $action) { + try { $action->() } + catch ($e) { + $self->render(json => {error => "$e"}, status => reservation_error_status($e)); + return 0; + } + return 1; +} + +=over 4 + +=item reserve() + +Reserves a worker instance with a comment and a specified duration. + +=back + +=cut + +sub reserve ($self) { + return undef unless my $worker = $self->_reservation_worker; + my $user = $self->current_user; + my $comment = $self->param('comment'); + return undef + unless $self->_apply_reservation( + sub { $worker->reserve($user, $comment, $self->param('duration'), $self->param('force')) }); + + my $reservation = $worker->reservation; + $self->emit_event( + 'openqa_worker_reserve', + { + id => $worker->id, + name => $worker->name, + user => $user->username, + comment => $comment, + expires => $reservation->{t_expires}, + }); + $self->render( + json => {message => 'Worker ' . $worker->name . ' reserved successfully.', reservation => $reservation}); +} + +=over 4 + +=item release() + +Releases an existing worker reservation. + +=back + +=cut + +sub release ($self) { + return undef unless my $worker = $self->_reservation_worker; + my $user = $self->current_user; + return undef unless $self->_apply_reservation(sub { $worker->release($user) }); + + $self->emit_event('openqa_worker_release', {id => $worker->id, name => $worker->name, user => $user->username}); + $self->render(json => {message => 'Worker ' . $worker->name . ' reservation released successfully.'}); +} + 1; diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/openQA-5.1789770186.a0963169/lib/OpenQA/WebAPI/Controller/Admin/Workers.pm new/openQA-5.1790003470.1faa32c9/lib/OpenQA/WebAPI/Controller/Admin/Workers.pm --- old/openQA-5.1789770186.a0963169/lib/OpenQA/WebAPI/Controller/Admin/Workers.pm 2026-09-19 00:23:06.000000000 +0200 +++ new/openQA-5.1790003470.1faa32c9/lib/OpenQA/WebAPI/Controller/Admin/Workers.pm 2026-09-21 17:11:10.000000000 +0200 @@ -27,6 +27,10 @@ return $info; } +sub _reservation_default_duration ($self) { + change_sec_to_word($self->app->config->{worker_reservation}->{default_duration}); +} + sub index ($self) { my $workers_db = $self->schema->resultset('Workers'); my $worker_stats = $workers_db->stats; @@ -37,11 +41,13 @@ $workers{$w->name} = _extend_info($w); } $self->stash( + reservation_default_duration => $self->_reservation_default_duration, workers_online => $worker_stats->{total_online}, total => $worker_stats->{total}, workers_active_free => $worker_stats->{free_active_workers}, workers_broken_free => $worker_stats->{free_broken_workers}, workers_busy => $worker_stats->{busy_workers}, + workers_reserved => $worker_stats->{reserved_workers}, is_admin => !!$self->is_admin, workers => \%workers ); @@ -54,7 +60,7 @@ sub show ($self) { my $w = $self->schema->resultset('Workers')->find($self->param('worker_id')) or return $self->reply->not_found; - $self->stash(worker => _extend_info($w)); + $self->stash(worker => _extend_info($w), reservation_default_duration => $self->_reservation_default_duration); $self->render('admin/workers/show'); } diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/openQA-5.1789770186.a0963169/lib/OpenQA/WebAPI/Plugin/AuditLog.pm new/openQA-5.1790003470.1faa32c9/lib/OpenQA/WebAPI/Plugin/AuditLog.pm --- old/openQA-5.1789770186.a0963169/lib/OpenQA/WebAPI/Plugin/AuditLog.pm 2026-09-19 00:23:06.000000000 +0200 +++ new/openQA-5.1790003470.1faa32c9/lib/OpenQA/WebAPI/Plugin/AuditLog.pm 2026-09-21 17:11:10.000000000 +0200 @@ -17,7 +17,7 @@ my @comment_events = qw(comment_create comments_create comment_update comment_delete); my @asset_events = qw(asset_register asset_delete); my @iso_events = qw(iso_create iso_delete iso_cancel); -my @worker_events = qw(command_enqueue worker_register worker_delete); +my @worker_events = qw(command_enqueue worker_register worker_delete worker_reserve worker_release); my @needle_events = qw(needle_modify needle_delete); # disabled events: diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/openQA-5.1789770186.a0963169/lib/OpenQA/WebAPI.pm new/openQA-5.1790003470.1faa32c9/lib/OpenQA/WebAPI.pm --- old/openQA-5.1789770186.a0963169/lib/OpenQA/WebAPI.pm 2026-09-19 00:23:06.000000000 +0200 +++ new/openQA-5.1790003470.1faa32c9/lib/OpenQA/WebAPI.pm 2026-09-21 17:11:10.000000000 +0200 @@ -380,6 +380,8 @@ $api_ro->post('/workers')->name('apiv1_create_worker')->to('worker#create'); $api_public_r->any('/workers/<workerid:num>')->get('/')->name('apiv1_worker')->to('worker#show'); $api_ro->delete('/workers/<worker_id:num>')->name('apiv1_worker_delete')->to('worker#delete'); + $api_ro->post('/workers/<workerid:num>/reservation')->name('apiv1_worker_reserve')->to('worker#reserve'); + $api_ro->delete('/workers/<workerid:num>/reservation')->name('apiv1_worker_release')->to('worker#release'); # api/v1/mutex $api_r_job->post('/mutex')->name('apiv1_mutex_create')->to('locks#mutex_create'); diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/openQA-5.1789770186.a0963169/lib/OpenQA/WorkerReservation/Error.pm new/openQA-5.1790003470.1faa32c9/lib/OpenQA/WorkerReservation/Error.pm --- old/openQA-5.1789770186.a0963169/lib/OpenQA/WorkerReservation/Error.pm 1970-01-01 01:00:00.000000000 +0100 +++ new/openQA-5.1790003470.1faa32c9/lib/OpenQA/WorkerReservation/Error.pm 2026-09-21 17:11:10.000000000 +0200 @@ -0,0 +1,10 @@ +# Copyright SUSE LLC +# SPDX-License-Identifier: GPL-2.0-or-later + +package OpenQA::WorkerReservation::Error; +use Mojo::Base 'Mojo::Exception', -signatures; + +# one of the kinds listed in OpenQA::WorkerReservation::ERROR_STATUS +has 'kind'; + +1; diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/openQA-5.1789770186.a0963169/lib/OpenQA/WorkerReservation.pm new/openQA-5.1790003470.1faa32c9/lib/OpenQA/WorkerReservation.pm --- old/openQA-5.1789770186.a0963169/lib/OpenQA/WorkerReservation.pm 1970-01-01 01:00:00.000000000 +0100 +++ new/openQA-5.1790003470.1faa32c9/lib/OpenQA/WorkerReservation.pm 2026-09-21 17:11:10.000000000 +0200 @@ -0,0 +1,30 @@ +# Copyright SUSE LLC +# SPDX-License-Identifier: GPL-2.0-or-later + +package OpenQA::WorkerReservation; +use Mojo::Base -strict, -signatures; + +use Exporter 'import'; +use Scalar::Util 'blessed'; +use OpenQA::WorkerReservation::Error; + +our @EXPORT_OK = qw(RESERVATION_PROPERTIES RESERVATION_TIMESTAMP_FORMAT + reservation_active reservation_error reservation_error_status); + +# worker properties holding the reservation, reused to avoid a dedicated table and migration +use constant RESERVATION_PROPERTIES => qw(RESERVED_BY_ID RESERVED_COMMENT RESERVED_T_CREATED RESERVED_T_EXPIRES); +use constant RESERVATION_TIMESTAMP_FORMAT => '%Y-%m-%dT%H:%M:%SZ'; + +my %ERROR_STATUS = (invalid => 400, forbidden => 403, conflict => 409); + +# a reservation is active while an owner is assigned and the optional expiry has not passed yet +sub reservation_active ($owner_id, $expires_epoch) { !!($owner_id && (!$expires_epoch || $expires_epoch > time)) } + +sub reservation_error ($kind, $message) { OpenQA::WorkerReservation::Error->new($message)->kind($kind) } + +sub reservation_error_status ($error) { + my $kind = blessed $error && $error->isa('OpenQA::WorkerReservation::Error') ? $error->kind : undef; + return $ERROR_STATUS{$kind // 'invalid'} // $ERROR_STATUS{invalid}; +} + +1; diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/openQA-5.1789770186.a0963169/t/04-scheduler.t new/openQA-5.1790003470.1faa32c9/t/04-scheduler.t --- old/openQA-5.1789770186.a0963169/t/04-scheduler.t 2026-09-19 00:23:06.000000000 +0200 +++ new/openQA-5.1790003470.1faa32c9/t/04-scheduler.t 2026-09-21 17:11:10.000000000 +0200 @@ -850,4 +850,22 @@ } or diag $explain_slots->(); }; +subtest 'scheduler excludes reserved workers' => sub { + my $operator + = $schema->resultset('Users')->create({username => 'percival', is_operator => 1, feature_version => 0}); + my $worker = $workers->first; + my ($original_t_seen, $original_error) = ($worker->t_seen, $worker->error); + my $guard = scope_guard sub { $worker->update({t_seen => $original_t_seen, error => $original_error}) }; + $worker->update({t_seen => DateTime->now(time_zone => 'UTC'), error => undef}); + $worker->set_property(WEBSOCKET_API_VERSION => WEBSOCKET_API_VERSION); + my $is_online = sub { + scalar grep { $_->id == $worker->id } @{OpenQA::Scheduler::Model::Jobs::determine_online_workers(0)}; + }; + + $worker->reserve($operator, 'scheduled reservation', '1h'); + ok !$is_online->(), 'reserved worker is not returned as an online candidate by the scheduler'; + $worker->release($operator); + ok $is_online->(), 'released worker is returned again as an online candidate by the scheduler'; +}; + done_testing; diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/openQA-5.1789770186.a0963169/t/16-utils.t new/openQA-5.1790003470.1faa32c9/t/16-utils.t --- old/openQA-5.1789770186.a0963169/t/16-utils.t 2026-09-19 00:23:06.000000000 +0200 +++ new/openQA-5.1790003470.1faa32c9/t/16-utils.t 2026-09-21 17:11:10.000000000 +0200 @@ -569,4 +569,11 @@ is raw_service_port('webui', $port), "$port&fd=$fileno", 'reserved port mapped to fd form'; }; +subtest 'parse_duration' => sub { + my %seconds_for = ('0' => 0, '3600' => 3600, '5s' => 5, '10m' => 600, '2h' => 7200, '1d' => 86400, '5D' => 432000); + is parse_duration($_), $seconds_for{$_}, "duration '$_' is $seconds_for{$_} seconds" for sort keys %seconds_for; + my @unparsable = (undef, '', 'foo', '5x', '-1', '1.5h', '2 h', '99999999999999d'); + is parse_duration($_), undef, "unparsable duration '" . ($_ // 'undef') . "' returns undef" for @unparsable; +}; + done_testing; diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/openQA-5.1789770186.a0963169/t/33-developer_mode.t new/openQA-5.1790003470.1faa32c9/t/33-developer_mode.t --- old/openQA-5.1789770186.a0963169/t/33-developer_mode.t 2026-09-19 00:23:06.000000000 +0200 +++ new/openQA-5.1790003470.1faa32c9/t/33-developer_mode.t 2026-09-21 17:11:10.000000000 +0200 @@ -64,7 +64,7 @@ ok -d $resultdir, "resultdir \"$resultdir\" exists"; # setup database without fixtures and special admin users 'Demo' and 'otherdeveloper' -my $schema = OpenQA::Test::Database->new->create(schema_name => 'public', drop_schema => 1); +my $schema = OpenQA::Test::Database->new->create(drop_schema => 1); my $users = $schema->resultset('Users'); $users->create( { diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/openQA-5.1789770186.a0963169/t/38-workers-table.t new/openQA-5.1790003470.1faa32c9/t/38-workers-table.t --- old/openQA-5.1789770186.a0963169/t/38-workers-table.t 2026-09-19 00:23:06.000000000 +0200 +++ new/openQA-5.1790003470.1faa32c9/t/38-workers-table.t 2026-09-21 17:11:10.000000000 +0200 @@ -17,6 +17,9 @@ use OpenQA::Test::FakeWebSocketTransaction; use OpenQA::WebSockets::Client; use OpenQA::Test::Utils 'embed_server_for_testing'; +use OpenQA::WorkerReservation 'RESERVATION_PROPERTIES'; +use Mojo::Util 'scope_guard'; +use Time::Seconds; embed_server_for_testing( server_name => 'OpenQA::WebSockets', @@ -26,7 +29,7 @@ # init test case my $test_case = OpenQA::Test::Case->new; -$test_case->init_data(fixtures_glob => '01-jobs.pl 02-workers.pl'); +$test_case->init_data(fixtures_glob => '01-jobs.pl 02-workers.pl 03-users.pl'); my $t = Test::Mojo->new('OpenQA::WebAPI'); # get resultsets @@ -130,4 +133,88 @@ is $worker->vnc_argument, 'remotehost.foo.bar:5991', 'WORKER_HOSTNAME used if set'; }; +subtest 'worker reservation model' => sub { + my $worker = $workers->first; + $worker->delete_properties([RESERVATION_PROPERTIES]); + $worker->update({job_id => undef, error => undef, t_seen => DateTime->now(time_zone => 'UTC')}); + + my $users = $db->resultset('Users'); + my ($admin, $non_operator, $operator) = map { $users->find($_) } 99901, 99902, 99903; + my $other_operator = $users->create({username => 'galahad', is_operator => 1, feature_version => 0}); + + my @rejected = ( + [[$non_operator, 'valid comment', '1h'], qr/Insufficient permissions/, 'non-operator users'], + [[$operator, ' ', '1h'], qr/comment is required/, 'a blank comment'], + [[$operator, undef, '1h'], qr/comment is required/, 'a missing comment'], + [[$operator, 'valid comment', 'soon'], qr/Invalid duration format 'soon'/, 'an unparsable duration'], + [[$operator, 'valid comment', '10d'], qr/exceeds the maximum of 432000s/, 'a duration beyond the operator max'], + [[$operator, 'valid comment', '0'], qr/only allowed for admins/, 'an indefinite duration as operator'], + ); + throws_ok { $worker->reserve(@{$_->[0]}) } $_->[1], "refuse reservation with $_->[2]" for @rejected; + ok !$worker->is_reserved, 'worker is not reserved after all rejected attempts'; + is $worker->reservation, undef, 'unreserved worker reports no reservation'; + + $worker->reserve($operator, 'operator reservation', '2h'); + ok $worker->is_reserved, 'worker is considered reserved after a successful reserve call'; + is $worker->status, 'reserved', 'worker status reports reserved while the reservation is active'; + is $worker->reservation->{user}, $operator->username, 'reservation names the reserving operator'; + is $worker->reservation->{comment}, 'operator reservation', 'reservation comment matches what was passed'; + like $worker->reservation->{t_created}, qr/^\d{4}(-\d\d){2}T(\d\d:){2}\d\dZ$/, 'reserved at ISO 8601 timestamp'; + is $workers->stats->{reserved_workers}, 1, 'statistics count exactly one reserved worker'; + is_deeply [keys %{$workers->reserved_worker_ids}], [$worker->id], 'bulk lookup reports the reserved worker'; + ok !exists $worker->info->{properties}->{RESERVED_BY_ID}, 'raw reservation properties are not exposed via info'; + + my @precedence = (['running', job_id => 99937], ['broken', error => 'some error'], ['dead', t_seen => undef]); + for my $case (@precedence) { + my ($expected, $column, $value) = @$case; + my $original = $worker->get_column($column); + $worker->update({$column => $value}); + is $worker->discard_changes->status, $expected, "$expected state takes precedence over the reservation"; + $worker->update({$column => $original}); + $worker->discard_changes; + } + + throws_ok { $worker->reserve($other_operator, 'other reservation', '1h') } + qr/already reserved by percival/, 'refuse overlapping reservations and name the current owner'; + throws_ok { $worker->reserve($admin, 'admin without force', '1h') } + qr/already reserved/, 'refuse admin reservations on reserved workers unless forced'; + + $worker->reserve($admin, 'admin override reservation', '3h', 1); + is $worker->reservation->{user}, $admin->username, 'admin force override changes the reservation owner'; + is $worker->reservation->{comment}, 'admin override reservation', 'admin force override updates the comment'; + + throws_ok { $worker->release($other_operator) } + qr/Insufficient permissions/, 'refuse release attempts by users other than the owner or an admin'; + $worker->release($admin); + ok !$worker->is_reserved, 'worker is no longer reserved after release'; + is $worker->status, 'idle', 'released worker status goes back to idle'; + throws_ok { $worker->release($admin) } qr/not reserved/, 'refuse releasing a worker without reservation'; + + $worker->reserve($operator, 'expiring reservation', '1h'); + $worker->set_property(RESERVED_T_EXPIRES => time - 1); + ok !$worker->is_reserved, 'reservation is inactive once the expiry has passed'; + is $worker->status, 'idle', 'expired reservation status goes back to idle'; + is $worker->reservation, undef, 'expired reservation is not reported despite left over properties'; + is_deeply $workers->reserved_worker_ids, {}, 'bulk lookup ignores expired reservations'; + + $worker->reserve($admin, 'indefinite reservation', '0'); + ok $worker->is_reserved, 'a duration of 0 reserves the worker indefinitely'; + is $worker->reservation->{t_expires}, undef, 'indefinite reservation has no expiry timestamp'; + + $worker->set_property(RESERVED_BY_ID => 999999); + is $worker->reservation->{user}, 'unknown', 'reservation of a no longer existing user is reported as unknown'; + $worker->delete_properties([RESERVATION_PROPERTIES]); +}; + +subtest 'unlimited admin reservations are capped once admin_max_duration is configured' => sub { + my $config = $t->app->config->{worker_reservation}; + my $guard = scope_guard sub { $config->{admin_max_duration} = 0 }; + $config->{admin_max_duration} = ONE_HOUR; + my $worker = $workers->first; + my $admin = $db->resultset('Users')->find(99901); + throws_ok { $worker->reserve($admin, 'too long for an admin', '2h') } + qr/exceeds the maximum of 3600s/, 'admins are limited as well once a maximum is configured'; + ok !$worker->is_reserved, 'worker stays unreserved when the admin duration is rejected'; +}; + done_testing(); diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/openQA-5.1789770186.a0963169/t/api/01-workers.t new/openQA-5.1790003470.1faa32c9/t/api/01-workers.t --- old/openQA-5.1789770186.a0963169/t/api/01-workers.t 2026-09-19 00:23:06.000000000 +0200 +++ new/openQA-5.1790003470.1faa32c9/t/api/01-workers.t 2026-09-21 17:11:10.000000000 +0200 @@ -14,7 +14,9 @@ use Mojo::URL; use OpenQA::Test::TimeLimit '10'; use OpenQA::Test::Case; +use OpenQA::Test::Client 'client'; use OpenQA::Test::Utils 'embed_server_for_testing'; +use OpenQA::WorkerReservation 'RESERVATION_PROPERTIES'; use OpenQA::Client; use OpenQA::WebSockets::Client; use OpenQA::Constants qw(DEFAULT_WORKER_TIMEOUT DB_TIMESTAMP_ACCURACY WEBSOCKET_API_VERSION); @@ -333,4 +335,77 @@ $t->delete_ok('/api/v1/workers/1')->status_is(400, 'deleting online worker prevented'); }; +subtest 'worker reservation API' => sub { + # $t authenticates as percival, an operator without admin rights, via the default test API key + my $users = $schema->resultset('Users'); + my ($admin, $operator) = map { $users->find($_) } 99901, 99903; + my $other_operator = $users->create( + { + username => 'galahad', + is_operator => 1, + feature_version => 0, + api_keys => [{key => 'GALAHADKEY01', secret => 'GALAHADSECRET01'}]}); + my $t_admin = client(Test::Mojo->new($t->app), apikey => 'ARTHURKEY01', apisecret => 'EXCALIBUR'); + my $t_non_op = client(Test::Mojo->new($t->app), apikey => 'LANCELOTKEY01', apisecret => 'MANYPEOPLEKNOW'); + my $t_other_op = client(Test::Mojo->new($t->app), apikey => 'GALAHADKEY01', apisecret => 'GALAHADSECRET01'); + + my $worker = $workers->find(2); + $worker->delete_properties([RESERVATION_PROPERTIES]); + my $reservation_url = '/api/v1/workers/2/reservation'; + + $t_non_op->post_ok($reservation_url, form => {comment => 'non-op attempt', duration => '1h'}) + ->status_is(403, 'reserve attempt by non-operator users is refused by the route guard'); + $t->post_ok('/api/v1/workers/99/reservation', form => {comment => 'ghost', duration => '1h'}) + ->status_is(404, 'reserve attempt on an unknown worker is refused with 404'); + $t->delete_ok('/api/v1/workers/99/reservation') + ->status_is(404, 'release attempt on an unknown worker is refused with 404'); + $t->delete_ok($reservation_url)->status_is(400, 'release attempt on an unreserved worker is refused with 400'); + $t->post_ok($reservation_url, form => {comment => 'op reservation', duration => 'soon'}) + ->status_is(400, 'reserve attempt with an unparsable duration is refused with 400') + ->json_like('/error' => qr/Invalid duration format/); + + $t->post_ok($reservation_url, form => {comment => 'op reservation', duration => '2h'}) + ->status_is(200, 'reserve attempt by operator users is allowed with 200') + ->json_is('/reservation/comment' => 'op reservation')->json_is('/reservation/user' => $operator->username); + is_deeply OpenQA::Test::Case::find_most_recent_event($t->app->schema, 'worker_reserve'), + { + id => 2, + name => $worker->name, + user => $operator->username, + comment => 'op reservation', + expires => $worker->reservation->{t_expires}, + }, + 'reservation is logged in the audit table with the expected fields'; + + $t->post_ok($reservation_url, form => {comment => 'another', duration => '1h'}) + ->status_is(409, 'reserve attempt on an already reserved worker is refused with 409'); + $t_other_op->delete_ok($reservation_url) + ->status_is(403, 'release attempt by an operator other than the owner is refused with 403'); + + $t->delete_ok($reservation_url)->status_is(200, 'release attempt by the reservation owner is allowed with 200'); + is_deeply OpenQA::Test::Case::find_most_recent_event($t->app->schema, 'worker_release'), + {id => 2, name => $worker->name, user => $operator->username}, + 'release is logged in the audit table with the expected fields'; + + $t->post_ok($reservation_url, form => {comment => 'op again', duration => '1h'})->status_is(200); + $t_admin->post_ok($reservation_url, form => {comment => 'admin override', duration => '2h', force => 1}) + ->status_is(200, 'admin force override on an existing reservation is allowed with 200') + ->json_is('/reservation/comment' => 'admin override')->json_is('/reservation/user' => $admin->username); + + $t->get_ok('/api/v1/workers?reserved=1')->status_is(200, 'list only reserved workers when reserved filter is 1') + ->json_is('/workers/0/id' => 2)->json_has('/workers/0/reservation')->json_hasnt('/workers/1'); + $t->get_ok('/api/v1/workers?reserved=0')->status_is(200, 'list only unreserved workers when reserved filter is 0') + ->json_is('/workers/0/id' => 1)->json_hasnt('/workers/0/reservation'); + + $worker->update( + {t_seen => time2str('%Y-%m-%d %H:%M:%S', time - DEFAULT_WORKER_TIMEOUT - DB_TIMESTAMP_ACCURACY, 'UTC')}); + $t_admin->delete_ok('/api/v1/workers/2') + ->status_is(400, 'refuse deletion attempts of reserved workers even if they are dead') + ->json_is('/error' => 'Cannot delete a reserved worker.'); + $t_admin->delete_ok('/api/v1/workers/2?force=1') + ->status_is(200, 'admins can force the deletion of a reserved worker'); + is $workers->find(2), undef, 'forcibly deleted reserved worker is gone'; + $other_operator->delete; +}; + done_testing(); diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/openQA-5.1789770186.a0963169/t/config.t new/openQA-5.1790003470.1faa32c9/t/config.t --- old/openQA-5.1789770186.a0963169/t/config.t 2026-09-19 00:23:06.000000000 +0200 +++ new/openQA-5.1790003470.1faa32c9/t/config.t 2026-09-21 17:11:10.000000000 +0200 @@ -234,6 +234,33 @@ }; }; +subtest 'Validation of worker reservation config' => sub { + my $defaults = OpenQA::Setup::WORKER_RESERVATION_DURATION_DEFAULTS; + my %config = (worker_reservation => {%$defaults, comment_required => 1}); + my $app = Mojolicious->new(config => \%config, log => $quiet_log); + my $reservation = $config{worker_reservation}; + + OpenQA::Setup::_validate_worker_reservation_config($app); + is_deeply $reservation, {%$defaults, comment_required => 1}, 'valid config is left untouched'; + + $reservation->{default_duration} = '30m'; + $reservation->{comment_required} = 0; + OpenQA::Setup::_validate_worker_reservation_config($app); + is $reservation->{default_duration}, ONE_MINUTE * 30, 'duration with unit suffix is converted to seconds'; + is $reservation->{comment_required}, 0, 'disabled comment requirement is accepted'; + + for my $key (sort keys %$defaults) { + $reservation->{$key} = 'invalid'; + combined_like { OpenQA::Setup::_validate_worker_reservation_config($app) } qr/Invalid.*$key/, + "warning logged for invalid $key"; + is $reservation->{$key}, $defaults->{$key}, "invalid $key falls back to the default"; + } + $reservation->{comment_required} = 'yes'; + combined_like { OpenQA::Setup::_validate_worker_reservation_config($app) } qr/Invalid.*comment_required/, + 'warning logged for invalid comment_required'; + is $reservation->{comment_required}, 1, 'invalid comment_required falls back to being required'; +}; + subtest 'Validation of file_security_policy' => sub { my %config; my $app = Mojolicious->new(config => \%config, log => $quiet_log); diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/openQA-5.1789770186.a0963169/t/full-stack.t new/openQA-5.1790003470.1faa32c9/t/full-stack.t --- old/openQA-5.1789770186.a0963169/t/full-stack.t 2026-09-19 00:23:06.000000000 +0200 +++ new/openQA-5.1790003470.1faa32c9/t/full-stack.t 2026-09-21 17:11:10.000000000 +0200 @@ -80,7 +80,7 @@ my $sharedir = setup_share_dir($ENV{OPENQA_BASEDIR}); # initialize database, start daemons -my $schema = OpenQA::Test::Database->new->create(schema_name => 'public', drop_schema => 1); +my $schema = OpenQA::Test::Database->new->create(drop_schema => 1); ok +Mojolicious::Commands->start_app('OpenQA::WebAPI', 'eval', '1+0'), 'assets are prefetched'; my $mojoport = service_port 'websocket'; $ws = create_websocket_server($mojoport, 0); diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/openQA-5.1789770186.a0963169/t/lib/OpenQA/Test/FullstackUtils.pm new/openQA-5.1790003470.1faa32c9/t/lib/OpenQA/Test/FullstackUtils.pm --- old/openQA-5.1789770186.a0963169/t/lib/OpenQA/Test/FullstackUtils.pm 2026-09-19 00:23:06.000000000 +0200 +++ new/openQA-5.1790003470.1faa32c9/t/lib/OpenQA/Test/FullstackUtils.pm 2026-09-21 17:11:10.000000000 +0200 @@ -132,7 +132,10 @@ my $match_index; while (($match_index = _match_regex_returning_index($message_regex, $log, $position_of_last_match)) < 0) { - return fail("Wait for $message_regex timed out") if $timeout <= 0; + if ($timeout <= 0) { + diag("Developer console log contains:\n$log"); # uncoverable statement + return fail("Wait for $message_regex timed out"); # uncoverable statement + } $timeout -= $check_interval; sleep $check_interval; diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/openQA-5.1789770186.a0963169/t/ui/15-admin-workers.t new/openQA-5.1790003470.1faa32c9/t/ui/15-admin-workers.t --- old/openQA-5.1789770186.a0963169/t/ui/15-admin-workers.t 2026-09-19 00:23:06.000000000 +0200 +++ new/openQA-5.1790003470.1faa32c9/t/ui/15-admin-workers.t 2026-09-21 17:11:10.000000000 +0200 @@ -3,13 +3,14 @@ # SPDX-License-Identifier: GPL-2.0-or-later use Test::Most; +use Mojo::Base -signatures; use FindBin; use lib "$FindBin::Bin/../lib", "$FindBin::Bin/../../external/os-autoinst-common/lib"; use Test::Mojo; use Test::Warnings ':report_warnings'; use OpenQA::Constants 'DEFAULT_WORKER_TIMEOUT'; -use OpenQA::Test::TimeLimit '18'; +use OpenQA::Test::TimeLimit '24'; use OpenQA::Test::Case; use OpenQA::Test::Utils qw(assume_all_assets_exist embed_server_for_testing wait_for); use Date::Format 'time2str'; @@ -50,6 +51,8 @@ $workers->create({id => $online_worker_id, host => 'online_test', instance => 1, t_seen => $online_timestamp}); $workers->create({id => $offline_worker_id, host => 'offline_test', instance => 1, t_seen => $offline_timestamp}); +sub delete_button_selector ($worker_id) { "tr#worker_$worker_id .action [post_delete_url]" } + driver_missing unless my $driver = call_driver; $driver->title_is('openQA', 'on main page'); @@ -111,12 +114,16 @@ $driver->find_element_by_xpath("//select[\@id='workers_online']/option[1]")->click(); # check delete link only shown on offline worker - is $driver->find_element('tr#worker_1 .action')->get_text(), '', 'localhost:1 do not show delete button'; - is $driver->find_element('tr#worker_2 .action')->get_text(), '', 'remotehost:1 do not show delete button'; - is $driver->find_element("tr#worker_$broken_worker_id .action")->get_text(), '', 'foo do not show delete button'; - is $driver->find_element("tr#worker_$online_worker_id .action")->get_text(), - '', 'online_test do not show delete button'; - is $driver->find_element("tr#worker_$offline_worker_id .action .btn")->is_displayed(), + my %not_deletable = ( + 'localhost:1' => 1, + 'remotehost:1' => 2, + foo => $broken_worker_id, + online_test => $online_worker_id + ); + is scalar @{$driver->find_elements(delete_button_selector($not_deletable{$_}), 'css')}, 0, + "$_ does not show delete button" + for sort keys %not_deletable; + is $driver->find_element(delete_button_selector($offline_worker_id))->is_displayed(), 1, 'offline worker show delete button'; # check worker 1 @@ -151,7 +158,7 @@ # test delete offline worker function subtest 'delete offline worker' => sub { - $driver->find_element("tr#worker_$offline_worker_id .btn")->click(); + $driver->find_element(delete_button_selector($offline_worker_id))->click(); my $e = wait_for_element(selector => 'div#flash-messages .alert span', description => 'delete message displayed'); is $e->get_text(), 'Delete worker offline_test:1 successfully.', 'delete offline worker successfully'; is scalar @{$driver->find_elements('table#workers tbody tr')}, 4, 'worker deleted not shown'; @@ -221,5 +228,23 @@ }; }; +subtest 'reserve and release a worker' => sub { + $driver->get('/admin/workers/1'); + $driver->find_element('#reservation button.btn-success')->click(); + wait_for_element(selector => '#reserveWorkerModal.show', description => 'reservation modal is displayed'); + is $driver->find_element('#reserveWorkerName')->get_value, 'localhost:1', 'modal is prefilled with the worker'; + is $driver->find_element('#reserveWorkerDuration')->get_value, '5h', 'modal defaults to the configured duration'; + $driver->find_element('#reserveWorkerComment')->send_keys('maintenance <script>'); + $driver->find_element('#reserveWorkerForm button[type=submit]')->click(); + + wait_for_element(selector => '#reservation button.btn-danger', description => 'release button is displayed'); + like $driver->find_element('#reservation')->get_text, qr/Reserved by: Demo.*Comment: maintenance <script>/s, + 'reservation details are shown escaped'; + is $workers->find(1)->reservation->{comment}, 'maintenance <script>', 'reservation is persisted'; + + $driver->find_element('#reservation button.btn-danger')->click(); + wait_for_element(selector => '#reservation button.btn-success', description => 'reserve button is displayed again'); + is $workers->find(1)->reservation, undef, 'reservation is released again'; +}; kill_driver(); done_testing(); diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/openQA-5.1789770186.a0963169/t/ui/18-tests-details.t new/openQA-5.1790003470.1faa32c9/t/ui/18-tests-details.t --- old/openQA-5.1789770186.a0963169/t/ui/18-tests-details.t 2026-09-19 00:23:06.000000000 +0200 +++ new/openQA-5.1790003470.1faa32c9/t/ui/18-tests-details.t 2026-09-21 17:11:10.000000000 +0200 @@ -199,7 +199,7 @@ is $count_headings->(), 3, 'number of module headings without filter'; # show filter form - $driver->find_element('.details-filter-toggle a')->click(); + $driver->find_element('#details-filter-toggle')->click(); # enable name filter $driver->find_element('#details-name-filter')->send_keys('er'); @@ -230,6 +230,29 @@ is $count_headings->(), 3, 'module headings shown again'; }; +subtest 'expand and collapse all rows' => sub { + my $expand_all = $driver->find_element('#expand-all-rows'); + ok $expand_all->is_displayed(), 'expand-all-rows button is displayed'; + my $collapse_all = $driver->find_element('#collapse-all-rows'); + ok $collapse_all->is_displayed(), 'collapse-all-rows button is displayed'; + + my $expand_btns_count = $driver->execute_script(q{return $('button.logview_expand_btn').length;}); + ok $expand_btns_count > 0, "found $expand_btns_count expand buttons"; + + my $expanded_count_init = $driver->execute_script(q{return $('#results td[mode="log"]').length;}); + is $expanded_count_init, 0, 'no rows expanded initially'; + + $expand_all->click(); + + my $expanded_count_after = $driver->execute_script(q{return $('#results td[mode="log"]').length;}); + is $expanded_count_after, $expand_btns_count, 'all rows are expanded'; + + $collapse_all->click(); + + my $expanded_count_final = $driver->execute_script(q{return $('#results td[mode="log"]').length;}); + is $expanded_count_final, 0, 'all rows are collapsed'; +}; + sub check_report_links ($failed_module, $failed_step, $container = undef) { my @report_links = $container @@ -260,6 +283,11 @@ subtest 'bug reporting' => sub { subtest 'screenshot' => sub { + my @previews = $driver->find_elements('.links_a.current_preview'); + if (!@previews) { + $driver->find_element('[href="#step/bootloader/1"]')->click(); + wait_for_element(selector => '#preview_container_in .report', is_displayed => 1); + } # note: image of bootloader step from previous test 'correct tags displayed' is still shown check_report_links(bootloader => 1); # close bootloader step preview so it will not hide other elements used by subsequent tests diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/openQA-5.1789770186.a0963169/templates/webapi/admin/workers/index.html.ep new/openQA-5.1790003470.1faa32c9/templates/webapi/admin/workers/index.html.ep --- old/openQA-5.1789770186.a0963169/templates/webapi/admin/workers/index.html.ep 2026-09-19 00:23:06.000000000 +0200 +++ new/openQA-5.1790003470.1faa32c9/templates/webapi/admin/workers/index.html.ep 2026-09-21 17:11:10.000000000 +0200 @@ -9,16 +9,19 @@ <div id="summary" class="card"> <div class="card-header">Statistics</div> <div class="card-body row"> - <div class="col-sm-3"> + <div class="col-sm-2"> Online: <span class="badge text-bg-success"><%= $workers_online ? $workers_online : 0 %></span> </div> - <div class="col-sm-3"> + <div class="col-sm-2"> Busy: <span class="badge text-bg-primary"><%= $workers_busy ? $workers_busy : 0 %></span> </div> - <div class="col-sm-3"> + <div class="col-sm-2"> Idle: <span class="badge text-bg-secondary"><%= $workers_active_free ? $workers_active_free : 0 %></span> </div> <div class="col-sm-3"> + Reserved: <span class="badge text-bg-info"><%= $workers_reserved ? $workers_reserved : 0 %></span> + </div> + <div class="col-sm-3"> Total: <span class="badge text-bg-dark"><%= $total ? $total : 0 %></span> </div> </div> @@ -71,8 +74,15 @@ <%= exists $worker->{properties} && $worker->{properties}->{ISOTOVIDEO_INTERFACE_VERSION} ? $worker->{properties}->{ISOTOVIDEO_INTERFACE_VERSION} : 'unknown' %> </td> <td class='action'> + % if (is_operator) { + % if ($worker->{reservation}) { + <button class="btn btn-sm btn-outline-danger" title="Release reservation" data-worker-id="<%= $worker->{id} %>" onclick="releaseWorker(this)">Release</button> + % } else { + <button class="btn btn-sm btn-outline-success" title="Reserve worker" data-worker-id="<%= $worker->{id} %>" data-worker-name="<%= $workername %>" onclick="openReserveModal(this)">Reserve</button> + % } + % } % if($is_admin == 1 && ($worker->{status} eq 'dead' && !$worker->{job})) { - <button class='btn' type='submit' alt='Delete' title='Delete' post_delete_url="<%= url_for('apiv1_worker_delete', worker_id => $worker->{id}) %>" onclick='deleteWorker(this)'><i class="fa-regular fa-trash-can"></i></button> + <button class='btn btn-sm' type='submit' alt='Delete' title='Delete' post_delete_url="<%= url_for('apiv1_worker_delete', worker_id => $worker->{id}) %>" onclick='deleteWorker(this)'><i class="fa-regular fa-trash-can"></i></button> % } </td> </tr> @@ -81,3 +91,5 @@ </table> </div> </div> + +%= include 'admin/workers/reserve_modal' if is_operator diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/openQA-5.1789770186.a0963169/templates/webapi/admin/workers/reserve_modal.html.ep new/openQA-5.1790003470.1faa32c9/templates/webapi/admin/workers/reserve_modal.html.ep --- old/openQA-5.1789770186.a0963169/templates/webapi/admin/workers/reserve_modal.html.ep 1970-01-01 01:00:00.000000000 +0100 +++ new/openQA-5.1790003470.1faa32c9/templates/webapi/admin/workers/reserve_modal.html.ep 2026-09-21 17:11:10.000000000 +0200 @@ -0,0 +1,39 @@ +<div class="modal fade" id="reserveWorkerModal" tabindex="-1" aria-labelledby="reserveWorkerModalLabel" aria-hidden="true"> + <div class="modal-dialog"> + <div class="modal-content"> + <div class="modal-header"> + <h5 class="modal-title" id="reserveWorkerModalLabel">Reserve worker</h5> + <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button> + </div> + <form id="reserveWorkerForm" onsubmit="submitReserve(event)"> + <input type="hidden" id="reserveWorkerId"> + <div class="modal-body"> + <p>A reservation prevents new jobs from being assigned to this worker, taking it out of the scheduling rotation without interrupting any currently running job.</p> + <div class="mb-3"> + <label for="reserveWorkerName" class="form-label">Worker</label> + <input type="text" class="form-control" id="reserveWorkerName" readonly> + </div> + <div class="mb-3"> + <label for="reserveWorkerComment" class="form-label">Comment (required)</label> + <input type="text" class="form-control" id="reserveWorkerComment" required> + </div> + <div class="mb-3"> + <label for="reserveWorkerDuration" class="form-label">Duration (e.g. 5h, 2d, 3600; 0 for indefinite)</label> + <input type="text" class="form-control" id="reserveWorkerDuration" + data-default-duration="<%= $reservation_default_duration %>"> + </div> + % if (is_admin) { + <div class="form-check mb-3"> + <input class="form-check-input" type="checkbox" id="reserveWorkerForce" value="1"> + <label class="form-check-label" for="reserveWorkerForce">Force (admin override)</label> + </div> + % } + </div> + <div class="modal-footer"> + <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button> + <button type="submit" class="btn btn-primary">Reserve</button> + </div> + </form> + </div> + </div> +</div> diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/openQA-5.1789770186.a0963169/templates/webapi/admin/workers/show.html.ep new/openQA-5.1790003470.1faa32c9/templates/webapi/admin/workers/show.html.ep --- old/openQA-5.1789770186.a0963169/templates/webapi/admin/workers/show.html.ep 2026-09-19 00:23:06.000000000 +0200 +++ new/openQA-5.1790003470.1faa32c9/templates/webapi/admin/workers/show.html.ep 2026-09-21 17:11:10.000000000 +0200 @@ -25,6 +25,26 @@ </div> </div> + <div class="card my-3"> + <div class="card-header">Reservation</div> + <div class="card-body" id="reservation"> + % if (my $reservation = $worker->{reservation}) { + <p><strong>Reserved by:</strong> <%= $reservation->{user} %></p> + <p><strong>Comment:</strong> <%= $reservation->{comment} %></p> + <p><strong>Reserved at:</strong> <%= $reservation->{t_created} %></p> + <p><strong>Expires:</strong> <%= $reservation->{t_expires} // 'Never (indefinite)' %></p> + % if (is_operator) { + <button class="btn btn-danger" data-worker-id="<%= $worker->{id} %>" onclick="releaseWorker(this)">Release reservation</button> + % } + % } else { + <p>This worker is not reserved.</p> + % if (is_operator) { + <button class="btn btn-success" data-worker-id="<%= $worker->{id} %>" data-worker-name="<%= $worker->{name} %>" onclick="openReserveModal(this)">Reserve worker</button> + % } + % } + </div> + </div> + % if (is_operator) { <h3>Properties</h3> <table class="table table-striped"> @@ -55,3 +75,5 @@ </div> </div> + +%= include 'admin/workers/reserve_modal' if is_operator diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/openQA-5.1789770186.a0963169/templates/webapi/admin/workers/worker_status.html.ep new/openQA-5.1790003470.1faa32c9/templates/webapi/admin/workers/worker_status.html.ep --- old/openQA-5.1789770186.a0963169/templates/webapi/admin/workers/worker_status.html.ep 2026-09-19 00:23:06.000000000 +0200 +++ new/openQA-5.1790003470.1faa32c9/templates/webapi/admin/workers/worker_status.html.ep 2026-09-21 17:11:10.000000000 +0200 @@ -1,19 +1,24 @@ +% my $reservation = $worker->{reservation}; +% my $reservation_details = ''; +% if ($reservation) { +% my $user = b($reservation->{user})->xml_escape; +% my $comment = b($reservation->{comment})->xml_escape; +% my $expires = b($reservation->{t_expires} // 'never')->xml_escape; +% $reservation_details = "Reserved by: $user<br>Comment: $comment<br>Expires: $expires"; +% } % if($worker->{status} eq 'running') { - Working - <%= help_popover('Worker status' => - 'Job: ' . - link_to($worker->{jobid}, url_for('test', testid => $worker->{jobid})) . - ( $worker->{status} eq 'running' && defined($worker->{currentstep}) ? - '<br> Step: '. $worker->{currentstep} : '' ) - ) - %> + <%= $reservation ? 'Working (Reserved)' : 'Working' %> + % my $job_link = link_to($worker->{jobid}, url_for('test', testid => $worker->{jobid})); + % my $step_info = defined($worker->{currentstep}) ? "<br> Step: $worker->{currentstep}" : ''; + % my $res_info = $reservation_details ? "<br>$reservation_details" : ''; + <%= help_popover('Worker status' => "Job: $job_link$step_info$res_info") %> +% } elsif ($worker->{status} eq 'reserved') { + Reserved + <%= help_popover('Worker status' => $reservation_details) %> % } elsif ($worker->{status} eq 'dead' && $worker->{job}) { Dead - <%= help_popover('Worker status' => - 'Dead with job: ' . - link_to($worker->{jobid}, url_for('test', testid => $worker->{jobid})) - ) - %> + % my $job_link = link_to($worker->{jobid}, url_for('test', testid => $worker->{jobid})); + <%= help_popover('Worker status' => "Dead with job: $job_link") %> % } elsif ($worker->{status} eq 'broken') { Unavailable <%= help_popover(Details => $worker->{error}) %> % } elsif ($worker->{alive}) { diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/openQA-5.1789770186.a0963169/templates/webapi/test/details.html.ep new/openQA-5.1790003470.1faa32c9/templates/webapi/test/details.html.ep --- old/openQA-5.1789770186.a0963169/templates/webapi/test/details.html.ep 2026-09-19 00:23:06.000000000 +0200 +++ new/openQA-5.1790003470.1faa32c9/templates/webapi/test/details.html.ep 2026-09-21 17:11:10.000000000 +0200 @@ -1,5 +1,11 @@ <div class="details-filter-toggle"> - <a href="#" aria-controls="next_previous" class="nav-link" title="Filter"> + <a href="#" class="nav-link" title="Expand all rows" id="expand-all-rows"> + <i class="fa-solid fa-expand"></i> + </a> + <a href="#" class="nav-link" title="Collapse all rows" id="collapse-all-rows"> + <i class="fa-solid fa-compress"></i> + </a> + <a href="#" aria-controls="next_previous" class="nav-link" title="Filter" id="details-filter-toggle"> <i class="fa-solid fa-filter"></i> </a> </div> ++++++ openQA.obsinfo ++++++ --- /var/tmp/diff_new_pack.2MzyDe/_old 2026-09-22 15:51:14.769319548 +0200 +++ /var/tmp/diff_new_pack.2MzyDe/_new 2026-09-22 15:51:14.773319715 +0200 @@ -1,5 +1,5 @@ name: openQA -version: 5.1789770186.a0963169 -mtime: 1789770186 -commit: a09631699365998aede8d867f0d4fa34e24e7d87 +version: 5.1790003470.1faa32c9 +mtime: 1790003470 +commit: 1faa32c95ab561016361f929f75cbf595f8f2659
