#!/usr/bin/perl
# -*- perl -*-

#
# Author: Slaven Rezic
#
# Copyright (C) 2017,2018,2019,2020,2021,2022,2023,2024,2025,2026 Slaven Rezic. All rights reserved.
# This program is free software; you can redistribute it and/or
# modify it under the same terms as Perl itself.
#
# WWW:  https://github.com/eserte/bbbike
#

use FindBin;
use lib "$FindBin::RealBin/..", "$FindBin::RealBin/../lib";
use 5.010; # //

use Doit;
use Doit::Log;
use Doit::Util qw(in_directory copy_stat);

use Cwd qw(realpath);
use File::Basename qw(basename dirname);
use File::Glob qw(bsd_glob);
use File::Temp qw(tempdir);
use Getopt::Long;
use Hash::Util qw(lock_keys);
use POSIX qw(strftime WNOHANG);
use Time::HiRes ();

use BBBikeUtil qw(save_pwd2 bbbike_root);

my %codename_to_packagerepos;
# list generated with following command on mydebs system:
#     perl -ne 'print " $1" if /Codename: (\S+)/' /srv/www/debian-repo/conf/distributions ; echo
for my $codename (qw(squeeze lenny etch wheezy jessie stretch lucid precise trusty xenial bionic buster focal bullseye bookworm trixie)) {
    $codename_to_packagerepos{$codename}->{'mydebs.bbbike.de'} = 1;
}
for my $codename (qw(bionic focal jammy noble resolute)) {
    $codename_to_packagerepos{$codename}->{'bbbike-ppa'} = 1;
}
for my $codename (qw(trixie forky jammy noble resolute)) {
    $codename_to_packagerepos{$codename}->{'eserte-obs'} = 1;
}
my %codename_to_obs_repository = (
    trixie   => 'Debian_13',
    forky    => 'Debian_Testing',
    jammy    => 'xUbuntu_22.04',
    noble    => 'xUbuntu_24.04',
    resolute => 'xUbuntu_26.04',
);

sub usage (;$) {
    die((defined $_[0] ? $_[0]."\n\n" : '') . <<EOF);
usage: $0 [--dry-run] gui | cgi | ci | build-deb [options]
EOF
}

sub usage_gui (;$) {
    die((defined $_[0] ? $_[0]."\n\n" : '') . <<EOF);
usage: $0 [--dry-run] gui [--dist debian|ubuntu|...] [--distver bullseye|...] [--src local|github|github-fixed|github-archive|bbbike...deb] [--bbbike-prog ...] [--bbbike-args "..."] [--branch ...]
	 [--no-feature-pdf] [--no-feature-svg] [-no-feature-remote]
	 [--debug] [--no-install-recommends] [--install-suggests] [--invalidate-apt] [--docker-image owner/name:tag] [--share-data-osm] [--copy-bikepowerrc] [--hack-writable-data] [--ubuntu-mirror <mirror>]

Note that --install-suggests would install quite a lot...
EOF
}

sub usage_ci (;$) {
    die((defined $_[0] ? $_[0]."\n\n" : '') . <<EOF);
usage: $0 [--dry-run] ci [--dist debian|ubuntu|...] [--distver stretch|...] [--src local|github] [--perl-ver X.Y.Z] [--env KEY=VAL ...] [--branch ...]
	 [--with-data-build] [--no-cache] [--invalidate-apt] [--image-variant ...] [--docker-build-max-retry ...] [--ubuntu-mirror <mirror>]
EOF
}

sub usage_build_deb (;$) {
    die((defined $_[0] ? $_[0]."\n\n" : '') . <<EOF);
usage: $0 [--dry-run] build-dev [--dist debian|ubuntu|...] [--distver stretch|...] [--distfile BBBike-X.YY.tar.gz] [--continue]
	 [--invalidate-apt]
EOF
}

sub usage_cgi (;$) {
    die((defined $_[0] ? $_[0]."\n\n" : '') . <<EOF);
usage: $0 [--dry-run] cgi [--dist debian|ubuntu|...] [--distver stretch|...] [--test]
	 [--invalidate-apt] [--[no]mapserver] [--use-bbbike-ppa] [--no-force-ipv4] [--ubuntu-mirror <mirror>]
EOF
}

{
    my $src_setup_done;
    sub _dockerfile_frag_src ($) {
	my $optref = shift;
	my $dockerfile = '';
	if ($src_setup_done) {
	    info 'src setup in Dockerfile already done, skipping this time';
	    return $dockerfile;
	}

	# Get the source, either from github or local src
	my $branch_args = '';
	if ($optref->{branch}) {
	    $branch_args = " --branch $optref->{branch}";
	}
	if ($optref->{src} eq 'github') {
	    $dockerfile .= <<EOF;
RUN git clone --depth=1 https://github.com/eserte/bbbike.git$branch_args /bbbike
EOF
	} elsif ($optref->{src} eq 'local') {
	    $dockerfile .= <<EOF;
COPY bbbike /bbbike
EOF
	} else {
	    usage "Invalid --src value '$optref->{src}', only 'github' and 'local' are known";
	}
	$src_setup_done = 1;
	$dockerfile;
    }
}

sub _dockerfile_apt_auth ($) {
    my $optref = shift;
    my $dockerfile = '';

    if ($optref->{distver} =~ m{^(wheezy|jessie$)}) {
	$dockerfile .= <<EOF;
RUN echo "APT::Get::AllowUnauthenticated 1;" > /etc/apt/apt.conf.d/02allow-unsigned
EOF
    }
    $dockerfile;
}

sub _dockerfile_fix_sources_list ($) {
    my $optref = shift;
    my $dockerfile = '';

    if ($optref->{distver} =~ m{^(wheezy|jessie|stretch|buster)$}) {
	$dockerfile .= <<EOF;
RUN echo 'deb [check-valid-until=no] http://archive.debian.org/debian $optref->{distver} main'                   >  /etc/apt/sources.list
RUN echo 'deb [check-valid-until=no] http://archive.debian.org/debian-security/ $optref->{distver}/updates main' >> /etc/apt/sources.list
EOF
    } elsif ($optref->{distver} =~ m{^(precise)$}) {
	$dockerfile .= <<'EOF';
RUN perl -i -pe 's{http://archive.ubuntu.com/}{http://old-releases.ubuntu.com/}g' /etc/apt/sources.list $(perl -e 'print </etc/apt/sources.list.d/*.list>')
EOF
    }

    if ($optref->{'ubuntu-mirror'}) {
	my $mirror = $optref->{'ubuntu-mirror'};
	$mirror =~ s{/$}{};
	$dockerfile .= <<"EOF";
RUN perl -le 'for my \$f ("/etc/apt/sources.list", glob("/etc/apt/sources.list.d/*.list"), glob("/etc/apt/sources.list.d/*.sources")) { if (-f \$f) { system("perl", "-i", "-pe", "s{https?://[^/]*ubuntu\\\\.com/ubuntu(?=/|\\\$)}{$mirror}g", \$f) } }'
EOF
    }

    $dockerfile;
}

sub _dockerfile_invalidate_cache () {
    <<"EOF";
# Just a hack to make sure that the following lines
# are executed at least once a day
RUN echo @{[ POSIX::strftime("%F", localtime) ]}
EOF
}

sub _docker_build_with_retry ($$$;@) {
    my($doit, $docker_image_tag, $cmdline, %opts) = @_;
    my $max_retry = delete $opts{'max-retry'} || 2;
    die "Unhandled options: " . join(' ', %opts) if %opts;
    my @docker_build_cmd = (qw(docker build), @$cmdline);
    for my $try (1..$max_retry) {
	if (!eval {
	    $doit->system(@docker_build_cmd);
	    1;
	}) {
	    if ($try < $max_retry) {
		$doit->system(qw(docker pull), $docker_image_tag);
	    } else {
		error "Error running '@docker_build_cmd': $@";
	    }
	} else {
	    last;
	}
    }
}

