Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Allow scheduling a (example) product via the web UI #5933

Merged
merged 7 commits into from
Sep 23, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions assets/assetpack.def
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,7 @@
< ../node_modules/ace-builds/src-min/mode-perl.js
< ../node_modules/ace-builds/src-min/mode-yaml.js
< ../node_modules/ace-builds/src-min/mode-diff.js
< ../node_modules/ace-builds/src-min/mode-ini.js

! step_edit.js
< javascripts/needleeditor.js
Expand Down Expand Up @@ -161,6 +162,9 @@
< javascripts/running.js
< javascripts/disable_status_updates.js [mode==test]

! create_tests.js
< javascripts/create_tests.js

! job_next_previous.js
< javascripts/job_next_previous.js

Expand Down
82 changes: 82 additions & 0 deletions assets/javascripts/create_tests.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
function getNonEmptyFormParams(form) {
const formData = new FormData(form);
const queryParams = new URLSearchParams();
for (const [key, value] of formData) {
if (value.length > 0) {
queryParams.append(key, value);
}
}
return queryParams;
}

function setupAceEditor(elementID, mode) {
const element = document.getElementById(elementID);
const initialValue = element.textContent;
const editor = ace.edit(element, {
mode: mode,
maxLines: Infinity,
tabSize: 2,
useSoftTabs: true
});
editor.session.setUseWrapMode(true);
editor.initialValue = initialValue;
return editor;
}

function setupCreateTestsForm() {
window.scenarioDefinitionsEditor = setupAceEditor('create-tests-scenario-definitions', 'ace/mode/yaml');
window.settingsEditor = setupAceEditor('create-tests-settings', 'ace/mode/ini');
}

function resetCreateTestsForm() {
window.scenarioDefinitionsEditor.setValue(window.scenarioDefinitionsEditor.initialValue, -1);
window.settingsEditor.setValue(window.settingsEditor.initialValue, -1);
}

function createTests(form) {
event.preventDefault();

const scenarioDefinitions = window.scenarioDefinitionsEditor.getValue();
const queryParams = getNonEmptyFormParams(form);
window.settingsEditor
.getValue()
.split('\n')
.map(line => line.split('=', 2))
.forEach(setting => queryParams.append(setting[0].trim(), (setting[1] ?? '').trim()));
queryParams.append('async', true);
if (scenarioDefinitions.length > 0) {
queryParams.append('SCENARIO_DEFINITIONS_YAML', scenarioDefinitions);
}
$.ajax({
url: form.dataset.postUrl,
method: form.method,
data: queryParams.toString(),
success: function (response) {
const id = response.scheduled_product_id;
const url = `${form.dataset.productlogUrl}?id=${id}`;
addFlash('info', `Tests have been scheduled, checkout the <a href="${url}">product log</a> for details.`);
},
error: function (xhr, ajaxOptions, thrownError) {
addFlash('danger', 'Unable to create tests: ' + (xhr.responseJSON?.error ?? xhr.responseText ?? thrownError));
}
});
}

function cloneTests(link) {
const loadingIndication = document.createElement('span');
loadingIndication.append('Cloning test distribution …');
link.parentNode.replaceWith(loadingIndication);
$.ajax({
url: document.getElementById('flash-messages').dataset.cloneUrl,
method: 'POST',
success: function (response) {
location.reload();
},
error: function (xhr, ajaxOptions, thrownError) {
const retryButton = '<br/><a class="btn btn-primary" href="#" onclick="cloneTests(this)">Retry</a>';
const error = xhr.responseJSON?.error ?? xhr.responseText ?? thrownError;
loadingIndication.parentNode.classList.replace('alert-primary', 'alert-danger');
loadingIndication.innerHTML = `Unable to clone: ${error} ${retryButton}`;
}
});
}
8 changes: 8 additions & 0 deletions etc/openqa/openqa.ini
Original file line number Diff line number Diff line change
Expand Up @@ -325,3 +325,11 @@ concurrent = 0
# home =
# username = openqa-user
# ssh_key_file = ~/.ssh/id_rsa

# Override form values for creating example test
#[test_preset example]
#title = Create example test
#info = Some info that will show up on the "Create … -> Example test" page
#casedir = https://github.com/os-autoinst/os-autoinst-distri-example.git
#distri = example
#build = openqa
16 changes: 10 additions & 6 deletions lib/OpenQA/Setup.pm
Original file line number Diff line number Diff line change
Expand Up @@ -198,8 +198,15 @@ sub read_config ($app) {
influxdb => {
ignored_failed_minion_jobs => '',
},
carry_over => \%CARRY_OVER_DEFAULTS
);
carry_over => \%CARRY_OVER_DEFAULTS,
'test_preset example' => {
title => 'Create example test',
info => 'Parameters to create an example test have been pre-filled in the following form. '
. 'You can simply submit the form as-is to test your openQA setup.',
casedir => 'https://github.com/os-autoinst/os-autoinst-distri-example.git',
distri => 'example',
build => 'openqa',
});
Martchus marked this conversation as resolved.
Show resolved Hide resolved

