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-06-18 18:40:16
++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Comparing /work/SRC/openSUSE:Factory/openQA (Old)
 and      /work/SRC/openSUSE:Factory/.openQA.new.1981 (New)
++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++

Package is "openQA"

Thu Jun 18 18:40:16 2026 rev:853 rq:1360081 version:5.1781712973.4c20e5c1

Changes:
--------
--- /work/SRC/openSUSE:Factory/openQA/openQA.changes    2026-06-17 
16:20:14.491888672 +0200
+++ /work/SRC/openSUSE:Factory/.openQA.new.1981/openQA.changes  2026-06-18 
18:41:06.778254590 +0200
@@ -1,0 +2,11 @@
+Wed Jun 17 16:16:23 UTC 2026 - [email protected]
+
+- Update to version 5.1781712973.4c20e5c1:
+  * test: make search API tests robust
+  * fix: resolve openqa-llm-server crash on startup
+  * chore(deps): Dependency cron 2026-06-17
+  * feat: Improve job archive generation efficiency and UI
+  * feat: Efficiently serve job archives via web server redirect
+  * feat: Add 'Download All' ZIP archive button to job downloads page
+
+-------------------------------------------------------------------

Old:
----
  openQA-5.1781621316.6d025b35.obscpio

New:
----
  openQA-5.1781712973.4c20e5c1.obscpio

++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++

Other differences:
------------------
++++++ openQA-client-test.spec ++++++
--- /var/tmp/diff_new_pack.iOo5kv/_old  2026-06-18 18:41:08.794338699 +0200
+++ /var/tmp/diff_new_pack.iOo5kv/_new  2026-06-18 18:41:08.798338866 +0200
@@ -18,7 +18,7 @@
 
 %define         short_name openQA-client
 Name:           %{short_name}-test
-Version:        5.1781621316.6d025b35
+Version:        5.1781712973.4c20e5c1
 Release:        0
 Summary:        Test package for %{short_name}
 License:        GPL-2.0-or-later

++++++ openQA-devel-test.spec ++++++
--- /var/tmp/diff_new_pack.iOo5kv/_old  2026-06-18 18:41:08.842340701 +0200
+++ /var/tmp/diff_new_pack.iOo5kv/_new  2026-06-18 18:41:08.842340701 +0200
@@ -18,7 +18,7 @@
 
 %define         short_name openQA-devel
 Name:           %{short_name}-test
-Version:        5.1781621316.6d025b35
+Version:        5.1781712973.4c20e5c1
 Release:        0
 Summary:        Test package for %{short_name}
 License:        GPL-2.0-or-later

++++++ openQA-test.spec ++++++
--- /var/tmp/diff_new_pack.iOo5kv/_old  2026-06-18 18:41:08.886342537 +0200
+++ /var/tmp/diff_new_pack.iOo5kv/_new  2026-06-18 18:41:08.890342704 +0200
@@ -18,7 +18,7 @@
 
 %define         short_name openQA
 Name:           %{short_name}-test
-Version:        5.1781621316.6d025b35
+Version:        5.1781712973.4c20e5c1
 Release:        0
 Summary:        Test package for openQA
 License:        GPL-2.0-or-later

++++++ openQA-worker-test.spec ++++++
--- /var/tmp/diff_new_pack.iOo5kv/_old  2026-06-18 18:41:08.930344373 +0200
+++ /var/tmp/diff_new_pack.iOo5kv/_new  2026-06-18 18:41:08.930344373 +0200
@@ -18,7 +18,7 @@
 
 %define         short_name openQA-worker
 Name:           %{short_name}-test
-Version:        5.1781621316.6d025b35
+Version:        5.1781712973.4c20e5c1
 Release:        0
 Summary:        Test package for %{short_name}
 License:        GPL-2.0-or-later

++++++ openQA.spec ++++++
--- /var/tmp/diff_new_pack.iOo5kv/_old  2026-06-18 18:41:08.986346710 +0200
+++ /var/tmp/diff_new_pack.iOo5kv/_new  2026-06-18 18:41:08.990346876 +0200
@@ -64,7 +64,7 @@
 # The following line is generated from dependencies.yaml
 %define assetpack_requires perl(CSS::Minifier::XS) >= 0.01 
perl(JavaScript::Minifier::XS) >= 0.11 perl(Mojolicious) 
perl(Mojolicious::Plugin::AssetPack) >= 1.36 perl(YAML::PP) >= 0.026
 # The following line is generated from dependencies.yaml
-%define common_requires ntp-daemon perl >= 5.20.0 perl(Carp::Always) >= 
0.14.02 perl(Config::IniFiles) perl(Cpanel::JSON::XS) >= 4.09 perl(Cwd) 
perl(Data::Dump) perl(Data::Dumper) perl(Digest::MD5) 
perl(Feature::Compat::Try) perl(Filesys::Df) perl(Getopt::Long) 
perl(HTTP::Status) perl(Minion) >= 10.25 perl(Mojolicious) >= 9.340.0 
perl(Regexp::Common) perl(Storable) perl(Text::Glob) perl(Time::Moment)
+%define common_requires ntp-daemon perl >= 5.20.0 perl(Archive::Zip) 
perl(Carp::Always) >= 0.14.02 perl(Config::IniFiles) perl(Cpanel::JSON::XS) >= 
4.09 perl(Cwd) perl(Data::Dump) perl(Data::Dumper) perl(Digest::MD5) 
perl(Feature::Compat::Try) perl(Filesys::Df) perl(Getopt::Long) 
perl(HTTP::Status) perl(Minion) >= 10.25 perl(Mojolicious) >= 9.340.0 
perl(Regexp::Common) perl(Storable) perl(Text::Glob) perl(Time::Moment)
 # runtime requirements for the main package that are not required by other 
sub-packages
 # The following line is generated from dependencies.yaml
 %define main_requires %assetpack_requires bsdtar git-core hostname 
openssh-clients perl(BSD::Resource) perl(Carp) perl(CommonMark) perl(CryptX) 
perl(DBD::Pg) >= 3.7.4 perl(DBI) >= 1.632 perl(DBIx::Class) >= 0.082801 
perl(DBIx::Class::DeploymentHandler) perl(DBIx::Class::DynamicDefault) 
perl(DBIx::Class::OptimisticLocking) 
perl(DBIx::Class::ResultClass::HashRefInflator) 
perl(DBIx::Class::Schema::Config) perl(DBIx::Class::Storage::Statistics) 
perl(Date::Format) perl(DateTime) perl(DateTime::Duration) 
perl(DateTime::Format::Pg) perl(Exporter) perl(Fcntl) perl(File::Basename) 
perl(File::Copy) perl(File::Copy::Recursive) perl(File::Path) perl(File::Spec) 
perl(FindBin) perl(Getopt::Long::Descriptive) perl(IO::Handle) perl(IPC::Run) 
perl(JSON::Validator) perl(LWP::UserAgent) perl(Module::Load::Conditional) 
perl(Module::Pluggable) perl(Mojo::Base) perl(Mojo::ByteStream) 
perl(Mojo::IOLoop) perl(Mojo::JSON) perl(Mojo::Pg) perl(Mojo::RabbitMQ::Client) 
>= 0.2 perl(Mojo::URL) perl(Mojo::Util) pe
 rl(Mojolicious::Commands) perl(Mojolicious::Plugin) 
perl(Mojolicious::Plugin::OAuth2) perl(Mojolicious::Static) 
perl(Net::OpenID::Consumer) perl(POSIX) perl(Pod::POM) perl(SQL::Translator) 
perl(Scalar::Util) perl(Sort::Versions) perl(Text::Diff) perl(Time::HiRes) 
perl(Time::ParseDate) perl(Time::Piece) perl(Time::Seconds) perl(URI::Escape) 
perl(YAML::PP) >= 0.026 perl(YAML::XS) perl(aliased) perl(base) perl(constant) 
perl(diagnostics) perl(strict) perl(warnings)
@@ -104,7 +104,7 @@
 %define devel_requires %devel_no_selenium_requires chromedriver
 
 Name:           openQA