sub gui {
    my($doit, %opt) = @_;
    lock_keys %opt;

    my $add_apt_args = "";
    if (!$opt{'install-recommends'}) {
	$add_apt_args .= " --no-install-recommends";
    }
    if ($opt{'install-suggests'}) {
	$add_apt_args .= " --install-suggests";
    }

    my @apikeyfiles = ('.opencageapikey');

    $doit->make_path("$ENV{HOME}/.docker-bbbike");

    my $docker_cmd;

    my $docker_image = $opt{'docker-image'} // "$opt{dist}:$opt{distver}";

    my $switched_user;

    my $dockerfile = <<EOF;
FROM $docker_image
EOF
    $dockerfile .= _dockerfile_invalidate_cache if $opt{'invalidate-apt'};
    $dockerfile .= _dockerfile_fix_sources_list(\%opt);
    $dockerfile .= _dockerfile_apt_auth(\%opt);

    # Use non-privileged user (same as host user) unless on Windows
    my $username;
    if ($^O ne 'MSWin32') {
	my $uid = $<;
	$username = (getpwuid($uid))[0];
	$dockerfile .= <<"EOF";
RUN uid=$uid; username=$username; \\
EOF
	$dockerfile .= <<'EOF';
    set -eux; \
    existing_user=$(getent passwd "$uid" | cut -d: -f1) || true; \
    if [ -n "$existing_user" ]; then \
        echo "UID $uid is taken by '$existing_user', renaming user to '$username'..."; \
        usermod -l "$username" "$existing_user"; \
        usermod -d "/home/$username" -m "$username"; \
        groupmod -n "$username" "$existing_user"; \
    else \
        useradd --non-unique --shell /bin/bash --uid "$uid" --comment '' --create-home "$username"; \
        passwd -d $username; \
    fi
EOF
    }

    # Gather required apt packages
    my @packages;
    my @optional_packages;
    if ($opt{src} =~ m{^(github|github-fixed|github-archive|local)$}) {
	push @packages, qw(perl-tk);
	if ($opt{'feature-pdf'}) {
	    push @packages, qw(libcairo-perl libpango-perl);
	}
	if ($opt{'feature-svg'}) {
	    push @packages, qw(librsvg2-bin);
	}
	if ($opt{'feature-remote'}) {
	    push @packages, qw(libwww-perl libxml-libxml-perl libgeo-metar-perl);
	}
	if ($opt{'feature-extras'}) {
	    # Very experimental. Packages might be unavailable for the current distro.
	    # for MultiMap plugin
	    push @optional_packages, qw(libgeo-proj4-perl);
	    # for Geocoder plugin
	    push @optional_packages, qw(libgeo-coder-osm-perl libgeo-coder-bing-perl);
	    # for Salesman plugin
	    push @optional_packages, qw(liblist-permutor-perl);
	    # sunrise/set in info window
	    push @optional_packages, qw(libastro-sunrise-perl);
	    # for fulltext search (search_anything)
	    push @optional_packages, qw(libstring-approx-perl);
	    # dillo is a very lightweight browser
	    # only capable displaying simple websites.
	    push @optional_packages, qw(dillo);
	}
	if ($opt{src} =~ m{^(github|github-fixed)$}) {
	    push @packages, 'git';
	} elsif ($opt{src} eq 'github-archive') {
	    push @packages, 'curl';
	}
    }
    if (defined $username) {
	push @packages, 'sudo';
    }

    # Install packages (if any defined)
    if (@packages || @optional_packages) {
	$dockerfile .= <<"EOF";
RUN apt-get update && apt-get install -qqy @packages
EOF
	if (@optional_packages) {
	    for my $optional_package (@optional_packages) {
		$dockerfile .= <<"EOF";
RUN apt-get install -qqy $optional_package || true
EOF
	    }
	}
    }

    # Setup sudoers
    if (defined $username) {
	$dockerfile .= <<"EOF";
RUN echo "$username ALL=(ALL) NOPASSWD:ALL" > /etc/sudoers.d/$username && chmod 0440 /etc/sudoers.d/$username
EOF
    }

    if ($opt{src} =~ m{^(github|github-fixed|github-archive|local)$}) {
	if ($opt{src} =~ m{^(github|github-fixed|github-archive)$}) {
	    $dockerfile .= <<'EOF';
RUN mkdir bbbike
EOF
	    if (defined $username) {
		$dockerfile .= <<"EOF";
RUN chown $username bbbike
USER $username
EOF
		$switched_user = 1;
	    }
	    my $branch_args = '';
	    if ($opt{branch}) {
		$branch_args = " --branch $opt{branch}";
	    }
	    if ($opt{src} eq 'github-fixed') {
		$dockerfile .= <<EOF;
RUN git clone --depth=1 https://github.com/eserte/bbbike.git$branch_args
EOF
		$docker_cmd = "bbbike/$opt{'bbbike-prog'} $opt{'bbbike-args'}";
	    } elsif ($opt{src} eq 'github-archive') {
		my $ref;
		if (!$opt{branch}) {
		    $ref = "heads/master";
		} elsif ($opt{branch} =~ m{^(RELEASE_|v)\d+_\d+}) { # guessing: looks like a tag
		    $ref = "tags/$opt{branch}";
		} else {
		    $ref = "heads/$opt{branch}";
		}
		$dockerfile .= <<EOF;
RUN curl -s -L https://github.com/eserte/bbbike/archive/refs/$ref.tar.gz | tar xz --strip-components=1 -C bbbike
EOF
		$docker_cmd = "bbbike/$opt{'bbbike-prog'} $opt{'bbbike-args'}";
	    } else {
		$docker_cmd = "git clone --depth=1 https://github.com/eserte/bbbike.git$branch_args && bbbike/$opt{'bbbike-prog'} $opt{'bbbike-args'}";
	    }
	} elsif ($opt{src} eq 'local') {
	    $docker_cmd = "/bbbike/$opt{'bbbike-prog'} $opt{'bbbike-args'}";
	}
    } elsif ($opt{src} =~ m{\.deb$}) {
	my $base_src = basename $opt{src};
	$dockerfile .= <<EOF;
COPY $base_src /tmp/
COPY install-deb /tmp/
WORKDIR /tmp
RUN apt-get update
RUN sh install-deb $add_apt_args ./$base_src
EOF
	if ($opt{'hack-writable-data'}) {
	    delete $opt{'hack-writable-data'};
	    if (defined $username) {
		$dockerfile .= <<EOF;
RUN chown -R $username /usr/lib/BBBike/data
EOF
	    }
	}

	$docker_cmd = "$opt{'bbbike-prog'} $opt{'bbbike-args'}";
    } else {
	usage "Invalid --src value '$opt{src}', only 'github', 'local' or a path to a .deb are possible";
    }

    if ($opt{'hack-writable-data'}) {
	error "--hack-writable-data works only with --src *.deb";
    }

    my $do_copy_bikepowerrc;
    if ($opt{'copy-bikepowerrc'}) {
	my $bikepowerrc = "$ENV{HOME}/.bikepowerrc";
	if (!-r $bikepowerrc) {
	    warning "No local $bikepowerrc available, cannot copy to docker image.";
	    $do_copy_bikepowerrc = 0;
	} else {
	    $dockerfile .= <<EOF;
COPY .bikepowerrc /home/$username/.bikepowerrc
RUN chown $username:$username /home/$username/.bikepowerrc
EOF
	    $do_copy_bikepowerrc = 1;
	}
    }

    $dockerfile .= <<'EOF';
ENV DOCKER_BBBIKE 1
EOF

    if (defined $username && !$switched_user) {
	$dockerfile .= <<"EOF";
USER $username
EOF
    }

    $dockerfile .= "CMD $docker_cmd\n";

    # Need to install socat on MacOSX, and do also some pre-checking.
    local %ENV = %ENV; # actually needed only for darwin
    if ($^O eq 'darwin') {
	if (!$ENV{DISPLAY}) {
	    error "No DISPLAY environment variable --- maybe Xquartz has to be started?";
	}
	if (!-S $ENV{DISPLAY}) {
	    error "Content of $ENV{DISPLAY} should be path to X socket";
	}
	$doit->add_component('brew');
	$doit->brew_install_packages(qw(socat));
    }

    my $docker_context_dir = tempdir("docker_context_XXXXXXXX", TMPDIR => 1, CLEANUP => 1);
    $doit->write_binary("$docker_context_dir/Dockerfile", $dockerfile);

    if ($opt{src} =~ m{\.deb$}) {
	for my $copy_def (
			  [$opt{src}, $docker_context_dir],
			  ["$FindBin::RealBin/../port/debian/helper/install-deb", $docker_context_dir],
			 ) {
	    _copy_p($doit, @$copy_def);
	}
    }

    if ($do_copy_bikepowerrc) {
	_copy_p($doit, "$ENV{HOME}/.bikepowerrc", $docker_context_dir);
    }

    my $tag = 'bbbike-gui-'.$opt{dist}.'-'.$opt{distver};
    in_directory {
	_docker_build_with_retry($doit, "$opt{dist}:$opt{distver}", ['--tag', $tag, '.']);
    } $docker_context_dir;

    my @volume_options;
    if ($opt{src} eq 'local') { push @volume_options, '-v', "$FindBin::RealBin/..:/bbbike:ro" }
    if (defined $username) {
	push @volume_options, '-v', "$ENV{HOME}/.docker-bbbike:/home/$username/.bbbike";
	for my $apikeyfile (@apikeyfiles) {
	    if (-r "$ENV{HOME}/$apikeyfile") {
		push @volume_options, '-v', "$ENV{HOME}/$apikeyfile:/home/$username/$apikeyfile:ro";
	    }
	}
    }
    if ($opt{'share-data-osm'}) {
	my $data_osm_directory = "$ENV{HOME}/.bbbike/data-osm";
	if (!-d $data_osm_directory) {
	    warning "No directory '$data_osm_directory' available, skipping --share-data-osm option";
	} else {
	    push @volume_options, '-v', "$data_osm_directory:/home/$username/.bbbike/data-osm"; # not read-only, host system may profit from downloads in container
	}
    }

    my @docker_run_args =
	(
	 # SYS_PTRACE is required for strace --- see
	 # http://blog.johngoulah.com/2016/03/running-strace-in-docker/
	 $opt{debug} ? ('--cap-add', 'SYS_PTRACE') : (),
	);
    if ($^O eq 'darwin') {
	my $scope_cleanups = Doit::ScopeCleanups->new;

	my $socat_port = 6098; # should be >= 6000
	my $display = ':' . ($socat_port-6000);
	my $socat_pid = fork;
	error "Can't fork: $!" if !defined $socat_pid;
	if ($socat_pid == 0) {
	    (my $escaped_DISPLAY = $ENV{DISPLAY}) =~ s{:}{\\:}g;
	    my @cmd = ('socat', 'TCP-LISTEN:'.$socat_port.',reuseaddr,fork', "UNIX-CLIENT:$escaped_DISPLAY");
	    info "Running @cmd";
	    exec @cmd;
	    die "Can't run <@cmd>: $!";
	}
	$scope_cleanups->add_scope_cleanup(sub { info "Killing socat process $socat_pid"; kill KILL => $socat_pid });

	# Race condition! Hopefully socat is faster than the following docker command!
	$doit->system('docker', 'run', '-ti',
		      @docker_run_args,
		      '--network=host',
		      @volume_options,
		      '-e', "DISPLAY=host.docker.internal$display",
		      $tag,
		     );
    } else {
	my $XSOCK = '/tmp/.X11-unix';
	my $XAUTH = '/tmp/.docker.xauth';
	$doit->run(['xauth', 'nlist', $ENV{DISPLAY}], '|', ['sed', '-e', 's/^..../ffff/'], '|', ['xauth', '-f', $XAUTH, 'nmerge', '-']);
	$doit->run(['docker', 'run', '-ti',
		    @docker_run_args,
		    '-v', "$XSOCK:$XSOCK", '-v', "$XAUTH:$XAUTH",
		    @volume_options,
		    '-e', "XAUTHORITY=$XAUTH", '-e', "DISPLAY=$ENV{DISPLAY}",
		    $tag,
		   ]);
    }
}

