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
 

Reply via email to