-Version:        5.1781621316.6d025b35
+Version:        5.1781712973.4c20e5c1
 Release:        0
 Summary:        Framework for automated system-level testing (web-frontend, 
scheduler and tools)
 Group:          Development/Tools/Other
@@ -930,4 +930,6 @@
 %dir %{_datadir}/containers
 %dir %{_datadir}/containers/systemd
 %{_datadir}/containers/systemd/openqa-llm-server.container
+%dir /opt/llm
+%dir /opt/llm/models
 

++++++ openQA-5.1781621316.6d025b35.obscpio -> 
openQA-5.1781712973.4c20e5c1.obscpio ++++++
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/openQA-5.1781621316.6d025b35/Makefile 
new/openQA-5.1781712973.4c20e5c1/Makefile
--- old/openQA-5.1781621316.6d025b35/Makefile   2026-06-16 16:48:36.000000000 
+0200
+++ new/openQA-5.1781712973.4c20e5c1/Makefile   2026-06-17 18:16:13.000000000 
+0200
@@ -182,6 +182,7 @@
        install -m 644 systemd/tmpfiles-openqa.conf 
"$(DESTDIR)"/usr/lib/tmpfiles.d/openqa.conf
        install -m 644 systemd/tmpfiles-openqa-webui.conf 
"$(DESTDIR)"/usr/lib/tmpfiles.d/openqa-webui.conf
        install -m 644 container/systemd/openqa-llm-server.container 
"$(DESTDIR)"/usr/share/containers/systemd/openqa-llm-server.container
+       install -d -m 755 "$(DESTDIR)"/opt/llm/models
        install -d -m 755 
"$(DESTDIR)"/usr/lib/systemd/system/openqa-gru.service.requires
        ln -s ../postgresql.service 
"$(DESTDIR)"/usr/lib/systemd/system/openqa-gru.service.requires/postgresql.service
        install -d -m 755 
"$(DESTDIR)"/usr/lib/systemd/system/openqa-scheduler.service.requires
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/openQA-5.1781621316.6d025b35/assets/assetpack.def 
new/openQA-5.1781712973.4c20e5c1/assets/assetpack.def
--- old/openQA-5.1781621316.6d025b35/assets/assetpack.def       2026-06-16 
16:48:36.000000000 +0200
+++ new/openQA-5.1781712973.4c20e5c1/assets/assetpack.def       2026-06-17 
18:16:13.000000000 +0200
@@ -165,6 +165,9 @@
 < javascripts/running.js
 < javascripts/disable_status_updates.js [mode==test]
 
