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