sub ci {
    my($doit, %opt) = @_;
    lock_keys %opt;

    my $t0 = Time::HiRes::time();

    my($use_eserte_obs, $use_bbbike_ppa) = (0, 0);
    $use_eserte_obs = $codename_to_packagerepos{$opt{'distver'}}->{'eserte-obs'} ? 1 : 0;
    if (!$use_eserte_obs) {
	$use_bbbike_ppa = $codename_to_packagerepos{$opt{'distver'}}->{'bbbike-ppa'} ? 1 : 0;
    }

    my %default_env =
	(
	 # build config
	 USE_MODPERL                => 1,
	 USE_SYSTEM_PERL            => 1,
	 USE_ESERTE_OBS		    => $use_eserte_obs,
	 USE_BBBIKE_PPA             => $use_bbbike_ppa,
	 # init_env_vars() is ineffective in this setup, so set it here
	 BBBIKE_LONG_TESTS          => 1,
	 BBBIKE_TEST_SKIP_MAPSERVER => 1,
	 BBBIKE_TEST_GUI            => 0,
	 BBBIKE_TEST_WITH_SELENIUM  => 0,
	 BBBIKE_TEST_FOR_LIVE       => 1,
	 PERL_CPANM_OPT             => "--mirror https://cpan.metacpan.org --mirror http://cpan.cpantesters.org",
	 CPAN_INSTALLER             => "cpm",
	);

    my $bbbike_rootdir = realpath("$FindBin::RealBin/..");
    my $docker_context_dir = tempdir("docker_context_XXXXXXXX", TMPDIR => 1, CLEANUP => 1);

    my $image = "$opt{dist}:$opt{distver}";
    if ($opt{'image-variant'}) {
	$image .= "-" . $opt{'image-variant'};
    }

    if (defined $opt{'cover-dir'}) {
	if ($opt{'with-data-build'}) {
	    error "--cover-dir conflicts with --with-data-build option";
	}
	if (!-d $opt{'cover-dir'}) {
	    $doit->make_path($opt{'cover-dir'});
	}
    }

    ## Build the Dockerfile
    my $dockerfile = <<EOF;
FROM $image

# travis defaults
ENV DEBIAN_FRONTEND noninteractive
# on newer systems sudo does not pass the environment, so another setting is needed
RUN echo "DEBIAN_FRONTEND=noninteractive" >> /etc/environment

EOF

    my($real_dist, $real_distver) = ($opt{dist}, $opt{distver});
    if ($real_dist eq 'perl') { # perl docker images are usually based on debian
	if ($real_distver =~ /-(stretch|buster|bullseye|bookworm)$/) {
	    $real_distver = $1;
	    $real_dist = 'debian';
	    warn "WARN: currently docker-bbbike has no support for non-system perl systems. Expect failures.\n";
	} else {
	    die "Cannot handle distver '$real_distver' (maybe the regexp need to be extended?)\n";
	}
    }

    # repository for agrep
    if ($real_dist eq 'debian') {
	my $debhost = $real_distver =~ m{^(wheezy|jessie|stretch|buster)$} ? 'archive.debian.org' : 'ftp.debian.org';
	$dockerfile .= <<EOF;
RUN echo "deb [check-valid-until=no] http://$debhost/debian/ $real_distver non-free" > /etc/apt/sources.list.d/$real_distver-non-free.list
EOF
    } elsif ($real_dist eq 'ubuntu') {
	if ($real_distver =~ m{^(precise|trusty)$}) {
	    $dockerfile .= <<EOF;
RUN echo "deb http://archive.ubuntu.com/ubuntu $real_distver multiverse" > /etc/apt/sources.list.d/$real_distver-multiverse.list
EOF
	} else {
	    # since Ubuntu 16.04 multiverse is enabled by default
	}
    } else {
	die "Unsupported dist $real_dist";
    }

    $dockerfile .= _dockerfile_fix_sources_list({%opt, dist => $real_dist, distver => $real_distver});

    if ($real_distver eq 'precise') {
	# http/1.1 pipelining used, but mydebs.bbbike.org cannot
	# do it (response: 400 Bad Request). Workaround from
	# http://bnpcs.blogspot.de/2010/11/disable-http-pipelining-in-ubuntu.html
	$dockerfile .= <<EOF;
RUN echo 'Acquire::http::Pipeline-Depth "0";' >> /etc/apt/apt.conf.d/00no-pipeline
EOF
    }

    # In case of unstable networks (seen for some jobs in github
    # actions). Recommendation from https://askubuntu.com/a/1107071/207243
    # Also set a delay, otherwise the next attempt will be done immediately.
    $dockerfile .= <<EOF;
RUN echo 'Acquire::Retries "3"; Acquire::Retry-Delay "10";' > /etc/apt/apt.conf.d/80-retries
EOF

    $dockerfile .= _dockerfile_apt_auth(\%opt);

    # basic packages
    $dockerfile .= _dockerfile_invalidate_cache if $opt{'invalidate-apt'};
    $dockerfile .= <<EOF;
RUN apt-get update -qq && apt-get install -qqy git cpanminus libssl-dev wget
EOF
    if (
	($real_dist eq 'ubuntu' && $real_distver ne 'trusty') ||
	$real_dist eq 'debian'
       ) {
	# Lacking packages in precise and bionic (18.04) (and possibly debian/stretch)
	$dockerfile .= <<EOF;
RUN apt-get install -qqy sudo make
EOF
    }

    if ($opt{'perl-ver'}) {
	(my $short_perl_ver = $opt{'perl-ver'}) =~ s{\.\d+$}{};
	# Probably travis has only perls for ubuntu trusty and precise
	my $distnumver = { ubuntu => {
				       resolute=> '26.04',
				       noble   => '24.04',
				       jammy   => '22.04',
				       focal   => '20.04',
				       bionic  => '18.04',
				       xenial  => '16.04',
				       trusty  => '14.04',
				       precise => '12.04',
				     } }->{$real_dist}->{$real_distver};
	if (!$distnumver) {
	    error "No support for --perl-ver and $real_dist:$real_distver";
	}
	# alternatively the tarballs are also available on https://storage.googleapis.com/travis-ci-language-archives/perl/binaries/$real_dist/$distnumver.04/x86_64/perl-$short_perl_ver.tar.bz2
	my $perlbrew_root = "/home/travis/perl5/perlbrew";
	$dockerfile .= <<EOF;
RUN apt-get install -qqy bzip2
RUN wget https://s3.amazonaws.com/travis-perl-archives/binaries/$real_dist/$distnumver/x86_64/perl-$short_perl_ver.tar.bz2
RUN tar xf perl-$short_perl_ver.tar.bz2
RUN [ -x $perlbrew_root/perls/$short_perl_ver/bin/perl ]
ENV PATH=$perlbrew_root/bin:$perlbrew_root/perls/$short_perl_ver/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
RUN [ -x $perlbrew_root/perls/$short_perl_ver/bin/cpanm -o -x $perlbrew_root/bin/cpanm ] || (apt-get install -qqy make && cpan App::cpanminus < /dev/null)
RUN [ -x $perlbrew_root/perls/$short_perl_ver/bin/cpanm -o -x $perlbrew_root/bin/cpanm ]
ENV PERLBREW_ROOT=$perlbrew_root
ENV PERLBREW_PERL=$short_perl_ver
EOF
	$default_env{USE_SYSTEM_PERL} = '';
    } else {
	# install dummy perlbrew, so at least "perlbrew off" works
	$dockerfile .= <<EOF;
RUN [ -x /usr/bin/perlbrew ] || (echo "#!/bin/sh" > /usr/bin/perlbrew && chmod 755 /usr/bin/perlbrew)
EOF
	if ($opt{dist} eq 'perl') {
	    my($short_perl_ver) = $opt{distver} =~ /^(\d+\.\d+)/;
	    if (!$short_perl_ver) {
		error "Cannot parse perl version from '$opt{distver}'";
	    }
	    $dockerfile .= <<EOF;
ENV PERLBREW_PERL=$short_perl_ver
# Some perl images (e.g. 5.39.9-bookworm) start in /usr/src/app instead of /
WORKDIR /
EOF
	}
    }
    $dockerfile .= <<EOF;
COPY bbbike/port/ci/ci-functions.sh /
RUN cat /ci-functions.sh >> ~/.bash_profile
EOF

    # apply default environment, possibly changed by --env options
    my %changed_env; # used later for git-test-note feature list
    my %effective_env;
    for my $key (sort keys %default_env) {
	my $val;
	if (exists $opt{env}->{$key}) {
	    $val = delete $opt{env}->{$key};
	    $changed_env{$key} = $val;
	} else {
	    $val = $default_env{$key};
	}
	$dockerfile .= <<EOF;
ENV $key "$val"
EOF
	$effective_env{$key} = $val;
    }

    if (!$effective_env{USE_SYSTEM_PERL}) {
	# If a non-system perl is used, then
	# install_perl_dependencies() needs Makefile.PL, so the src
	# needs to be setup early
	$dockerfile .= _dockerfile_frag_src(\%opt);
    }	

    # Emulate .travis.yml
    $dockerfile .= <<EOF;
ENV CI_BUILD_DIR /bbbike
ENV BBBIKE_CI true
WORKDIR bbbike

# "-e" -> init_travis must not be run anymore
# init_env_vars does not work in a Dockerbuild environment. Set some env vars manually
ENV CODENAME $real_distver

ENV BBBIKE_DOCKER 1
EOF

    # Apply --build-env
    for my $key (sort keys %{ $opt{'build-env'} }) {
	$dockerfile .= <<EOF;
ENV $key "$opt{'build-env'}->{$key}"
EOF
    }

    $dockerfile .= <<EOF;
RUN /bin/bash --login -e -c "wrapper init_perl"
RUN /bin/bash --login -e -c "wrapper init_apt"
RUN /bin/bash --login -e -c "wrapper install_non_perl_dependencies"
RUN /bin/bash --login -e -c "wrapper install_old_perl_dependencies"
RUN /bin/bash --login -e -c "wrapper install_perl_testonly_dependencies"
RUN /bin/bash --login -e -c "wrapper install_webserver_dependencies"
RUN /bin/bash --login -e -c "wrapper install_selenium"
RUN /bin/bash --login -e -c "wrapper install_perl_dependencies"
EOF

    $dockerfile .= _dockerfile_frag_src(\%opt); # may be a no-op if already run

    $dockerfile .= <<EOF;
RUN /bin/bash --login -e -c "wrapper init_cgi_config"
RUN /bin/bash --login -e -c "wrapper fix_cgis"
RUN /bin/bash --login -e -c "wrapper init_webserver_config"

EOF

    # Apply the rest of --env
    for my $key (sort keys %{ $opt{env} }) {
	$dockerfile .= <<EOF;
ENV $key $opt{env}->{$key}
EOF
    }

    # Dependencies for data building
    if ($opt{'with-data-build'}) {
	my $pkwalify_distname;
	if (
	    ($real_dist eq 'debian' && $real_distver =~ m{^(squeeze|wheezy|jessie)$}) ||
	    ($real_dist eq 'ubuntu' && $real_distver =~ m{^(trusty)$})
	   ) {
	    $pkwalify_distname = 'libkwalify-perl'; # available in mydebs.bbbike.de
	} else {
	    $pkwalify_distname = 'pkwalify'; # available in official repos
	}
	$dockerfile .= <<EOF
RUN apt-get install -qqy \\
    libdata-compare-perl \\
    libdatetime-event-easter-perl \\
    libdatetime-event-recurrence-perl \\
    libdatetime-format-iso8601-perl \\
    $pkwalify_distname \\
    libyaml-syck-perl \\
    bmake
EOF
    }

    # Dependencies for test coverage
    if (defined $opt{'cover-dir'}) {
	$dockerfile .= <<EOF
RUN apt-get install -qqy \\
    libdevel-cover-perl
EOF
    }

    # XXX global config file for mapserver 8.x
    # XXX review and maybe remove again
    if ($real_distver =~ /(bookworm|noble|resolute)/) {
	warning "Temporary hack: provide /etc/mapserver.conf until better solutions exist";
	$dockerfile .= <<'EOF';
RUN if [ "$BBBIKE_TEST_SKIP_MAPSERVER" != "1" ]; then cp /bbbike/mapserver/brb/mapserver.conf /etc; fi
EOF
    }

    # Start all services and finally run the test suite
    $dockerfile .= qq{CMD /bin/bash --login -e -c "wrapper start_webserver && wrapper start_xserver && wrapper init_webserver_environment && wrapper start_selenium && wrapper init_data && perl Makefile.PL && make distcheck && };
    if (defined $opt{'cover-dir'}) {
	$dockerfile .= qq{make ext coverage && used_config};
    } elsif (!$opt{'with-data-build'}) {
	$dockerfile .= qq{HARNESS_TIMER=1 make test HARNESS_OPTIONS=j$opt{jobs} && used_config};
    } else {
	# ... except in with-data-build mode; actual test will run later (see below)
	$dockerfile .= qq{while true; do sleep 3600; done};
    }
    $dockerfile .= qq{"\n};
    # "make test" should be last command --- check scripts may look at the end of the generated log
    ## End of Dockerfile building

    $doit->write_binary("$docker_context_dir/Dockerfile", $dockerfile);
    if ($opt{src} eq 'local') {
	$doit->mkdir("$docker_context_dir/bbbike");
	in_directory {
	    $doit->run(
		       ['git', 'ls-files', '-z'], '|',
		       ['rsync', '-a', '--files-from=-', '-0', '--no-dirs', '--whole-file', '.', "$docker_context_dir/bbbike"],
		      );
	} $bbbike_rootdir;
    } else {
	$doit->make_path("$docker_context_dir/bbbike/port/ci");
	$doit->copy("$bbbike_rootdir/port/ci/ci-functions.sh", "$docker_context_dir/bbbike/port/ci/");
	# mtime has to be preserved, otherwise older docker (<1.8?)
	# will take mtime into account when calculating cache checksums
	# see https://github.com/moby/moby/pull/12031
	my $mtime = (stat("$bbbike_rootdir/port/ci/ci-functions.sh"))[9];
	$doit->utime($mtime, $mtime, "$docker_context_dir/bbbike/port/ci/ci-functions.sh");
    }

    in_directory {
	my $scope_cleanups = Doit::ScopeCleanups->new;
	(my $tag_suffix = $image) =~ s{:}{-}g;
	my $tag = 'bbbike-ci-' . $tag_suffix;
	my @build_options;
	if ($opt{'docker-build-max-retry'}) {
	    push @build_options, 'max-retry' => $opt{'docker-build-max-retry'};
	}

	eval { $doit->system(qw(docker rmi), "$tag-old") };
	eval { $doit->system(qw(docker tag), $tag, "$tag-old") };
	_docker_build_with_retry($doit, $image, ['--tag', $tag, ($opt{'no-cache'} ? '--no-cache' : ()), '.'], @build_options);
	eval { $doit->system(qw(docker rm), $tag) };

	my @errors;
	if (!$opt{'with-data-build'}) {
	    my @docker_cmd = (qw(docker run), ($opt{'keep-container'} ? () : qw(--rm)), qw(--name), $tag);
	    if (defined $opt{'cover-dir'}) {
		push @docker_cmd, '-v', $opt{'cover-dir'}.':/bbbike/cover_db';
	    }
	    push @docker_cmd, $tag;
	    eval {
		$doit->system(@docker_cmd);
	    };
	    if ($@) {
		push @errors, "docker run failed: $@";
	    }
	} else {
	    $doit->system(qw(docker run -d --name), $tag, $tag);

	    # in with-data-build mode two test suites run: code and data test
	    $scope_cleanups->add_scope_cleanup(sub { $doit->system(qw(docker kill), $tag) });
	    my @errors;

	    # XXX hmmm --- probably it would be a good idea to wait until the initial CMD was already done
	    eval {
		$doit->system(qw(docker exec), $tag, 'sh', '-c', 'cd data && bmake all slow-checks really-slow-checks');
	    };
	    if ($@) {
		push @errors, "data build errored: $@";
	    }

	    eval {
		$doit->system(qw(docker exec), $tag, qw(env HARNESS_TIMER=1 make test), "HARNESS_OPTIONS=j$opt{jobs}")
	    };
	    if ($@) {
		push @errors, "code test errored: $@";
	    }
	}

	if (!$doit->is_dry_run) {
	    my $t1 = Time::HiRes::time();
	    info sprintf "Run time: %.1fs", $t1-$t0;
	}

	{
	    my @test_note_cmd;
	    push @test_note_cmd, @errors ? '--fail' : '--pass';
	    push @test_note_cmd, '--docker';
	    push @test_note_cmd, '--spec', $image;
	    for my $key (sort keys %changed_env) {
		push @test_note_cmd, '--feature', $key.'='.$changed_env{$key};
	    }
	    if ($opt{'perl-ver'}) {
		push @test_note_cmd, '--feature', 'perl=' . $opt{'perl-ver'};
	    }
	    if (!$doit->is_dry_run) {
		info "Use the following as test note command:\n\tgit-test-note @test_note_cmd\n";
	    }
	}

	if (@errors) {
	    error "Tests failed:\n" . join("\n", @errors);
	}
    } $docker_context_dir;
}

sub build_deb {
    my($doit, %opt) = @_;
    lock_keys %opt;

    my($distfile, $distvname);
    if ($opt{distfile}) {
	$distfile = $opt{distfile};
	($distvname = basename($distfile)) =~ s{.tar.gz$}{};
    } else {
	require BBBikeBuildUtil;
	my $bsdmake = BBBikeBuildUtil::get_pmake(fallback => 0);

	if (!$opt{continue} || !-e "Makefile") {
	    $doit->system($^X, "Makefile.PL");
	}
	chomp($distvname = $doit->info_qx($bsdmake, "-VDISTVNAME"));
	$distfile = "$distvname.tar.gz";
	if (!$opt{continue} || !-s $distfile) {
	    $doit->system($bsdmake, "dist");
	}
    }
    if (!-s $distfile) {
	error "Expected distfile $distfile is missing";
    }
    my $distbasefile = basename $distfile;

    # Look into tarball for Debian version
    my $dist_tempdir = tempdir("bbbike_deb_XXXXXXXX", TMPDIR => 1, CLEANUP => 1);
    $doit->system("tar", "-C", $dist_tempdir, "-xf", $distfile, "$distvname/BBBikeVar.pm");
    require Safe;
    my $safe = Safe->new;
    $safe->rdo("$dist_tempdir/$distvname/BBBikeVar.pm");
    my $stable_version = $safe->reval('$BBBike::STABLE_VERSION');
    $stable_version or error "Can't get STABLE_VERSION";
    my $debian_version = $safe->reval('$BBBike::DEBIAN_VERSION');
    $debian_version or error "Can't get DEBIAN_VERSION";

    # Prepare docker environment
    my $docker_context_dir = tempdir("docker_context_XXXXXXXX", TMPDIR => 1, CLEANUP => 1);
    $doit->copy($distfile, $docker_context_dir);
    my $origfile = "bbbike_${stable_version}.orig.tar.gz";
    $doit->copy("$docker_context_dir/$distbasefile", "$docker_context_dir/$origfile"); # XXX why?
    $doit->system('rsync', '-a', 'port/debian/', "$docker_context_dir/debian/");
    my $debfile = "bbbike_${debian_version}_all.deb";

    my $dockerfile = <<EOF;
FROM $opt{dist}:$opt{distver}
EOF
    $dockerfile .= _dockerfile_invalidate_cache if $opt{'invalidate-apt'};
    $dockerfile .= <<EOF;
RUN apt-get update && apt-get install -qqy dpkg-dev lintian rsync debhelper
RUN mkdir /bbbike
COPY $distbasefile /bbbike
COPY $origfile /bbbike
COPY /debian/ /bbbike/debian/
WORKDIR /bbbike
RUN tar xf $distbasefile && cd $distvname && rsync -a ../debian/ debian/ && dpkg-buildpackage -us -uc -rfakeroot -D
RUN lintian $debfile
CMD /bbbike/debian/helper/install-deb ./$debfile && cp -f $debfile /hosttmp
EOF
    $doit->write_binary("$docker_context_dir/Dockerfile", $dockerfile);
    in_directory {
	my $tag = "bbbike-deb-$opt{dist}-$opt{distver}";
	$doit->system("docker", "build", "--tag", $tag, ".");
	$doit->system("docker", "run", "-v", "/tmp:/hosttmp", $tag);
	$doit->system("ls", "-al", "/tmp/$debfile");
    } $docker_context_dir;
}

sub cgi {
    my($doit, %opt) = @_;
    lock_keys %opt;

    my $pwd = save_pwd2;
    chdir bbbike_root or die "Can't chdir: $!";

    my $docker_host = $opt{'force-ipv4'} ? '0.0.0.0' : '';
    my $docker_port = 5000;

    my $use_bbbike_ppa;
    if (defined $opt{'use-bbbike-ppa'}) {
	$use_bbbike_ppa = $opt{'use-bbbike-ppa'};
    } elsif ($codename_to_packagerepos{$opt{'distver'}}->{'bbbike-ppa'}) {
	$use_bbbike_ppa = 1;
	info "bbbike-ppa support exists for $opt{dist}:$opt{distver} --- using it";
    }
    my $use_eserte_obs;
    if ($codename_to_packagerepos{$opt{'distver'}}->{'eserte-obs'}) {
	$use_eserte_obs = 1;
	info "eserte-obs support exists for $opt{dist}:$opt{distver} --- using it";
    }

    # Prepare docker environment
    my $docker_context_dir = tempdir("docker_context_XXXXXXXX", TMPDIR => 1, CLEANUP => 1);
    require ExtUtils::Manifest;
    ExtUtils::Manifest::manicopy(ExtUtils::Manifest::maniread(), $docker_context_dir, "cp");
    # Additional files needed not listed in MANIFEST
    for my $f ("cgi/GNUmakefile", "cgi/Makefile", "miscsrc/b2gmake") {
	my($from, $todir) = ("$FindBin::RealBin/../$f", "$docker_context_dir/" . dirname($f));
	_copy_p($doit, $from, $todir);
    }
    if ($opt{mapserver}) {
	# XXX Actually it would be easier to copy everything in the repository, not just the files mentioned in MANIFEST
	for my $f ("data/Makefile", "data/Makefile.mapfiles", "data/Makefile.garmin", "data/Makefile.vars", "data/doit.pl", bsd_glob("data/*-orig"), "data/temp_blockings/bbbike-temp-blockings.pl", bsd_glob("data/temp_blockings/*.bbd"),  bsd_glob("miscsrc/*")) {
	    my($src, $dest) = ("$FindBin::RealBin/../$f", "$docker_context_dir/" . dirname($f));
	    next if -d $src; # ignore directories in miscsrc
	    _copy_p($doit, $src, $dest);
	}
	# for my $f ("bbbikedraw.pl", "grepstrassen", "bbd2esri", "bbd2mapservhtml.pl") {
	#     $doit->chmod(0755, "$docker_context_dir/miscsrc/$f"); # XXX copy should really preserve the file mode
	# }
	$doit->system("cp", "-rf", "$FindBin::RealBin/../mapserver/", "$docker_context_dir/mapserver/");
    }

    my $dockerfile = <<EOF;
FROM $opt{dist}:$opt{distver}
EOF
    $dockerfile .= _dockerfile_invalidate_cache if $opt{'invalidate-apt'};
    $dockerfile .= _dockerfile_fix_sources_list(\%opt);
    $dockerfile .= _dockerfile_apt_auth(\%opt);

    my $fonts_dejavu_package = (
				(
				 ($opt{dist} eq 'ubuntu' && $opt{distver} =~ m{^(precise|trusty|xenial)$}) ||
				 ($opt{dist} eq 'debian' && $opt{distver} =~ 'wheezy')
				) ? 'ttf-dejavu' : 'fonts-dejavu'
			       );
    $dockerfile .= <<EOF;
ENV DEBIAN_FRONTEND noninteractive
RUN apt-get update && apt-get install -qqy --no-install-recommends \\
      libgd3 libgd-gd2-perl ttf-bitstream-vera $fonts_dejavu_package \\
      libpdf-create-perl libcairo-perl libpango-perl libfont-afm-perl \\
      libimager-perl libsvg-perl librsvg2-bin \\
      libstring-approx-perl libdbd-xbase-perl libxml-parser-perl \\
      libxml-twig-perl libdbi-perl libtie-ixhash-perl \\
      libobject-realize-later-perl libmldbm-perl \\
      libclass-accessor-perl libarchive-zip-perl \\
      libtemplate-perl libxml-libxml-perl libxml-simple-perl \\
      libyaml-libyaml-perl libjson-xs-perl \\
      libtext-unidecode-perl libdata-compare-perl \\
      libdate-calc-perl libdatetime-perl libimage-exiftool-perl \\
      libgeo-metar-perl libtext-csv-xs-perl libxml2-utils libdb-file-lock-perl \\
      libapache-session-perl libfile-counterfile-perl \\
      libdigest-md5-perl libmodule-metadata-perl libimager-qrcode-perl \\
      starman tzdata
RUN perl -i -pe 's/^(deb.*main)\$/\$1 contrib non-free/' /etc/apt/sources.list && apt-get update
RUN apt-get install -qqy --no-install-recommends \\
      agrep wget gnupg
EOF
    if ($use_bbbike_ppa) {
	$dockerfile .= <<EOF;
RUN apt-get install -qqy --no-install-recommends \\
      software-properties-common
RUN add-apt-repository ppa:eserte/bbbike
EOF
    } elsif ($use_eserte_obs) {
	$dockerfile .= <<"EOF"
RUN wget -O- https://download.opensuse.org/repositories/home:/eserte/$codename_to_obs_repository{$opt{distver}}/Release.key | gpg --dearmor > /usr/share/keyrings/obs-eserte.gpg
RUN printf "Types: deb\\nURIs: https://download.opensuse.org/repositories/home:/eserte/$codename_to_obs_repository{$opt{distver}}\\nSuites: ./\\nSigned-By: /usr/share/keyrings/obs-eserte.gpg\\n" > /etc/apt/sources.list.d/obs-eserte.sources
EOF
    } else {
	$dockerfile .= <<'EOF';
RUN wget -O- http://mydebs.bbbike.de/key/mydebs.bbbike.key | apt-key add -
RUN CODENAME=$(perl -nle '/^VERSION_CODENAME="?([^"]+)/ and $codename=$1; /^VERSION="\d+ \((.*)\)/ and $maybe_codename=$1; END { print $codename // $maybe_codename }' /etc/os-release); echo deb http://mydebs.bbbike.de ${CODENAME} main > /etc/apt/sources.list.d/mydebs.bbbike.list~ && mv /etc/apt/sources.list.d/mydebs.bbbike.list~ /etc/apt/sources.list.d/mydebs.bbbike.list
EOF
    }
    my $extra_pkgs = '';
    if ($opt{distver} =~ /^(resolute|forky)$/) {
	$extra_pkgs = 'libcrypt-dev';
    }
    $dockerfile .= <<EOF;
RUN apt-get update && apt-get install -qqy --no-install-recommends \\
      libplack-middleware-rewrite-perl libplack-middleware-deflater-perl libcgi-emulate-psgi-perl libcgi-compile-perl \\
      libapache-session-counted-perl \\
      libarray-heap-perl \\
      make gcc libc6-dev $extra_pkgs libinline-perl libinline-c-perl \\
      libemail-mime-perl libemail-sender-perl
EOF
    if ($opt{mapserver}) {
	$dockerfile .= <<EOF;
RUN apt-get install -qqy --no-install-recommends \\
      bmake mapserver-bin cgi-mapserver
EOF
    }
    $dockerfile .= <<EOF;
# XXX check the above list, and maybe minimize!
# XXX mapserver not included, because of complicated build process
RUN mkdir /bbbike
COPY . /bbbike
WORKDIR /bbbike
RUN perl Makefile.PL && make ext
EOF
    # next lines taken from init_cgi_config from ci-functions.sh
    if (!$opt{mapserver}) {
	$dockerfile .= <<'EOF';
RUN (cd cgi && cp -f bbbike-debian-no-mapserver.cgi.config bbbike.cgi.config)
RUN (cd cgi && cat bbbike2-debian.cgi.config | perl -pe 's/bbbike-debian.cgi.config/bbbike-debian-no-mapserver.cgi.config/' > bbbike2.cgi.config)
EOF
    } else {
	$dockerfile .= <<'EOF';
RUN (cd cgi && cp -f bbbike-debian.cgi.config bbbike.cgi.config)
RUN (cd cgi && cp -f bbbike2-debian.cgi.config bbbike2.cgi.config)
RUN (cd data && perl -I.. -MBBBikeBuildUtil=run_pmake -e 'run_pmake' mapfiles)
RUN (cd mapserver/brb && ./doit.pl --dest-dir . --host localhost  --location-style bbbike --bbbike-dir ../.. templates permissions)
EOF
	# XXX global config file for mapserver 8.x
	# XXX review and maybe remove again
	if ($opt{distver} =~ /(bookworm|noble|resolute)/) {
	    warning "Temporary hack: provide /etc/mapserver.conf until better solutions exist";
	    $dockerfile .= <<'EOF';
RUN cp /bbbike/mapserver/brb/mapserver.conf /etc
EOF
	}
    }

    # create some geojson files needed for bbbikeleaflet
    if ($opt{mapserver}) { # XXX mapserver option is only needed because required prereqs (bmake, Makefile, maybe more?) are made available with this option XXX should be made differently!
	$dockerfile .= <<'EOF';
RUN (cd data && perl -I.. -MBBBikeBuildUtil=run_pmake -e 'run_pmake' ../tmp/geojson/bbbike-temp-blockings-optimized.geojson ../tmp/geojson/comments_ferry.geojson)
EOF
    }

    if (!$opt{mapserver}) { # XXX currently the "local" TARGET in mapserver/brb/Makefile is hardcoded to not use the cgi-bin url layout
	$dockerfile .= <<'EOF';
## switch to cgi-bin layout
ENV BBBIKE_URL_LAYOUT cgi-bin
## additionally the following needs to be done for bbbikeleaflet.cgi
RUN sed -i  '$i\$use_cgi_bin_layout=1;' /bbbike/cgi/bbbike.cgi.config
EOF
    }

    $dockerfile .= <<"EOF";

WORKDIR /bbbike/cgi
RUN make symlinks fix-permissions
EXPOSE $docker_port
CMD starman --listen $docker_host:$docker_port bbbike.psgi
EOF
    $doit->write_binary("$docker_context_dir/Dockerfile", $dockerfile);
    in_directory {
	my $tag = "bbbike-cgi-$opt{dist}-$opt{distver}";
	$doit->system("docker", "build", "--tag", $tag, ".");
	eval { $doit->system("docker", "rm", "-f", $tag) };
	my $docker_ppid = fork;
	die $! if !defined $docker_ppid;
	if ($docker_ppid == 0) {
	    $doit->system("docker", "run", "-it", "--name", $tag, "-P", $tag);
	    exit 0;
	}
	info "Waiting for container to obtain port (max. 30s) and for service beeing started";
	my $port;
    RETRY: for my $try (1..100) {
	    my $docker_port_res = eval { $doit->info_open3({quiet=>1,errref=>\my $stderr}, "docker", "port", $tag) };
	    if ($docker_port_res) {
		for my $line (split /\n/, $docker_port_res) {
		    if ($line =~ m{^$docker_port/tcp -> .*:(\d+)}) {
			$port = $1;
			last RETRY;
		    }
		}
	    }
	    Time::HiRes::sleep($try < 50 ? 0.1 : 0.5);
	    my $kid = waitpid($docker_ppid, WNOHANG);
	    if ($kid) {
		error "container exited prematurely --- \$?=$?";
	    }
	}
	if (!$port) {
	    warning "Cannot get container port";
	} else {
	    {
		my $initialized = 0;
		require HTTP::Tiny;
		my $ua = HTTP::Tiny->new;
	    RETRY: for my $try (1..10) {
		    my $resp = $ua->get("http://localhost:$port/bbbike/cgi/bbbike.cgi?init_environment=1");
		    if ($resp->{status} == 200) {
			$initialized = 1;
			last RETRY;
		    }
		    Time::HiRes::sleep(0.5);
		}
		if (!$initialized) {
		    warning "cgi environment could not be successfully initialized";
		}
	    }
	    if ($opt{test}) {
		local %ENV = %ENV;
		$doit->setenv(BBBIKE_TEST_SKIP_MAPSERVER => 1);
		$doit->setenv(BBBIKE_TEST_CGIDIR => "http://localhost:$port/bbbike/cgi");
		$doit->system('prove', "$FindBin::RealBin/../t/cgihead.t");
	    }
	    info "Access web service under http://localhost:$port";
	}
	info "Please CTRL-C or execute 'docker kill $tag' to stop container";
	waitpid($docker_ppid, 0);
    } $docker_context_dir;
}

sub _copy_p {
    my($doit, $src, $destdir) = @_;
    $doit->copy($src, $destdir);
    copy_stat($src, "$destdir/" . basename($src));
}

return 1 if caller;

sub _check_distver ($) {
    my $distver = shift;
    if ($distver =~ m{^5\.\d+\.\d+-}) {
	# This looks like a perl:... image, accept it
	return;
    }
    if ($distver =~ m{^\d+}) {
	die "Use symbolic --distver (i.e. bionic instead of 18.04)\n";
    }
}

my $doit = Doit->init;

my $subcmd = shift
    or usage "Subcmd is missing.";

if ($subcmd =~ m{^(gui|perl[-_]?tk)$}) {
    my %opt = (
	       dist    => 'debian',
	       distver => 'latest',
	       src     => 'local',
	       branch  => undef,
	       debug   => 0,
	       'bbbike-prog' => 'bbbike',
	       'bbbike-args' => '',
	       'install-recommends' => 1,
	       'install-suggests' => 0,
	       'feature-pdf' => 1,
	       'feature-svg' => 1,
	       'feature-remote' => 1,
	       'feature-extras' => 1,
	       'invalidate-apt' => 0,
	       'docker-image' => undef,
	       'share-data-osm' => 0,
	       'hack-writable-data' => 0,
	       'copy-bikepowerrc' => 0,
	       'ubuntu-mirror' => undef,
	      );
    GetOptions(\%opt,
	       "dist=s",
	       "distver|distversion=s",
	       "src=s",
	       "branch=s",
	       'debug!',
	       'bbbike-prog=s',
	       'bbbike-args=s',
	       'install-recommends!',
	       'install-suggests!',
	       'feature-pdf!',
	       'feature-svg!',
	       'feature-remote!',
	       'feature-extras!',
	       'invalidate-apt!',
	       'docker-image=s',
	       'share-data-osm!',
	       'hack-writable-data!',
	       'copy-bikepowerrc!',
	       'ubuntu-mirror=s',
	      )
	or usage_gui;
    gui($doit, %opt);
} elsif ($subcmd eq 'ci' || $subcmd eq 'test') {
    my %opt = (
	       jobs    => 1,
	       dist    => 'debian',
	       distver => 'bookworm',
	       src     => 'local',
	       branch  => undef,
	       env     => {},
	       'build-env' => {},
	       'with-data-build' => 0,
	       'no-cache' => 0,
	       'keep-container' => 0,
	       'invalidate-apt' => 0,
	       'perl-ver' => undef,
	       'image-variant' => undef,
	       'cover-dir' => undef,
	       'docker-build-max-retry' => undef,
	       'ubuntu-mirror' => undef,
	      );
    GetOptions(\%opt,
	       "jobs|j=i",
	       "dist=s",
	       "distver|distversion=s",
	       "src=s",
	       'branch=s',
	       'env=s%',
	       'build-env=s%',
	       "with-data-build!",
	       'no-cache',
	       'keep-container!',
	       'invalidate-apt!',
	       'perl-ver=s',
	       'image-variant=s',
	       'cover-dir=s',
	       'docker-build-max-retry=i',
	       'ubuntu-mirror=s',
	      )
	or usage_ci;
    _check_distver $opt{distver};
    ci($doit, %opt);
} elsif ($subcmd eq 'build-deb') {
    my %opt = (
	       dist    => 'debian',
	       distver => 'latest',
	       distfile => undef,
	       continue => 0,
	       'invalidate-apt' => 0,
	      );
    GetOptions(\%opt,
	       "dist=s",
	       "distver|distversion=s",
	       "distfile=s",
	       "continue!",
	       'invalidate-apt!',
	      )
	or usage_build_deb;
    build_deb($doit, %opt);
} elsif ($subcmd =~ m{^(cgi|web)$}) {
    my %opt = (
	       dist    => 'debian',
	       distver => 'bookworm',
	       test => 0,
	       'invalidate-apt' => 0,
	       mapserver => 0,
	       'use-bbbike-ppa' => undef,
	       'force-ipv4' => 1,
	       'ubuntu-mirror' => undef,
	      );
    GetOptions(\%opt,
	       "dist=s",
	       "distver|distversion=s",
	       "test!",
	       'invalidate-apt!',
	       'mapserver!',
	       'use-bbbike-ppa!',
	       'force-ipv4!',
	       'ubuntu-mirror=s',
	      )
	or usage_cgi;
    cgi($doit, %opt);
} else {
    usage "Unknown subcmd '$subcmd'";
}
__END__

=encoding utf-8

=head1 NAME

docker-bbbike - run bbbike tasks in docker

=head1 SYNOPSIS

    ./miscsrc/docker-bbbike [options]

=head1 DESCRIPTION

C<docker-bbbike> may run different tasks in a docker environment, see below.

Before you start, a working docker environment and some other
prerequisites are needed. On a linux system, the following tasks
usually need to be done:

=over

=item * install docker

Depending on your Debian/Ubuntu distribution run

    sudo apt-get install docker-ce

or

    sudo apt-get install docker.io

or follow the instructions in L<https://docs.docker.com/engine/install/>

=item * install further dependencies

    sudo apt-get install libipc-perl-run

Fur running the C<docker-bbbike ci> with the default setting
C<--src=local> subcommand the following are also needed:

    sudo apt-get install rsync git

This is not needed for C<docker-bbbike ci --src=github>.

=item * make sure you can run docker without C<sudo>

Add yourself to the C<docker> group, e.g. using

    sudo usermod -a -G docker $USER

After doing this, it is necessary to have a fresh shell --- either
logout and login again, or start a new shell.

=item * if you don't have the bbbike source code checked out at all, do the following (make sure that the C<git> package is installed)

    git clone --depth=1 https://github.com/eserte/bbbike && cd bbbike

Otherwise just C<cd> into the bbbike source directory.

Some subcommands (C<ci>, C<gui>) can operate from GitHub sources using
the C<--src=github> option and thus may not need a local git clone.

=back

=head2 GUI

Run GUI application (also known as the BBBike Perl/Tk application) in
a docker image, forwarding display to the real X11 display:

    ./miscsrc/docker-bbbike gui

The same, but specify OS + version (instead of default debian:latest):

    ./miscsrc/docker-bbbike gui --dist ubuntu --distver 16.04

To start bbbike from an existing .deb package, use

    ./miscsrc/docker-bbbike gui --src /path/to/bbbike.deb

Supply bbbike options:

    ./miscsrc/docker-bbbike gui --bbbike-args="-lazy -advanced"

Run bbbike_choose for other cities using OSM data:

    ./miscsrc/docker-bbbike gui --bbbike-prog miscsrc/bbbike_chooser.pl --share-data-osm

=head2 TESTS (like in github actions)

Run test suite like in github actions, with settings C<USE_MODPERL=1> and
C<USE_SYSTEM_PERL=1>:

    ./miscsrc/docker-bbbike ci --jobs 4

Instead of C<ci>, the alias C<test> may be used.

The same, but specify additional environment variables:

    ./miscsrc/docker-bbbike ci --jobs 4 --env BBBIKE_TEST_GUI=1

Use another Ubuntu version (default is trusty):

    ./miscsrc/docker-bbbike ci --jobs 4 --distver precise

Including Mapserver tests:

    ./miscsrc/docker-bbbike ci --jobs 4 --env BBBIKE_TEST_SKIP_MAPSERVER=0

Use another perl version, and don't use mod_perl, but starman instead:

    ./miscsrc/docker-bbbike ci --jobs 4 --env BBBIKE_TEST_SKIP_MODPERL=1 --env USE_MODPERL=0 --perl-ver 5.20.3

Don't use the current source from github, rather copy the current
source directory into the docker container:

    ./miscsrc/docker-bbbike ci --jobs 4 --src local

A crontab entry may look like this:

    48 0 * * *      mkdir -p $HOME/log/docker-bbbike-debian-bookworm && cd $HOME/cvrsnica/src/bbbike && ./miscsrc/docker-bbbike test --dist debian --distver bookworm --src local --env BBBIKE_TEST_SKIP_MAPSERVER=0 > $HOME/log/docker-bbbike-debian-bookworm/$(date +\%FT\%T.log) 2>&1

Coverage testing may be activated using the C<--cover-dir> option
which needs to be set to a directory which contains the coverage
files, including F<coverage.html>:

    ./miscsrc/docker-bbbike ci --cover-dir /tmp/bbbike-cover_db
    firefox /tmp/bbbike-cover_db/coverage.html

=head2 DEB PACKAGE BUILD

Create a debian package for the current source:

    ./miscsrc/docker-bbbike build-deb

The resulting .deb file will be in F</tmp>.

To continue a partially completed run, use

    ./miscsrc/docker-bbbike build-deb --continue

This would especially skip the creation of the intermediate tarball.

The options C<--dist> and C<--distver> may be used to use another
Debian/Ubuntu/... version. The default is to use the latest Debian.

Create a debian package for an existing tarball:

    ./miscsrc/docker-bbbike build-deb --distfile BBBike-X.YY.tar.gz

=head2 CGI

Run the cgi in a docker container:

    ./miscsrc/docker-bbbike cgi --distver bookworm

=head1 HOWTOS

=head2 Test data update using an older bbbike version

Use an older .deb package and the option C<--hack-writable-data>
(because F</usr/lib/BBBike/data> is not writable when installing from
a debian package).

    ./miscsrc/docker-bbbike gui --src ../bbbike-distfiles/bbbike_3.17-1_amd64.deb --bbbike-args="--no-www" --hack-writable-data

If not the default live server should be used for updates, then open
ptksh (Shift-P) and set another server. The following works for using
a starman installation on the host C<bbbike-pps-bookworm>:

    $BBBike::BBBIKE_UPDATE_WWW='http://bbbike-pps-bookworm/BBBike';

Now you can use "Einstellungen > Daten-Update über das Internet".

=head2 Run the GUI program using specified GitHub versions, branches, or commits

Run with the latest version of BBBike on GitHub:

    ./miscsrc/docker-bbbike gui --src github

Note that the C<git-clone> is done during C<docker-run> time, so the
contents are fetched at every invocation, but it's also guaranteed
that the contents are fresh.

Run with an arbitrary branch or tag:

    ./miscsrc/docker-bbbike gui --src github-fixed --branch RELEASE_3_16 --dist debian --distver wheezy

The C<git-clone> step is run during C<docker-build> time, so the
contents are fixed in the docker image, which means faster startup
time. Not suitable for movable branches or master.

Run with an arbitrary branch or tag without using C<git-clone>:

    ./miscsrc/docker-bbbike gui --src github-archive --branch RELEASE_3_16 --dist debian --distver wheezy

The C<github-archive> method uses a C<curl> call to fetch a tarball
which is immediately extracted. This means that the checkout does not
have a C<.git> directory which may be benefitial in some cases (e.g.
testing the data update functionality, which refuses to work if
there's a C<.git> directory detected). As the C<github-fixed> method
this is not suitable for movable branches or master.

Here's a matrix overview of these options:

 +-------------------+---------------------+----------------------+----------------------+
 | Feature           | --src github        | --src github-fixed   | --src github-archive |
 +-------------------+---------------------+----------------------+----------------------+
 | Content fetching  | At docker-run       | At docker-build      | At docker-build      | 
 |                   | (every invocation)  | (fixed in image)     | (fixed in image)     |
 +-------------------+---------------------+----------------------+----------------------+
 | Contents freshness| Always fresh        | Fixed, not fresh     | Fixed, not fresh     |
 +-------------------+---------------------+----------------------+----------------------+
 | Startup speed     | Slower              | Faster               | Faster               |
 +-------------------+---------------------+----------------------+----------------------+
 | .git directory    | Yes                 | Yes                  | No                   |
 +-------------------+---------------------+----------------------+----------------------+
 | Suitable for      | Movable branches,   | Fixed branches/tags  | Fixed branches/tags  |
 |                   | master              |                      |                      |
 +-------------------+---------------------+----------------------+----------------------+
 | Data update       | Does not work       | Does not work        | Works                |
 | functionality     |                     |                      |                      |
 +-------------------+---------------------+----------------------+----------------------+

=head1 SEE ALSO

L<docker(1)>.

=cut