+! archive_wait.js
+< javascripts/archive_wait.js
+
 ! create_tests.js
 < javascripts/create_tests.js
 
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' 
old/openQA-5.1781621316.6d025b35/assets/javascripts/archive_wait.js 
new/openQA-5.1781712973.4c20e5c1/assets/javascripts/archive_wait.js
--- old/openQA-5.1781621316.6d025b35/assets/javascripts/archive_wait.js 
1970-01-01 01:00:00.000000000 +0100
+++ new/openQA-5.1781712973.4c20e5c1/assets/javascripts/archive_wait.js 
2026-06-17 18:16:13.000000000 +0200
@@ -0,0 +1,49 @@
+function checkArchiveStatus() {
+  let retries = 0;
+  const maxRetries = 120; // 10 minutes (120 * 5s)
+
+  const poll = () => {
+    fetch(window.location.href, {
+      headers: {
+        'X-Requested-With': 'XMLHttpRequest'
+      }
+    })
+      .then(response => {
+        if (response.redirected) {
+          window.location.href = response.url;
+          return;
+        }
+
+        if (!response.ok) {
+          throw new Error(`Server returned ${response.status}: 
${response.statusText}`);
+        }
+
+        if (retries < maxRetries) {
+          retries++;
+          setTimeout(poll, 5000);
+        } else {
+          showArchiveError('Timeout: Archive preparation is taking too long. 
Please try again later.');
+        }
+      })
+      .catch(error => {
+        console.error('Archive status check failed:', error);
+        showArchiveError(`Failed to check archive status: ${error.message || 
error}`);
+      });
+  };
+
+  setTimeout(poll, 5000);
+}
+
+function showArchiveError(message) {
+  const errorContainer = document.getElementById('archive-error');
+  if (errorContainer) {
+    errorContainer.innerText = message;
+    errorContainer.classList.remove('d-none');
+    const spinner = document.querySelector('.spinner-border');
+    if (spinner) {
+      spinner.classList.add('d-none');
+    }
+  }
+}
+
+document.addEventListener('DOMContentLoaded', checkArchiveStatus);
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' 
old/openQA-5.1781621316.6d025b35/container/systemd/openqa-llm-server.container 
new/openQA-5.1781712973.4c20e5c1/container/systemd/openqa-llm-server.container
--- 
old/openQA-5.1781621316.6d025b35/container/systemd/openqa-llm-server.container  
    2026-06-16 16:48:36.000000000 +0200
+++ 
new/openQA-5.1781712973.4c20e5c1/container/systemd/openqa-llm-server.container  
    2026-06-17 18:16:13.000000000 +0200
@@ -5,10 +5,12 @@
 [Container]
 Image=ghcr.io/ggml-org/llama.cpp:server
 ContainerName=openqa-llm-server
-Volume=/opt/llm/models:/models
+Volume=/opt/llm/models:/models:U
 PublishPort=8080:8080
 Exec=-hf unsloth/gemma-4-E4B-it-GGUF:UD-IQ2_M --temp 1.0 --top-p 0.95 --top-k 
64 --ctx-size 65536
-User=_openqa-worker
+UserNS=auto
+Environment=HF_HOME=/models
+Environment=LLAMA_CACHE=/models
 
 [Service]
 Restart=always
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/openQA-5.1781621316.6d025b35/cpanfile 
new/openQA-5.1781712973.4c20e5c1/cpanfile
--- old/openQA-5.1781621316.6d025b35/cpanfile   2026-06-16 16:48:36.000000000 
+0200
+++ new/openQA-5.1781712973.4c20e5c1/cpanfile   2026-06-17 18:16:13.000000000 
+0200
@@ -4,6 +4,7 @@
 # from dependencies.yaml
 ##################################################
 
+requires 'Archive::Zip';
 requires 'BSD::Resource';
 requires 'CSS::Minifier::XS', '>= 0.01';
 requires 'Capture::Tiny';
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/openQA-5.1781621316.6d025b35/dependencies.yaml 
new/openQA-5.1781712973.4c20e5c1/dependencies.yaml
--- old/openQA-5.1781621316.6d025b35/dependencies.yaml  2026-06-16 
16:48:36.000000000 +0200
+++ new/openQA-5.1781712973.4c20e5c1/dependencies.yaml  2026-06-17 
18:16:13.000000000 +0200
@@ -33,6 +33,7 @@
 
 common_requires:
   perl: '>= 5.20.0'
+  perl(Archive::Zip):
   perl(Carp::Always): '>= 0.14.02'
   perl(Config::IniFiles):
   perl(Cpanel::JSON::XS): '>= 4.09'
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/openQA-5.1781621316.6d025b35/dist/rpm/openQA.spec 
new/openQA-5.1781712973.4c20e5c1/dist/rpm/openQA.spec
--- old/openQA-5.1781621316.6d025b35/dist/rpm/openQA.spec       2026-06-16 
16:48:36.000000000 +0200
+++ new/openQA-5.1781712973.4c20e5c1/dist/rpm/openQA.spec       2026-06-17 
18:16:13.000000000 +0200
@@ -64,7 +64,7 @@
 # The following line is generated from dependencies.yaml
 %define assetpack_requires perl(CSS::Minifier::XS) >= 0.01 
perl(JavaScript::Minifier::XS) >= 0.11 perl(Mojolicious) 
perl(Mojolicious::Plugin::AssetPack) >= 1.36 perl(YAML::PP) >= 0.026
 # The following line is generated from dependencies.yaml
-%define common_requires ntp-daemon perl >= 5.20.0 perl(Carp::Always) >= 
0.14.02 perl(Config::IniFiles) perl(Cpanel::JSON::XS) >= 4.09 perl(Cwd) 
perl(Data::Dump) perl(Data::Dumper) perl(Digest::MD5) 
perl(Feature::Compat::Try) perl(Filesys::Df) perl(Getopt::Long) 
perl(HTTP::Status) perl(Minion) >= 10.25 perl(Mojolicious) >= 9.340.0 
perl(Regexp::Common) perl(Storable) perl(Text::Glob) perl(Time::Moment)
+%define common_requires ntp-daemon perl >= 5.20.0 perl(Archive::Zip) 
perl(Carp::Always) >= 0.14.02 perl(Config::IniFiles) perl(Cpanel::JSON::XS) >= 
4.09 perl(Cwd) perl(Data::Dump) perl(Data::Dumper) perl(Digest::MD5) 
perl(Feature::Compat::Try) perl(Filesys::Df) perl(Getopt::Long) 
perl(HTTP::Status) perl(Minion) >= 10.25 perl(Mojolicious) >= 9.340.0 
perl(Regexp::Common) perl(Storable) perl(Text::Glob) perl(Time::Moment)
 # runtime requirements for the main package that are not required by other 
sub-packages
 # The following line is generated from dependencies.yaml
 %define main_requires %assetpack_requires bsdtar git-core hostname 
openssh-clients perl(BSD::Resource) perl(Carp) perl(CommonMark) perl(CryptX) 
perl(DBD::Pg) >= 3.7.4 perl(DBI) >= 1.632 perl(DBIx::Class) >= 0.082801 
perl(DBIx::Class::DeploymentHandler) perl(DBIx::Class::DynamicDefault) 
perl(DBIx::Class::OptimisticLocking) 
perl(DBIx::Class::ResultClass::HashRefInflator) 
perl(DBIx::Class::Schema::Config) perl(DBIx::Class::Storage::Statistics) 
perl(Date::Format) perl(DateTime) perl(DateTime::Duration) 
perl(DateTime::Format::Pg) perl(Exporter) perl(Fcntl) perl(File::Basename) 
perl(File::Copy) perl(File::Copy::Recursive) perl(File::Path) perl(File::Spec) 
perl(FindBin) perl(Getopt::Long::Descriptive) perl(IO::Handle) perl(IPC::Run) 
perl(JSON::Validator) perl(LWP::UserAgent) perl(Module::Load::Conditional) 
perl(Module::Pluggable) perl(Mojo::Base) perl(Mojo::ByteStream) 
perl(Mojo::IOLoop) perl(Mojo::JSON) perl(Mojo::Pg) perl(Mojo::RabbitMQ::Client) 
>= 0.2 perl(Mojo::URL) perl(Mojo::Util) pe
 rl(Mojolicious::Commands) perl(Mojolicious::Plugin) 
perl(Mojolicious::Plugin::OAuth2) perl(Mojolicious::Static) 
perl(Net::OpenID::Consumer) perl(POSIX) perl(Pod::POM) perl(SQL::Translator) 
perl(Scalar::Util) perl(Sort::Versions) perl(Text::Diff) perl(Time::HiRes) 
perl(Time::ParseDate) perl(Time::Piece) perl(Time::Seconds) perl(URI::Escape) 
perl(YAML::PP) >= 0.026 perl(YAML::XS) perl(aliased) perl(base) perl(constant) 
perl(diagnostics) perl(strict) perl(warnings)
@@ -931,5 +931,7 @@
 %dir %{_datadir}/containers
 %dir %{_datadir}/containers/systemd
 %{_datadir}/containers/systemd/openqa-llm-server.container
+%dir /opt/llm
+%dir /opt/llm/models
 
 %changelog
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' 
old/openQA-5.1781621316.6d025b35/etc/apache2/vhosts.d/openqa-common.inc 
new/openQA-5.1781712973.4c20e5c1/etc/apache2/vhosts.d/openqa-common.inc
--- old/openQA-5.1781621316.6d025b35/etc/apache2/vhosts.d/openqa-common.inc     
2026-06-16 16:48:36.000000000 +0200
+++ new/openQA-5.1781712973.4c20e5c1/etc/apache2/vhosts.d/openqa-common.inc     
2026-06-17 18:16:13.000000000 +0200
@@ -18,6 +18,12 @@
 </Directory>
 Alias /assets "/var/lib/openqa/share/factory"
 
+<Directory "/var/lib/openqa/cache/archives">
+    AllowOverride None
+    Require all granted
+</Directory>
+Alias /archives "/var/lib/openqa/cache/archives"
+
 <Directory "/var/lib/openqa/images">
   Options SymLinksIfOwnerMatch
   AllowOverride None
@@ -51,6 +57,7 @@
 ProxyPass /javascripts !
 ProxyPass /stylesheets !
 ProxyPass /assets !
+ProxyPass /archives !
 ProxyPass /error !
 
 # ensure websocket connections are handled as such by the reverse proxy while
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' 
old/openQA-5.1781621316.6d025b35/etc/nginx/vhosts.d/openqa-archives.inc 
new/openQA-5.1781712973.4c20e5c1/etc/nginx/vhosts.d/openqa-archives.inc
--- old/openQA-5.1781621316.6d025b35/etc/nginx/vhosts.d/openqa-archives.inc     
1970-01-01 01:00:00.000000000 +0100
+++ new/openQA-5.1781712973.4c20e5c1/etc/nginx/vhosts.d/openqa-archives.inc     
2026-06-17 18:16:13.000000000 +0200
@@ -0,0 +1,8 @@
+alias /var/lib/openqa/cache/archives/;
+autoindex          on;
+tcp_nopush         on;
+sendfile           on;
+sendfile_max_chunk 1m;
+
+# Enforce download of archives
+add_header Content-Disposition 'attachment; filename="$1"';
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' 
old/openQA-5.1781621316.6d025b35/etc/nginx/vhosts.d/openqa-locations.inc 
new/openQA-5.1781712973.4c20e5c1/etc/nginx/vhosts.d/openqa-locations.inc
--- old/openQA-5.1781621316.6d025b35/etc/nginx/vhosts.d/openqa-locations.inc    
2026-06-16 16:48:36.000000000 +0200
+++ new/openQA-5.1781712973.4c20e5c1/etc/nginx/vhosts.d/openqa-locations.inc    
2026-06-17 18:16:13.000000000 +0200
@@ -7,4 +7,9 @@
 #    #return 301 http://$host-files$request_uri;
 #}
 
+## Optional faster archive downloads
+#location ~ ^/archives/(.*)$ {
+#    include vhosts.d/openqa-archives.inc;
+#}
+
 include vhosts.d/openqa-endpoints.inc;
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/openQA-5.1781621316.6d025b35/etc/openqa/openqa.ini 
new/openQA-5.1781712973.4c20e5c1/etc/openqa/openqa.ini
--- old/openQA-5.1781621316.6d025b35/etc/openqa/openqa.ini      2026-06-16 
16:48:36.000000000 +0200
+++ new/openQA-5.1781712973.4c20e5c1/etc/openqa/openqa.ini      2026-06-17 
18:16:13.000000000 +0200
@@ -412,12 +412,29 @@
 ## because ownership and purpose of a job group are unclear if the description 
is missing.
 #prio_group_parameters = full_name:Development:50
 
+## Concurrency limit of archive generation (ZIP creation) jobs in parallel
+#create_zip_archive_limit = 2
+
 [archiving]
 ## Moves logs of jobs which are preserved during the cleanup because they are
 ## considered important to
 ## 
"${OPENQA_ARCHIVEDIR:-${OPENQA_BASEDIR:-/var/lib}/openqa/archive}/testresults"
 #archive_preserved_important_jobs = 0
 
+[job_details_archive]
+## Directory to store cached ZIP archives
+#job_details_archive_cache_dir = /var/lib/openqa/cache/archives
+
+## Maximum size for the archive cache in GB (on-demand archives for 'Download
+## All')
+#job_details_archive_cache_limit_gb = 5
+
+## Minimum free storage space percentage required to keep cached archives
+#job_details_archive_cache_min_free_percentage = 10
+
+## Percentage of the cache limit to keep after cleanup (low watermark)
+#job_details_archive_cache_watermark_percentage = 80
+
 [job_settings_ui]
 ## Specify the keys of job settings which reference a file and should 
therefore be rendered
 ## as links to those files within the job settings tab.
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/openQA-5.1781621316.6d025b35/lib/OpenQA/Archive.pm 
new/openQA-5.1781712973.4c20e5c1/lib/OpenQA/Archive.pm
--- old/openQA-5.1781621316.6d025b35/lib/OpenQA/Archive.pm      1970-01-01 
01:00:00.000000000 +0100
+++ new/openQA-5.1781712973.4c20e5c1/lib/OpenQA/Archive.pm      2026-06-17 
18:16:13.000000000 +0200
@@ -0,0 +1,123 @@
+# Copyright SUSE LLC
+# SPDX-License-Identifier: GPL-2.0-or-later
+
+package OpenQA::Archive;
+use Mojo::Base -strict, -signatures;
+
+use Archive::Zip qw(:ERROR_CODES :CONSTANTS);
+use Mojo::File 'path';
+use OpenQA::Utils qw(resultdir assetdir check_df locate_asset 
human_readable_size random_hex);
+use OpenQA::Log qw(log_info log_error log_debug);
+use OpenQA::App;
+use File::Basename;
+use Feature::Compat::Try;
+use Fcntl qw(:flock);
+
+sub archive_cache_dir () {
+    return $ENV{OPENQA_JOB_DETAILS_ARCHIVE_CACHE_DIR} if 
$ENV{OPENQA_JOB_DETAILS_ARCHIVE_CACHE_DIR};
+    my $config = OpenQA::App->singleton->config->{job_details_archive};
+    return $config->{job_details_archive_cache_dir} if 
$config->{job_details_archive_cache_dir};
+    return path(OpenQA::Utils::prjdir(), 'cache', 'archives')->to_string;
+}
+
+sub get_cache_limit () {
+    my $limit_gb = 
OpenQA::App->singleton->config->{job_details_archive}->{job_details_archive_cache_limit_gb}
 // 5;
+    return $limit_gb * 1024 * 1024 * 1024;
+}
+
+sub get_min_free_percentage () {
+    return 
OpenQA::App->singleton->config->{job_details_archive}->{job_details_archive_cache_min_free_percentage}
 // 10;
+}
+
+sub get_watermark_percentage () {
+    return 
OpenQA::App->singleton->config->{job_details_archive}->{job_details_archive_cache_watermark_percentage}
+      // 80;
+}
+
+sub create_job_archive ($job) {
+    my $job_id = $job->id;
+    my $cache_dir = path(archive_cache_dir());
+    $cache_dir->make_path unless -d $cache_dir;
+    my $archive_name = "job_$job_id.zip";
+    my $archive_path = $cache_dir->child($archive_name);
+    my $lock_path = $cache_dir->child("$archive_name.lock");
+    open my $lock_fh, '>', $lock_path->to_string
+      or die "Could not open lock file $lock_path: $!";
+    flock $lock_fh, LOCK_EX or die "Could not lock $lock_path: $!";
+    if (-e $archive_path) {
+        close $lock_fh;
+        return $archive_path;
+    }
+    log_info "Creating archive for job $job_id at $archive_path";
+    # Archive::Zip does not keep all member data in memory. When using addFile 
or
+    # addTree, it only remembers the filenames. writeToFileNamed then streams
+    # the content from the original files to the output zip.
+    my $zip = Archive::Zip->new();
+    if (my $res_dir = $job->result_dir) {
+        log_debug "Adding results from $res_dir to archive";
+        $zip->addTree($res_dir, 'testresults/') if -d $res_dir;
+    }
+    my $assets = $job->jobs_assets;
+    while (my $ja = $assets->next) {
+        my $asset = $ja->asset;
+        my $disk_file = $asset->disk_file;
+        if ($disk_file && -e $disk_file) {
+            log_debug 'Adding asset ' . $asset->name . ' to archive';
+            my $zip_path = $asset->type . '/' . $asset->name;
+            if (-d $disk_file) {
+                $zip->addTree($disk_file, $zip_path . '/');
+            }
+            else {
+                $zip->addFile($disk_file, $zip_path);
+            }
+        }
+    }
+    cleanup_cache();
+    my $temp_path = $cache_dir->child($archive_name . '.tmp.' . random_hex(8));
+    my $status = $zip->writeToFileNamed($temp_path->to_string);
+    unless ($status == AZ_OK) {
+        $temp_path->remove if -e $temp_path;
+        close $lock_fh;
+        log_error "Failed to write archive for job $job_id to $temp_path: 
$status";
+        die "Failed to create archive: $status";
+    }
+    rename $temp_path->to_string, $archive_path->to_string
+      or die "Could not rename $temp_path to $archive_path: $!";
+    close $lock_fh;
+    return $archive_path;
+}
+
+sub is_cache_limit_exceeded ($current_size, $available, $total) {
+    return $current_size > get_cache_limit() || ($available / $total * 100) < 
get_min_free_percentage();
+}
+
+sub cleanup_cache () {
+    my $cache_dir = path(archive_cache_dir());
+    return unless -d $cache_dir;
+    try { _perform_cache_cleanup($cache_dir) }
+    catch ($e) { log_error "Failed to cleanup archive cache: $e" }    # 
uncoverable statement
+}
+
+sub _perform_cache_cleanup ($cache_dir) {
+    my ($available, $total) = check_df($cache_dir->to_string);
+    my $archives = $cache_dir->list->grep(sub { $_->basename =~ 
/^job_\d+\.zip$/ })->map(
+        sub {
+            my $stat = $_->stat;
+            {path => $_, mtime => $stat->mtime, size => $stat->size};
+        })->sort(sub { $a->{mtime} <=> $b->{mtime} });
+    my $current_cache_size = 0;
+    $archives->each(sub ($item, @) { $current_cache_size += $item->{size} });
+
+    return unless is_cache_limit_exceeded($current_cache_size, $available, 
$total);
+    log_info 'Archive cache exceeds limits (size: '
+      . human_readable_size($current_cache_size)
+      . '), cleaning up oldest archives';
+    my $target_size = get_cache_limit() * (get_watermark_percentage() / 100);
+    while ($current_cache_size > $target_size && (my $oldest = shift 
@$archives)) {
+        log_info 'Removing old archive ' . $oldest->{path};
+        $oldest->{path}->remove;
+        $current_cache_size -= $oldest->{size};
+    }
+}
+
+1;
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/openQA-5.1781621316.6d025b35/lib/OpenQA/Setup.pm 
new/openQA-5.1781712973.4c20e5c1/lib/OpenQA/Setup.pm
--- old/openQA-5.1781621316.6d025b35/lib/OpenQA/Setup.pm        2026-06-16 
16:48:36.000000000 +0200
+++ new/openQA-5.1781712973.4c20e5c1/lib/OpenQA/Setup.pm        2026-06-17 
18:16:13.000000000 +0200
@@ -287,6 +287,7 @@
             worker_limit_retry_delay => ONE_HOUR / 4,
             mcp_max_result_size => 500000,
             scheduled_product_min_storage_duration => 34,
+            create_zip_archive_limit => 2,
             throttle_failing_job_threshold => 2,
             throttle_failing_job_prio_step => 10,
             throttle_failing_job_history_length => 20,
@@ -298,6 +299,12 @@
         archiving => {
             archive_preserved_important_jobs => 0,
         },
+        job_details_archive => {
+            job_details_archive_cache_dir => undef,
+            job_details_archive_cache_limit_gb => 5,
+            job_details_archive_cache_min_free_percentage => 10,
+            job_details_archive_cache_watermark_percentage => 80,
+        },
         job_settings_ui => {
             keys_to_render_as_links => '',
             default_data_dir => 'data',
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' 
old/openQA-5.1781621316.6d025b35/lib/OpenQA/Shared/Plugin/Gru.pm 
new/openQA-5.1781712973.4c20e5c1/lib/OpenQA/Shared/Plugin/Gru.pm
--- old/openQA-5.1781621316.6d025b35/lib/OpenQA/Shared/Plugin/Gru.pm    
2026-06-16 16:48:36.000000000 +0200
+++ new/openQA-5.1781712973.4c20e5c1/lib/OpenQA/Shared/Plugin/Gru.pm    
2026-06-17 18:16:13.000000000 +0200
@@ -42,6 +42,7 @@
       OpenQA::Task::Needle::LimitTempRefs
       OpenQA::Task::Job::Limit
       OpenQA::Task::Job::ArchiveResults
+      OpenQA::Task::Job::CreateZipArchive
       OpenQA::Task::Job::FinalizeResults
       OpenQA::Task::Job::HookScript
       OpenQA::Task::Job::Restart
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' 
old/openQA-5.1781621316.6d025b35/lib/OpenQA/Task/Job/CreateZipArchive.pm 
new/openQA-5.1781712973.4c20e5c1/lib/OpenQA/Task/Job/CreateZipArchive.pm
--- old/openQA-5.1781621316.6d025b35/lib/OpenQA/Task/Job/CreateZipArchive.pm    
1970-01-01 01:00:00.000000000 +0100
+++ new/openQA-5.1781712973.4c20e5c1/lib/OpenQA/Task/Job/CreateZipArchive.pm    
2026-06-17 18:16:13.000000000 +0200
@@ -0,0 +1,37 @@
+# Copyright SUSE LLC
+# SPDX-License-Identifier: GPL-2.0-or-later
+
+package OpenQA::Task::Job::CreateZipArchive;
+use Mojo::Base 'Mojolicious::Plugin', -signatures;
+
+use OpenQA::Archive;
+use Feature::Compat::Try;
+use Time::Seconds;
+
+sub register ($self, $app, @args) {
+    $app->minion->add_task(create_zip_archive => \&_create_zip_archive);
+}
+
+sub _create_zip_archive ($minion_job, $job_id) {
+    my $app = $minion_job->app;
+
+    # avoid running too many archive generation jobs in parallel
+    my $limit = $app->config->{misc_limits}->{create_zip_archive_limit} // 2;
+    return $minion_job->retry({delay => ONE_MINUTE})
+      unless my $guard = $app->minion->guard('create_zip_archive_task', 
ONE_DAY, {limit => $limit});
+
+    my $job = $app->schema->resultset('Jobs')->find($job_id);
+    unless ($job) {
+        $minion_job->finish("Job $job_id not found");
+        return undef;
+    }
+    try {
+        OpenQA::Archive::create_job_archive($job);
+        $minion_job->finish;
+    }
+    catch ($e) {
+        $minion_job->fail("Failed to create archive: $e");
+    }
+}
+
+1;
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' 
old/openQA-5.1781621316.6d025b35/lib/OpenQA/WebAPI/Controller/File.pm 
new/openQA-5.1781712973.4c20e5c1/lib/OpenQA/WebAPI/Controller/File.pm
--- old/openQA-5.1781621316.6d025b35/lib/OpenQA/WebAPI/Controller/File.pm       
2026-06-16 16:48:36.000000000 +0200
+++ new/openQA-5.1781712973.4c20e5c1/lib/OpenQA/WebAPI/Controller/File.pm       
2026-06-17 18:16:13.000000000 +0200
@@ -7,8 +7,10 @@
 BEGIN { $ENV{MAGICK_THREAD_LIMIT} = 1; }
 
 use OpenQA::Needles;
+use OpenQA::Archive;
 use OpenQA::Utils qw(:DEFAULT prjdir assetdir imagesdir);
 use File::Basename;
+use Feature::Compat::Try;
 use File::Spec;
 use File::Spec::Functions 'catfile';
 use Data::Dump 'pp';
@@ -113,6 +115,17 @@
     $self->reply->file($file);
 }
 
+sub download_archive ($self) {
+    # so minimal security is good enough
+    my $path = $self->param('archivepath');
+    return $self->reply->not_found if $path =~ qr/\.\./;
+    my $file = path(OpenQA::Archive::archive_cache_dir(), $path)->to_string;
+    return $self->reply->not_found unless -f $file && -r _;
+    $self->res->headers->content_type('application/zip');
+    $self->res->headers->content_disposition("attachment; filename=$path;");
+    $self->reply->file($file);
+}
+
 sub test_asset ($self) {
     my $jobid = $self->param('testid');
     my %cond = ('me.id' => $jobid);
@@ -145,6 +158,38 @@
     return $self->redirect_to($url);
 }
 
+sub archive ($self) {
+    return $self->reply->not_found unless my $job = 
$self->schema->resultset('Jobs')->find($self->param('testid'));
+    my $job_id = $job->id;
+    my $archive_name = "job_$job_id.zip";
+    my $cache_dir = path(OpenQA::Archive::archive_cache_dir());
+    my $archive_path = $cache_dir->child($archive_name);
+    return $self->_redirect_to_archive($archive_path) if -e $archive_path;
+    try {
+        if (my $minion = $self->app->can('minion') ? $self->app->minion : 
undef) {
+            $self->app->log->info("Enqueuing create_zip_archive for job 
$job_id");
+            $minion->enqueue(
+                create_zip_archive => [$job_id],
+                {notes => {job_id => $job_id}, priority => -10, expire => 
3600});
+        }
+        else {
+            # Fallback for environments without a fully-functional Minion 
(e.g. tests)
+            return 
$self->_redirect_to_archive(OpenQA::Archive::create_job_archive($job));
+        }
+    }
+    catch ($e) {
+        $self->app->log->error("Archive creation failed: $e");
+        return $self->render(text => 'Internal Server Error', status => 500);
+    }
+    $self->render('test/archive_wait', job => $job);
+}
+
+sub _redirect_to_archive ($self, $archive_path) {
+    my $url = $self->url_for('download_archive', archivepath => 
$archive_path->basename);
+    $self->app->log->debug("redirect to $url");
+    return $self->redirect_to($url);
+}
+
 sub _set_headers ($self, $path) {
     my $filename = basename($path);
     my $headers = $self->res->headers;
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/openQA-5.1781621316.6d025b35/lib/OpenQA/WebAPI.pm 
new/openQA-5.1781712973.4c20e5c1/lib/OpenQA/WebAPI.pm
--- old/openQA-5.1781621316.6d025b35/lib/OpenQA/WebAPI.pm       2026-06-16 
16:48:36.000000000 +0200
+++ new/openQA-5.1781712973.4c20e5c1/lib/OpenQA/WebAPI.pm       2026-06-17 
18:16:13.000000000 +0200
@@ -151,6 +151,7 @@
     my $require_auth_for_assets = $config->{auth}->{require_for_assets};
     my $assets_r = $require_auth_for_assets ? $auth_any_user : $r;
     
$assets_r->get('/assets/*assetpath')->name('download_asset')->to('file#download_asset');
+    
$assets_r->get('/archives/*archivepath')->name('download_archive')->to('file#download_archive');
 
     my $test_path = '/tests/<testid:num>';
     my $test_r = $r->any($test_path);
@@ -179,6 +180,7 @@
     $test_r->get('/file/#filename')->name('test_file')->to('file#test_file');
     
$test_r->get('/settings/:dir/*link_path')->name('filesrc')->to('test#show_filesrc');
     $test_r->get('/video' => sub ($c) { $c->render_testfile('test/video') 
})->name('video');
+    
$test_r->under('/')->to('session#ensure_user')->get('/archive')->name('test_archive')->to('file#archive');
     $test_r->get('/logfile' => sub ($c) { $c->render_testfile('test/logfile') 
})->name('logfile');
     # adding assetid => qr/\d+/ doesn't work here. wtf?
     
$test_r->get('/asset/<assetid:num>')->name('test_asset_id')->to('file#test_asset');
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/openQA-5.1781621316.6d025b35/t/60-archive-download.t 
new/openQA-5.1781712973.4c20e5c1/t/60-archive-download.t
--- old/openQA-5.1781621316.6d025b35/t/60-archive-download.t    1970-01-01 
01:00:00.000000000 +0100
+++ new/openQA-5.1781712973.4c20e5c1/t/60-archive-download.t    2026-06-17 
18:16:13.000000000 +0200
@@ -0,0 +1,335 @@
+#!/usr/bin/env perl
+
+# Copyright SUSE LLC
+# SPDX-License-Identifier: GPL-2.0-or-later
+
+use Mojo::Base -signatures;
+use Test::Most;
+use Test::Warnings ':report_warnings';
+use Test::Mojo;
+use Mojo::File qw(path tempdir);
+use FindBin;
+use lib "$FindBin::Bin/lib", 
"$FindBin::Bin/../external/os-autoinst-common/lib";
+use Test::MockObject;
+use Test::MockModule;
+
+use OpenQA::Test::Case;
+use OpenQA::App;
+use OpenQA::WebAPI;
+use OpenQA::Utils qw(resultdir assetdir);
+use OpenQA::Archive;
+use Archive::Zip qw(:ERROR_CODES :CONSTANTS);
+
+my $tmp = tempdir();
+$ENV{OPENQA_BASEDIR} = $tmp->to_string;
+$tmp->child('openqa', 'db')->make_path;
+
+my $case = OpenQA::Test::Case->new;
+my $schema = $case->init_data;
+my $app = OpenQA::WebAPI->new;
+OpenQA::App->set_singleton($app);
+my $t = Test::Mojo->new($app);
+
+$ENV{OPENQA_JOB_DETAILS_ARCHIVE_CACHE_DIR} = $tmp->child('cache')->to_string;
+my $cache_dir = path($ENV{OPENQA_JOB_DETAILS_ARCHIVE_CACHE_DIR});
+$cache_dir->make_path;
+
+subtest 'Archive download' => sub {
+    my $job = $schema->resultset('Jobs')->create(
+        {
+            DISTRI => 'archtest',
+            VERSION => '1.0',
+            FLAVOR => 'test',
+            ARCH => 'x86_64',
+            TEST => 'testjob',
+            state => 'done',
+            result => 'passed',
+        });
+    $job->create_result_dir;
+    $job->update({result_dir => $job->result_dir});
+    my $res_dir_str = $job->result_dir;
+    die 'result_dir is not set' unless $res_dir_str;
+    my $res_dir = path($res_dir_str);
+    $res_dir->make_path;
+    $res_dir->child('details-test.json')->spew('{"test": "data"}');
+    $res_dir->child('ulogs')->make_path->child('test.log')->spew('log data');
+    my $asset = $schema->resultset('Assets')->create(
+        {
+            type => 'iso',
+            name => 'test.iso',
+        });
+    $schema->resultset('JobsAssets')->create(
+        {
+            job_id => $job->id,
+            asset_id => $asset->id,
+        });
+    my $asset_path = path(assetdir(), 'iso', 'test.iso');
+    $asset_path->dirname->make_path;
+    $asset_path->spew('iso data');
+    $t->get_ok('/tests/' . $job->id . '/archive')->status_is(302)
+      ->header_is('Location' => '/login?return_page=%2Ftests%2F' . $job->id . 
'%2Farchive');
+    $t->get_ok('/');
+    ok $case->login($t, 'admin'), 'Logged in as admin';
+    $t->get_ok('/tests/' . $job->id . 
'/archive')->status_is(302)->header_like('Location' => qr|/archives/job_|);
+    my $archive_url = $t->tx->res->headers->location;
+    $t->get_ok('/tests/' . $job->id . '/downloads_ajax')->status_is(200)
+      ->element_exists('a[title="Download all test results and assets as a ZIP 
archive"]');
+    
$t->get_ok($archive_url)->status_is(200)->content_type_is('application/zip')
+      ->header_is('Content-Disposition' => 'attachment; filename=job_' . 
$job->id . '.zip;');
+    my $zip_content = $t->tx->res->body;
+    my $zip_file = $tmp->child('downloaded.zip');
+    $zip_file->spew($zip_content);
+    my $zip = Archive::Zip->new();
+    is $zip->read($zip_file->to_string), AZ_OK, 'Zip is valid';
+    ok $zip->memberNamed('testresults/details-test.json'), 'Contains test 
results';
+    ok $zip->memberNamed('testresults/ulogs/test.log'), 'Contains ulogs';
+    ok $zip->memberNamed('iso/test.iso'), 'Contains assets';
+    is $zip->contents('testresults/details-test.json'), '{"test": "data"}', 
'Result content is correct';
+    is $zip->contents('iso/test.iso'), 'iso data', 'Asset content is correct';
+};
+
+subtest 'Archive with large files' => sub {
+    my $job = $schema->resultset('Jobs')->create(
+        {
+            DISTRI => 'archtest',
+            VERSION => '1.0',
+            FLAVOR => 'test',
+            ARCH => 'x86_64',
+            TEST => 'large_job',
+            state => 'done',
+            result => 'passed',
+        });
+    my $res_dir = path($job->create_result_dir);
+    my $large_file = $res_dir->child('large.bin');
+    # 50MB is enough to test without taking too long
+    my $fh = $large_file->open('>');
+    for (1 .. 50 * 1024) {
+        print $fh 'A' x 1024;
+    }
+    $fh->close;
+
+    my $archive_path = OpenQA::Archive::create_job_archive($job);
+    ok -e $archive_path, 'Archive with large file created';
+    my $zip = Archive::Zip->new();
+    is $zip->read($archive_path->to_string), AZ_OK, 'Large zip is valid';
+    my $member = $zip->memberNamed('testresults/large.bin');
+    ok $member, 'Contains large file';
+    is $member->uncompressedSize, 50 * 1024 * 1024, 'Size is correct';
+};
+
+subtest 'Archive caching' => sub {
+    my $job_id = $schema->resultset('Jobs')->first->id;
+    my $cache_file = 
path($ENV{OPENQA_JOB_DETAILS_ARCHIVE_CACHE_DIR})->child("job_$job_id.zip");
+    ok -e $cache_file, 'Archive is cached';
+    my $mtime = $cache_file->stat->mtime;
+    utime $mtime - 10, $mtime - 10, $cache_file->to_string;
+    $mtime = $cache_file->stat->mtime;
+    $t->get_ok('/tests/' . $job_id . '/archive')->status_is(302);
+    $t->get_ok($t->tx->res->headers->location)->status_is(200);
+    is $cache_file->stat->mtime, $mtime, 'Cached file was reused';
+};
+
+subtest 'Hide "Download All" button when no content' => sub {
+    my $job = $schema->resultset('Jobs')->create(
+        {
+            DISTRI => 'archtest',
+            VERSION => '1.0',
+            FLAVOR => 'test',
+            ARCH => 'x86_64',
+            TEST => 'empty_job',
+            state => 'done',
+            result => 'passed',
+        });
+    $t->get_ok('/tests/' . $job->id . '/downloads_ajax')->status_is(200)
+      ->element_exists_not('a[title="Download all test results and assets as a 
ZIP archive"]');
+};
+
+subtest 'Cache limit calculation' => sub {
+    my $orig_config = $app->config->{job_details_archive};
+    $app->config->{job_details_archive} = {
+        job_details_archive_cache_limit_gb => 1,
+        job_details_archive_cache_min_free_percentage => 20,
+        job_details_archive_cache_watermark_percentage => 50,
+        job_details_archive_cache_dir => 
$tmp->child('cache_config')->to_string,
+    };
+    my $orig_env = $ENV{OPENQA_JOB_DETAILS_ARCHIVE_CACHE_DIR};
+    delete $ENV{OPENQA_JOB_DETAILS_ARCHIVE_CACHE_DIR};
+    is OpenQA::Archive::archive_cache_dir(), 
$app->config->{job_details_archive}->{job_details_archive_cache_dir},
+      'Cache dir from config';
+    is OpenQA::Archive::get_cache_limit(), 1024 * 1024 * 1024, 'Limit in 
bytes';
+    is OpenQA::Archive::get_min_free_percentage(), 20, 'Min free percentage';
+    is OpenQA::Archive::get_watermark_percentage(), 50, 'Watermark percentage';
+    ok OpenQA::Archive::is_cache_limit_exceeded(2 * 1024 * 1024 * 1024, 100, 
1000), 'Limit exceeded by size';
+    ok OpenQA::Archive::is_cache_limit_exceeded(100, 10, 100), 'Limit exceeded 
by free percentage';
+    ok !OpenQA::Archive::is_cache_limit_exceeded(100, 30, 100), 'Limit not 
exceeded';
+    delete 
$app->config->{job_details_archive}->{job_details_archive_cache_dir};
+    like OpenQA::Archive::archive_cache_dir(), qr|/cache/archives$|, 'Default 
cache dir';
+    delete $app->config->{job_details_archive};
+    is OpenQA::Archive::get_cache_limit(), 5 * 1024 * 1024 * 1024, 'Default 
limit';
+    $ENV{OPENQA_JOB_DETAILS_ARCHIVE_CACHE_DIR} = $orig_env;
+    $app->config->{job_details_archive} = $orig_config;
+};
+
+subtest 'Cache cleanup execution' => sub {
+    my $mock_utils = Test::MockModule->new('OpenQA::Utils');
+    $mock_utils->mock(check_df => sub { (50, 1000) });
+    my $orig_config = $app->config->{job_details_archive};
+    $app->config->{job_details_archive} = {
+        job_details_archive_cache_limit_gb => 0.000000001,
+        job_details_archive_cache_min_free_percentage => 20,
+        job_details_archive_cache_watermark_percentage => 1,
+    };
+    my $now = time;
+    for my $i (100 .. 105) {
+        my $f = $cache_dir->child("job_$i.zip");
+        $f->spew("data $i" . ('x' x 100000));
+        utime $now - (200 - $i), $now - (200 - $i), $f->to_string;
+    }
+    my @initial = $cache_dir->list->grep(sub { $_->basename =~ 
/^job_\d+\.zip$/ })->each;
+    OpenQA::Archive::cleanup_cache();
+    my @remaining = $cache_dir->list->grep(sub { $_->basename =~ 
/^job_\d+\.zip$/ })->each;
+    ok scalar(@remaining) < scalar(@initial), 'Some archives were removed 
during cleanup';
+    $app->config->{job_details_archive} = $orig_config;
+};
+
+subtest 'Create archive details' => sub {
+    my $mock_job = Test::MockObject->new;
+    $mock_job->set_always(id => 789);
+    $mock_job->set_always(result_dir => 
$tmp->child('results_dir_789')->to_string);
+    $tmp->child('results_dir_789')->make_path;
+    my $mock_asset = Test::MockObject->new;
+    $mock_asset->set_always(disk_file => 
$tmp->child('asset_dir_789')->to_string);
+    $mock_asset->set_always(name => 'my_asset_dir');
+    $mock_asset->set_always(type => 'other');
+    $tmp->child('asset_dir_789')->make_path;
+    $tmp->child('asset_dir_789')->child('file.txt')->spew('content');
+    my $mock_ja = Test::MockObject->new;
+    $mock_ja->set_always(asset => $mock_asset);
+    my $mock_assets_rs = Test::MockObject->new;
+    my @assets = ($mock_ja);
+    $mock_assets_rs->mock(next => sub { shift @assets });
+    $mock_job->set_always(jobs_assets => $mock_assets_rs);
+    my $archive_path = OpenQA::Archive::create_job_archive($mock_job);
+    ok -e $archive_path, 'Archive created with directory asset';
+    my $mock_asset_missing = Test::MockObject->new;
+    $mock_asset_missing->set_always(disk_file => 
$tmp->child('nonexistent_asset')->to_string);
+    $mock_asset_missing->set_always(name => 'missing_asset');
+    $mock_asset_missing->set_always(type => 'other');
+    my $mock_ja_missing = Test::MockObject->new;
+    $mock_ja_missing->set_always(asset => $mock_asset_missing);
+    my @assets_missing = ($mock_ja_missing);
+    $mock_assets_rs->mock(next => sub { shift @assets_missing });
+    $mock_job->set_always(id => 7890);
+    my $archive_path_missing = OpenQA::Archive::create_job_archive($mock_job);
+    ok -e $archive_path_missing, 'Archive created even with missing asset 
file';
+    my $archive_path2 = OpenQA::Archive::create_job_archive($mock_job);
+    is $archive_path2->to_string, $archive_path_missing->to_string, 'Returned 
existing archive';
+};
+
+subtest 'Create archive failure' => sub {
+    my $mock_job = Test::MockObject->new;
+    $mock_job->set_always(id => 444);
+    $mock_job->set_always(result_dir => undef);
+    $mock_job->set_always(jobs_assets => 
Test::MockObject->new->set_always(next => undef));
+    my $mock_zip_module = Test::MockModule->new('Archive::Zip::Archive');
+    $mock_zip_module->mock(
+        writeToFileNamed => sub {
+            my ($self, $file) = @_;
+            path($file)->spew('dummy');
+            return AZ_IO_ERROR;
+        });
+    throws_ok { OpenQA::Archive::create_job_archive($mock_job) } qr/Failed to 
create archive/, 'Throws on zip failure';
+};
+
+subtest 'CreateZipArchive task' => sub {
+    require OpenQA::Task::Job::CreateZipArchive;
+    my $mock_minion_job = Test::MockObject->new;
+    $mock_minion_job->set_always(app => $app);
+    my $mock_schema_obj = Test::MockObject->new;
+    my $mock_rs = Test::MockObject->new;
+    $mock_schema_obj->set_always(resultset => $mock_rs);
+    $mock_rs->set_always(find => undef);
+    my $mock_app_module = Test::MockModule->new('OpenQA::WebAPI');
+    $mock_app_module->mock(schema => sub { $mock_schema_obj });
+    $mock_minion_job->set_true('finish');
+    OpenQA::Task::Job::CreateZipArchive::_create_zip_archive($mock_minion_job, 
4567);
+    $mock_minion_job->called_ok('finish', 'Finished with job not found 
message');
+    my $mock_job = Test::MockObject->new;
+    $mock_job->set_always(id => 4567);
+    $mock_rs->set_always(find => $mock_job);
+    my $mock_archive_module = Test::MockModule->new('OpenQA::Archive');
+    $mock_archive_module->mock(create_job_archive => sub { 
path('/tmp/dummy.zip') });
+    OpenQA::Task::Job::CreateZipArchive::_create_zip_archive($mock_minion_job, 
4567);
+    $mock_minion_job->called_ok('finish', 'Finished successfully');
+    $mock_archive_module->mock(create_job_archive => sub { die 'creation 
error' });
+    $mock_minion_job->set_true('fail');
+    OpenQA::Task::Job::CreateZipArchive::_create_zip_archive($mock_minion_job, 
4567);
+    $mock_minion_job->called_ok('fail', 'Failed correctly on error');
+
+    # Verify create_zip_archive_limit config is respected
+    my $mock_minion = Test::MockModule->new('Minion');
+    my @guard_calls;
+    $mock_minion->mock(
+        guard => sub {
+            my ($self, $name, $duration, $options) = @_;
+            push @guard_calls, {name => $name, duration => $duration, options 
=> $options};
+            return 'mock_guard';
+        });
+    my $orig_limit = $app->config->{misc_limits}->{create_zip_archive_limit};
+
+    # default limit (2)
+    delete $app->config->{misc_limits}->{create_zip_archive_limit};
+    $mock_archive_module->mock(create_job_archive => sub { 
path('/tmp/dummy.zip') });
+    @guard_calls = ();
+    OpenQA::Task::Job::CreateZipArchive::_create_zip_archive($mock_minion_job, 
4567);
+    is_deeply \@guard_calls, [{name => 'create_zip_archive_task', duration => 
86400, options => {limit => 2}}],
+      'called guard with default limit';
+
+    # custom configured limit (5)
+    $app->config->{misc_limits}->{create_zip_archive_limit} = 5;
+    @guard_calls = ();
+    OpenQA::Task::Job::CreateZipArchive::_create_zip_archive($mock_minion_job, 
4567);
+    is_deeply \@guard_calls, [{name => 'create_zip_archive_task', duration => 
86400, options => {limit => 5}}],
+      'called guard with custom configured limit';
+
+    $app->config->{misc_limits}->{create_zip_archive_limit} = $orig_limit;
+    $mock_minion->unmock_all;
+};
+
+subtest 'Controller extra tests' => sub {
+    my $job = $schema->resultset('Jobs')->create(
+        {
+            DISTRI => 'archtest',
+            VERSION => '1.0',
+            FLAVOR => 'test',
+            ARCH => 'x86_64',
+            TEST => 'job_fail',
+            state => 'done',
+            result => 'passed',
+        });
+    $t->get_ok('/archives/..%2fetc%2fpasswd')->status_is(404);
+    $t->get_ok('/archives/nonexistent.zip')->status_is(404);
+    my $mock_minion = Test::MockObject->new;
+    my $mock_app_module = Test::MockModule->new('OpenQA::WebAPI');
+    $mock_app_module->mock(minion => sub { $mock_minion });
+    my $orig_can = $app->can('can');
+    $mock_app_module->mock(
+        can => sub ($self, $method) {
+            return 1 if $method eq 'minion';
+            return $orig_can ? $self->$orig_can($method) : 
UNIVERSAL::can($self, $method);    ## no critic (ProhibitUniversalCan)
+        });
+    $mock_minion->set_true('enqueue');
+
+    $case->login($t, 'admin');
+
+    $t->get_ok('/tests/' . $job->id . 
'/archive')->status_is(200)->content_like(qr/Preparing Archive for Job/);
+    $mock_minion->called_ok('enqueue', 'Minion job enqueued');
+    $mock_minion->clear;
+    $t->get_ok('/tests/' . $job->id . '/archive')->status_is(200);
+    $mock_minion->called_ok('enqueue', 'Minion job enqueued again');
+
+    $mock_minion->mock(enqueue => sub { die 'Enqueue failed' });
+    $t->get_ok('/tests/' . $job->id . 
'/archive')->status_is(500)->content_is('Internal Server Error');
+};
+
+done_testing;
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/openQA-5.1781621316.6d025b35/t/api/15-search.t 
new/openQA-5.1781712973.4c20e5c1/t/api/15-search.t
--- old/openQA-5.1781621316.6d025b35/t/api/15-search.t  2026-06-16 
16:48:36.000000000 +0200
+++ new/openQA-5.1781712973.4c20e5c1/t/api/15-search.t  2026-06-17 
18:16:13.000000000 +0200
@@ -4,6 +4,10 @@
 
 use Test::Most;
 use Test::Mojo;
+use Mojo::Base -signatures;
+use Test::MockModule;
+use Mojo::File qw(tempdir path);
+use File::Copy::Recursive qw(dircopy);
 
 use FindBin;
 use lib "$FindBin::Bin/../lib", 
"$FindBin::Bin/../../external/os-autoinst-common/lib";
@@ -11,6 +15,19 @@
 use OpenQA::Test::Case;
 
 OpenQA::Test::Case->new->init_data;
+
+my $utils_mock = Test::MockModule->new('OpenQA::Utils');
+my $tempdir = tempdir("$FindBin::Script-XXXX", TMPDIR => 1);
+my $opensuse_src = path($FindBin::Bin, '..', 'data', 'openqa', 'share', 
'tests', 'opensuse')->realpath;
+my $opensuse_dest = $tempdir->child('opensuse');
+dircopy($opensuse_src, $opensuse_dest) or die "copy failed: $!";
+
+$utils_mock->redefine(
+    testcasedir => sub ($distri = undef, $version = undef, $rootfortests = 
undef) {
+        return $tempdir->child($distri) if defined $distri;
+        return "$tempdir";
+    });
+
 my $t = Test::Mojo->new('OpenQA::WebAPI');
 $t->app->config->{rate_limits}->{search} = 10;
 
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' 
old/openQA-5.1781621316.6d025b35/templates/webapi/test/archive_wait.html.ep 
new/openQA-5.1781712973.4c20e5c1/templates/webapi/test/archive_wait.html.ep
--- old/openQA-5.1781621316.6d025b35/templates/webapi/test/archive_wait.html.ep 
1970-01-01 01:00:00.000000000 +0100
+++ new/openQA-5.1781712973.4c20e5c1/templates/webapi/test/archive_wait.html.ep 
2026-06-17 18:16:13.000000000 +0200
@@ -0,0 +1,20 @@
+% layout 'bootstrap';
+% title 'Preparing Archive - ' . $job->id;
+
+% content_for 'head' => begin
+%= asset 'archive_wait.js'
+% end
+
+<div class="container mt-5">
+    <div class="row justify-content-center">
+        <div class="col-md-8 text-center">
+            <h3>Preparing Archive for Job <%= $job->id %></h3>
+            <p class="lead">Please wait while the ZIP archive is being 
created. This might take a while for large jobs.</p>
+            <div class="spinner-border text-primary my-4" role="status">
+                <span class="sr-only">Loading…</span>
+            </div>
+            <div id="archive-error" class="alert alert-danger d-none my-4" 
role="alert"></div>
+            <p>You will be automatically redirected once the download is 
ready.</p>
+        </div>
+    </div>
+</div>
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' 
old/openQA-5.1781621316.6d025b35/templates/webapi/test/downloads.html.ep 
new/openQA-5.1781712973.4c20e5c1/templates/webapi/test/downloads.html.ep
--- old/openQA-5.1781621316.6d025b35/templates/webapi/test/downloads.html.ep    
2026-06-16 16:48:36.000000000 +0200
+++ new/openQA-5.1781712973.4c20e5c1/templates/webapi/test/downloads.html.ep    
2026-06-17 18:16:13.000000000 +0200
@@ -1,3 +1,12 @@
+% my $assets = $job->jobs_assets;
+% if (current_user && (@$resultfiles || @$ulogs || $assets->count)) {
+<div class="mb-3">
+    %= link_to url_for('test_archive', testid => $job->id) => (class => 'btn 
btn-primary', title => 'Download all test results and assets as a ZIP archive', 
rel => 'nofollow') => begin
+        <i class="fa fa-download"></i> Download All (ZIP)
+    % end
+</div>
+% }
+
 % if(@$resultfiles) {
     <h5>Result files</h5>
     %= include 'test/result_file_list', resultfiles => $resultfiles, 
is_userfile => 0
@@ -7,7 +16,6 @@
     %= include 'test/result_file_list', resultfiles => $ulogs, is_userfile => 1
 % }
 
-% my $assets = $job->jobs_assets;
 % while (my $asset = $assets->next) {
     % $asset = $asset->asset;
     % unless ($asset->hidden) {
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/openQA-5.1781621316.6d025b35/tools/ci/autoinst.sha 
new/openQA-5.1781712973.4c20e5c1/tools/ci/autoinst.sha
--- old/openQA-5.1781621316.6d025b35/tools/ci/autoinst.sha      2026-06-16 
16:48:36.000000000 +0200
+++ new/openQA-5.1781712973.4c20e5c1/tools/ci/autoinst.sha      2026-06-17 
18:16:13.000000000 +0200
@@ -1 +1 @@
-f2147fa66cb9bb318a482e8ef855a1ea2d73f2a0
\ No newline at end of file
+0160257e12a992ea3e50db86210830c8ef9419b4
\ No newline at end of file

++++++ openQA.obsinfo ++++++
--- /var/tmp/diff_new_pack.iOo5kv/_old  2026-06-18 18:41:33.715378367 +0200
+++ /var/tmp/diff_new_pack.iOo5kv/_new  2026-06-18 18:41:33.743379535 +0200
@@ -1,5 +1,5 @@
 name: openQA
-version: 5.1781621316.6d025b35
-mtime: 1781621316
-commit: 6d025b359e32990634ae363427a655a3e0fa6a5d
+version: 5.1781712973.4c20e5c1
+mtime: 1781712973
+commit: 4c20e5c1fb18222dff688b8881d542045b2eacde
 

Reply via email to