# in development mode we use fake auth and log to stderr
my %mode_defaults = (
Expand Down Expand Up @@ -247,10 +254,7 @@ sub read_config ($app) {
}
for my $k (@known_keys) {
my $v = $cfg && $cfg->val($section, $k);
$v
//= exists $mode_defaults{$app->mode}{$section}->{$k}
? $mode_defaults{$app->mode}{$section}->{$k}
: $defaults{$section}->{$k};
$v //= $mode_defaults{$app->mode}{$section}->{$k} // $defaults{$section}->{$k};
$config->{$section}->{$k} = trim $v if defined $v;
}
}
Expand Down
2 changes: 2 additions & 0 deletions lib/OpenQA/WebAPI.pm
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,8 @@ sub startup ($self) {
$r->get('/search')->name('search')->to(template => 'search/search');

$r->get('/tests')->name('tests')->to('test#list');
$r->get('/tests/create')->name('tests_create')->to('test#create');
$op_auth->post('/tests/clone')->name('tests_clone')->to('test#clone');
# we have to set this and some later routes up differently on Mojo
# < 9 and Mojo >= 9.11
if ($Mojolicious::VERSION > 9.10) {
Expand Down
68 changes: 67 additions & 1 deletion lib/OpenQA/WebAPI/Controller/Test.pm
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@ use OpenQA::Utils;
use OpenQA::Jobs::Constants;
use OpenQA::Schema::Result::Jobs;
use OpenQA::Schema::Result::JobDependencies;
use OpenQA::Utils qw(determine_web_ui_web_socket_url get_ws_status_only_url);
use OpenQA::YAML qw(load_yaml);
use OpenQA::Utils qw(determine_web_ui_web_socket_url get_ws_status_only_url testcasedir);
use Mojo::ByteStream;
use Mojo::Util 'xml_escape';
use Mojo::File 'path';
Expand Down Expand Up @@ -116,6 +117,71 @@ sub list {
my ($self) = @_;
}

sub _load_test_preset ($self, $preset_key) {
return undef unless defined $preset_key;
# avoid reading INI file again on subsequent calls
state %presets;
return $presets{$preset_key} if exists $presets{$preset_key};
$presets{$preset_key} = undef;
# read preset from an INI section [test_presets/…] or fallback to defaults assigned on setup
my $config = $self->app->config;
return undef unless my $ini_config = $config->{ini_config};
my $ini_key = "test_preset $preset_key";
return $presets{$preset_key}
= $ini_config->SectionExists($ini_key)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I still don't like that we are creating some extra code for this specific feature to handle nested data, while we could do this in a more generic way in Setup.pm with the group feature.
Also it moves any validation we might want to do to runtime instead of server startup.
Since it's something internal we can probably change the implementation later, but just saying.
Not sure what others think.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I tried to keep it simple but chose the section name so the group feature can be used in the future for more generic parsing. So if we needed to parse this in a more generic way at some point it'll be possible without a breaking change to the config file format.

? {map { ($_ => $ini_config->val($ini_key, $_)) } $ini_config->Parameters($ini_key)}
: $config->{$ini_key};
}

sub _load_scenario_definitions ($self, $preset) {
return undef if exists $preset->{scenario_definitions};
return undef unless my $distri = $preset->{distri};
return undef unless my $casedir = testcasedir($distri, $preset->{version});
my $defs_yaml = eval { path($casedir, 'scenario-definitions.yaml')->slurp('UTF-8') };
if (my $error = $@) {
return $self->stash(flash_error => "Unable to read scenario definitions for the specified preset: $error")
unless $error =~ /no.*file/i;
my $info = Mojo::ByteStream->new(
qq(You first need to <a href="#" onclick="cloneTests(this)">clone the $distri test distribution</a>.));
return $self->stash(flash_info => $info);
}
my $defs = eval { load_yaml(string => $defs_yaml) };
return $self->stash(flash_error => "Unable to parse scenario definitions for the specified preset: $@") if $@;
my $e = join("\n", @{$self->app->validate_yaml($defs, 'JobScenarios-01.yaml')});
return $self->stash(flash_error => "Unable to validate scenarios definitions of the specified preset:\n$e") if $e;
$preset->{scenario_definitions} = $defs_yaml;
return undef unless my @products = values %{$defs->{products}};
return undef unless my @job_templates = keys %{$defs->{job_templates}};
$preset->{$_} //= $products[0]->{$_} for qw(distri version flavor arch);
$preset->{test} //= $job_templates[0];
}

sub create ($self) {
my $preset_key = $self->param('preset');
my $preset = $self->_load_test_preset($preset_key);
if (defined $preset) {
$self->stash(flash_info => $preset->{info});
$self->_load_scenario_definitions($preset);
}
elsif (defined $preset_key) {
$self->stash(flash_error => "The specified preset '$preset_key' does not exist.");
}
Martchus marked this conversation as resolved.
Show resolved Hide resolved
$self->stash(preset => ($preset // {}));
}

sub clone ($self) {
my $preset = $self->_load_test_preset($self->param('preset'));
return $self->render(status => 400, text => 'unable to find preset') unless defined $preset;
return $self->render(status => 400, text => 'preset has no distri') unless my $distri = $preset->{distri};
return $self->render(status => 400, text => 'preset has no casedir') unless my $casedir = $preset->{casedir};
$self->gru->enqueue_and_keep_track(
task_name => 'git_clone',
task_description => 'cloning test distribution',
task_args => {testcasedir($distri, $preset->{version}) => $casedir} # uncoverable statement
)->then(sub ($result) { $self->render(json => $result) }) # uncoverable statement
->catch(sub ($error, @) { $self->reply->gru_result($error, 400) });
}

sub get_match_param {
my ($self) = @_;

Expand Down
28 changes: 22 additions & 6 deletions script/fetchneedles
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,15 @@
: "${dist_name:=${dist:-"openSUSE"}}" # the display name, for the help message
: "${dist:="opensuse"}"
: "${giturl:="https://github.com/os-autoinst/os-autoinst-distri-opensuse.git"}"
: "${branch:="master"}"
: "${branch:=""}"
: "${email:="openqa@$HOST"}"
: "${username:="openQA web UI"}"
: "${product:="$dist"}"

: "${git_lfs:="0"}"
: "${needles_separate:="1"}"
: "${needles_giturl:="https://github.com/os-autoinst/os-autoinst-needles-opensuse.git"}"
: "${needles_branch:="master"}"
: "${needles_branch:=""}"

: "${updateall:="0"}"
: "${force:="0"}"
Expand All @@ -27,7 +27,7 @@ if [ "$1" = "-h" ] || [ "$1" = "--help" ]; then
exit
fi

dir="/var/lib/openqa/share/tests"
dir="${OPENQA_BASEDIR:-/var/lib}/openqa/share/tests"
if [ -w / ]; then
if [ ! -e "$dir/$dist" ]; then
mkdir -p "$dir/$dist"
Expand Down Expand Up @@ -72,24 +72,40 @@ deal_with_stale_lockfile() {
fi
}

# Determine the "main branch" for the remote origin unless a branch has been specified explicitly
# note: This relies on a file called `refs/remotes/origin/HEAD` with contents like `ref: refs/remotes/origin/master` being
# present under the `.git` directory. This should be the case for all recently cloned Git repositories. Otherwise
# one can just create the file making it point to the desired branch.
get_git_base() {
branch=$1
if [ "$branch" ]; then
echo "origin/$branch"
else
git rev-parse --abbrev-ref refs/remotes/origin/HEAD
fi
}

git_update() {
branch="${1:-"master"}"
deal_with_stale_lockfile
git gc --auto --quiet
git fetch -q origin

# Clear any uncommitted changes that would prevent a rebase
[ "$force" = 1 ] && git reset -q --hard HEAD
git rebase -q origin/"$branch" || fail 'Use force=1 to discard uncommitted changes before rebasing'
base=$(get_git_base "$1")
git rebase -q "$base" || fail 'Use force=1 to discard uncommitted changes before rebasing'
}

# For needles repos because of needle saving we might end up in conflict, i.e.
# detached HEAD so we need to repair this
git_update_needles() {
git_update "$needles_branch"
if [ "$(git rev-parse --abbrev-ref --symbolic-full-name HEAD)" = "HEAD" ]; then
base=$(get_git_base "$needles_branch")
needles_branch=${base#origin/}
git branch -D "$needles_branch"
git checkout -b "$needles_branch"
git branch "--set-upstream-to=origin/$needles_branch" "$needles_branch"
git branch "--set-upstream-to=$base" "$needles_branch"
git push origin "HEAD:$needles_branch"
fi
}
Expand Down
13 changes: 8 additions & 5 deletions t/17-build_tagging.t
Original file line number Diff line number Diff line change
Expand Up @@ -110,26 +110,29 @@ subtest 'tag on non-existent build does not show up' => sub {
is(scalar @tags, 1, 'only first build tagged');
};

my $tags_on_group = '#content a[href^=/tests/]';
my $tags_on_dashboard = 'a[href^=/tests/]';

subtest 'builds first tagged important, then unimportant disappear (poo#12028)' => sub {
post_comment_1001 'tag:0091:important';
post_comment_1001 'tag:0091:-important';
$t->get_ok('/group_overview/1001?limit_builds=1')->status_is(200);
my @tags = $t->tx->res->dom->find('a[href^=/tests/]')->map('text')->each;
my @tags = $t->tx->res->dom->find($tags_on_group)->map('text')->each;
is(scalar @tags, 2, 'only one build');
is($tags[0], 'Build87.5011', 'only newest build present');
};

subtest 'only_tagged=1 query parameter shows only tagged (poo#11052)' => sub {
$t->get_ok('/group_overview/1001?only_tagged=1')->status_is(200);
is(scalar @{$t->tx->res->dom->find('a[href^=/tests/]')}, 3, 'only one tagged build is shown (on group overview)');
is(scalar @{$t->tx->res->dom->find($tags_on_group)}, 3, 'three tagged builds shown (on group overview)');
$t->get_ok('/group_overview/1001?only_tagged=0')->status_is(200);
is(scalar @{$t->tx->res->dom->find('a[href^=/tests/]')}, 13, 'all builds shown again (on group overview)');
is(scalar @{$t->tx->res->dom->find($tags_on_group)}, 13, 'all builds shown again (on group overview)');

$t->get_ok('/dashboard_build_results?only_tagged=1')->status_is(200);
is(scalar @{$t->tx->res->dom->find('a[href^=/tests/]')}, 3, 'only one tagged build is shown (on index page)');
is(scalar @{$t->tx->res->dom->find($tags_on_dashboard)}, 3, 'three tagged builds shown (on index page)');
is(scalar @{$t->tx->res->dom->find('h2')}, 1, 'only one group shown anymore');
$t->get_ok('/dashboard_build_results?only_tagged=0')->status_is(200);
is(scalar @{$t->tx->res->dom->find('a[href^=/tests/]')}, 9, 'all builds shown again (on index page)');
is(scalar @{$t->tx->res->dom->find($tags_on_dashboard)}, 9, 'all builds shown again (on index page)');
is(scalar @{$t->tx->res->dom->find('h2')}, 2, 'two groups shown again');
};

Expand Down
3 changes: 3 additions & 0 deletions t/config.t
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,7 @@ subtest 'Test configuration default modes' => sub {
$test_config->{logging}->{level} = "debug";
$test_config->{global}->{service_port_delta} = 2;
is ref delete $config->{global}->{auto_clone_regex}, 'Regexp', 'auto_clone_regex parsed as regex';
ok delete $config->{'test_preset example'}, 'default values for example tests assigned';
is_deeply $config, $test_config, '"test" configuration';

# Test configuration generation with "development" mode
Expand All @@ -193,6 +194,7 @@ subtest 'Test configuration default modes' => sub {
$test_config->{_openid_secret} = $config->{_openid_secret};
$test_config->{global}->{service_port_delta} = 2;
delete $config->{global}->{auto_clone_regex};
delete $config->{'test_preset example'};
is_deeply $config, $test_config, 'right "development" configuration';

# Test configuration generation with an unknown mode (should fallback to default)
Expand All @@ -203,6 +205,7 @@ subtest 'Test configuration default modes' => sub {
$test_config->{auth}->{method} = "OpenID";
$test_config->{global}->{service_port_delta} = 2;
delete $config->{global}->{auto_clone_regex};
delete $config->{'test_preset example'};
delete $test_config->{logging};
is_deeply $config, $test_config, 'right default configuration';
};
Expand Down
1 change: 0 additions & 1 deletion t/data/openqa.ini

This file was deleted.

4 changes: 4 additions & 0 deletions t/data/openqa.ini
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
[test_preset bar]
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Because I had to add config values for testing instead of just testing with the default config. (We want to cover the part of the code that allows the users/admins to override values so we obviously need to supply this kind of configuration when running the test.)

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In other tests we just change the config on the fly to test specific features

title = Some preset
distri = does-not-exist
casedir = http://foo.git
10 changes: 10 additions & 0 deletions t/data/openqa/share/tests/example/scenario-definitions.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
products:
example:
distri: "example"
flavor: "DVD"
arch: "x86_64"
version: '0'
job_templates:
simple_boot:
product: "example"
Loading
Loading