1#!/usr/bin/env perl
2use strict;
3use warnings;
4
5=pod
6
7=head1 NAME
8
9docker-test.pl - run OpenXPKI tests in a Docker container
10
11=cut
12
13# Core modules
14use Cwd qw( realpath getcwd );
15use File::Copy;
16use File::Temp qw( tempdir );
17use FindBin qw( $Bin );
18use Getopt::Long;
19use IPC::Open3 qw( open3 );
20use List::Util qw( sum );
21use Pod::Usage;
22use POSIX ":sys_wait_h";
23use Symbol qw( gensym );
24
25# CPAN modules
26use File::Which qw();
27
28
29# $interactive
30#   1 = STDIN can be used for input
31#   0 = output will be hidden if execution takes less than 5 seconds
32sub execute {
33    my ($args, $interactive) = @_;
34
35    my $pid;
36    my $output;
37    if ($interactive) {
38        $pid = open3("<&STDIN", ">&STDOUT", 0, @$args);
39    }
40    else {
41        $output = gensym; # filehandle
42        $pid = open3(0, $output, 0, @$args);
43    }
44    my $tick = 0;
45    my $kid;
46    while (not $kid) {
47        # show output if command takes more than 5 sec
48        if (not $interactive and $tick > 10) {
49            while( my $lines = <$output> ) { print "$lines" }
50        }
51        $kid = waitpid($pid, WNOHANG); # wait for command to exit
52        last if $kid;
53        sleep 1; $tick++;
54    };
55
56    exit 1 if $? == -1; # execute failed: error message was already shown by system()
57
58    my $lines = ""; $lines = do { local $/; $lines = <$output> || "" } unless $interactive;
59    die sprintf "ERROR - '%s' died with signal %d\n%s\n", $args->[0], ($? & 127), $lines if ($? & 127);
60    die sprintf "ERROR - '%s' exited with code %d\n%s\n", $args->[0], $? >> 8, $lines   if ($? >> 8);
61}
62
63sub normalize_if_github_repo {
64    my ($repo) = @_;
65    return $repo =~ / \A [[:word:]-]+ \/ [[:word:]-]+ \Z /msx
66        ? "https://github.com/$repo.git"
67        : $repo;
68}
69
70sub get_branch_commit {
71    my ($dir) = @_;
72    my ($branch, $commit);
73
74    chdir($dir);
75
76    $branch = `git rev-parse --abbrev-ref HEAD`;
77    chomp $branch;
78    # if we are currently in a detached head (i.e. no branch name)
79    if ($branch eq 'HEAD') {
80        $commit = `git rev-parse --short HEAD`; # get commit ID
81        chomp $commit;
82        $branch = undef;
83    }
84    return ($branch, $commit);
85}
86
87
88my $project_root = realpath("$Bin/../");
89my $engine = File::Which::which('docker') || File::Which::which('podman') || die "Could not find 'docker' or 'podman'\n";
90
91#
92# Parse command line arguments
93#
94my ($help, $repo, $branch, $commit, $conf_repo, $conf_branch, $conf_commit, $test_all, @_only, $test_coverage, $batch);
95my $parseok = GetOptions(
96    'all'            => \$test_all,
97    'only=s'         => \@_only,
98    'cover|coverage' => \$test_coverage,
99    'c|commit=s'     => \$commit,
100    'b|branch=s'     => \$branch,
101    'r|repo=s'       => \$repo,
102    'confrepo=s'     => \$conf_repo,
103    'confbranch=s'   => \$conf_branch,
104    'confcommit=s'   => \$conf_commit,
105    'batch'          => \$batch,
106    'help'           => \$help,
107);
108
109my $test_only = join ',', @_only;
110
111pod2usage(-exitval => 1, -verbose => 0)
112    unless ($parseok and ($test_all or $test_only or $test_coverage or $help));
113
114pod2usage(-exitval => 0, -verbose => 2) if $help;
115
116my $mode_switches = sum ( map { $_ ? 1 : 0 } ($test_all, $test_only, $test_coverage) );
117die "ERROR: Please specify only one of: --all | --only | --cover\n" if $mode_switches > 1;
118
119#
120# Construct Docker arguments
121#
122my %docker_env = (
123    OXI_TEST_ONLY => undef,
124    OXI_TEST_ALL => undef,
125    OXI_TEST_COVERAGE => undef,
126    OXI_TEST_GITREPO => undef,
127    OXI_TEST_GITBRANCH => undef,
128    OXI_TEST_GITCOMMIT => undef,
129    OXI_TEST_CONFIG_GITREPO => undef,
130    OXI_TEST_CONFIG_GITBRANCH => undef,
131    OXI_TEST_CONFIG_GITCOMMIT => undef,
132    OXI_TEST_NONINTERACTIVE => undef,
133);
134my @docker_args = ();
135
136# Restricted set of tests specified?
137$docker_env{OXI_TEST_ONLY}     = $test_only if $test_only;
138$docker_env{OXI_TEST_ALL}      = $test_all if $test_all;
139$docker_env{OXI_TEST_COVERAGE} = $test_coverage if $test_coverage;
140$docker_env{OXI_TEST_NONINTERACTIVE} = 1 if $batch;
141
142#
143# Code repository
144#
145my $is_local_repo = 0;
146if ($repo) {
147    $docker_env{OXI_TEST_GITREPO} = normalize_if_github_repo($repo);
148}
149# default to current branch in case of local repo
150else {
151    push @docker_args, "-v", "$project_root:/repo";
152    $docker_env{OXI_TEST_GITREPO} = "/repo";
153    ($branch, $commit) = get_branch_commit($project_root);
154    $is_local_repo = 1;
155}
156
157$docker_env{OXI_TEST_GITBRANCH} = $branch if $branch;
158$docker_env{OXI_TEST_GITCOMMIT} = $commit if $commit;
159
160#
161# Configuration repository
162#
163if ($conf_repo) {
164    $docker_env{OXI_TEST_CONFIG_GITREPO} = normalize_if_github_repo($conf_repo);
165}
166# default
167else {
168    my $conf_dir = "$project_root/config";
169
170    # local code repo is used and local config repo exists
171    if ($is_local_repo and -f "$conf_dir/.git" and -f "$conf_dir/config.d/system/server.yaml") {
172        # use the local config's current commit
173        $docker_env{OXI_TEST_CONFIG_GITREPO} = "/repo/config";
174        ($conf_branch, $conf_commit) = get_branch_commit($conf_dir);
175    }
176    else {
177        # no local config repo: use default Github repo
178        $docker_env{OXI_TEST_CONFIG_GITREPO} = normalize_if_github_repo('openxpki/openxpki-config');
179    }
180}
181
182$docker_env{OXI_TEST_CONFIG_GITBRANCH} = $conf_branch if $conf_branch;
183$docker_env{OXI_TEST_CONFIG_GITCOMMIT} = $conf_commit if $conf_commit;
184
185push @docker_args,
186    map {
187        $docker_env{$_}
188            ? ("-e", sprintf "%s=%s", $_, $docker_env{$_})
189            : ()
190    }
191    sort keys %docker_env;
192
193#
194# Build container
195#
196print "\n====[ Build Docker image ]====\n";
197my @cmd;
198if ($batch) {
199    print "Skipping (batch mode)\n";
200}
201else {
202    `which sha256sum` or die "Sorry, I need the 'sha256sum' command line tool\n";
203
204    print "(might take more than 10 minutes when first run on a new host)\n";
205    # Make scripts accessible for "docker build" (Dockerfile).
206    # Only update TAR file if neccessary to prevent Docker from always rebuilding the image
207    my $tarfile = "$Bin/docker-test/scripts.tar";
208    my $olddir = getcwd; chdir $Bin;
209    # keep existing TAR file (and its timestamp) unless there were changes
210    if (-f $tarfile) {
211        # create a temp TAR file and compare checksums
212        `tar --create -f "$tarfile.new" scripts testenv`;
213        (my $checksum_old = `sha256sum "$tarfile"`) =~ s/^(\S+)\s+.*/$1/s;
214        (my $checksum_new = `sha256sum "$tarfile.new"`) =~ s/^(\S+)\s+.*/$1/s;
215        if ($checksum_new ne $checksum_old) {
216            move("$tarfile.new", $tarfile);
217        }
218        else {
219            unlink "$tarfile.new";
220        }
221    }
222    else {
223        `tar --create -f "$tarfile" scripts testenv`;
224    }
225    chdir $olddir;
226
227    @cmd = ( $engine, qw( build -t oxi-test ), "$Bin/docker-test");
228    execute \@cmd;
229}
230
231#
232# Run container
233#
234@cmd = ( $engine, qw( run -it --rm ), @docker_args, "oxi-test" );
235printf "\nExecuting: %s\n", join(" ", @cmd);
236execute \@cmd, 1;
237
238__END__
239
240=head1 SYNOPSIS
241
242docker-test.pl --all           [Options]
243
244docker-test.pl --only TESTSPEC [Options]
245
246docker-test.pl --cover         [Options]
247
248docker-test.pl --help
249
250Modes:
251
252    --all
253        Run all unit and QA tests
254
255    --only TESTSPEC
256        Run only the given tests (option can be specified multiple times)
257
258    --cover
259        Run code coverage tests instead of normal tests and copy the directory
260        containing the test results into the root of the local repository
261        (code-coverage-YYMMDD-HHMMSS)
262
263Options:
264
265    -c COMMIT
266    --commit COMMIT
267        Use the given COMMIT instead of "HEAD" (can be anything git understands)
268
269    -b BRANCH
270    --branch BRANCH
271        Use the given BRANCH instead of the current (local repo) or the default
272        (remote repo)
273
274    -r REPO
275    --repo REPO
276        Use the given (remote) REPOsitory instead of the local one where we are
277        currently in
278
279    --batch
280        Run the tests non-interactively in "batch"/"script" mode:
281        1. Do not try to rebuild the Docker image
282        2. In case of errors, do not open a shell in the container, just exit.
283
284    --help
285        Show full documentation
286
287=head1 DESCRIPTION
288
289Run unit and QA tests for OpenXPKI in a Docker container.
290
291You need a working Docker installation to run this script.
292On first execution a Docker image called "oxi-test" is built (might take
293more than 10 minutes).
294Then (on every call) a Docker container is created from the image in which
295the repo is cloned and the tests are executed (takes a few minutes).
296
297This means that nothing will be modified on your host system (in your local
298repository) except for --coverage, in that case the results are copied over.
299
300=head1 SYNTAX
301
302=head2 TESTSPEC
303
304Tests can be specified either by directory:
305
306    core/server/t/31_database
307    # same as:  t/31_database
308    qatest/backend/api2/
309
310or file name:
311
312    core/server/t/31_database/01-base.t
313    # same as:  t/31_database/01-base.t
314    qatest/backend/api2/10_list_profiles.t
315
316=head1 EXAMPLES
317
318docker-test.pl --all
319
320    Test the latest commit (not working dir!) of the current Git branch in your
321    local repo.
322
323docker-test.pl --only t/31_database --only t/45_session
324
325    Only run database and session related unit tests.
326
327docker-test.pl --coverage
328
329    Test the code coverage using "cover -test".
330
331docker-test.pl --all -b myfix
332
333    Test latest commit of branch "myfix" in your local repo.
334
335docker-test.pl --all -r openxpki/openxpki
336
337    Test latest commit of default branch in Github repository "openxpki/openxpki".
338
339docker-test.pl --all -r https://github.com/openxpki/openxpki.git
340
341    Same as above.
342
343=cut
344