#!/usr/bin/perl -w

# Copyright (C) Internet Systems Consortium, Inc. ("ISC")
#
# SPDX-License-Identifier: MPL-2.0
#
# This Source Code Form is subject to the terms of the Mozilla Public
# License, v. 2.0.  If a copy of the MPL was not distributed with this
# file, you can obtain one at https://mozilla.org/MPL/2.0/.
#
# See the COPYRIGHT file distributed with this work for additional
# information regarding copyright ownership.

# Framework for starting test servers.
# Based on the type of server specified, check for port availability, remove
# temporary files, start the server, and verify that the server is running.
# If a server is specified, start it. Otherwise, start all servers for test.

use strict;
use warnings;

use Cwd ':DEFAULT', 'abs_path';
use English '-no_match_vars';
use Getopt::Long;
use Time::HiRes 'sleep'; # allows sleeping fractional seconds

# Usage:
#   perl start.pl [--noclean] [--restart] [--port port] [--taskset cpus] test [server [options]]
#
#   --noclean       Do not cleanup files in server directory.
#
#   --restart       Indicate that the server is being restarted, so get the
#                   server to append output to an existing log file instead of
#                   starting a new one.
#
#   --port port     Specify the default port being used by the server to answer
#                   queries (default 5300).  This script will interrogate the
#                   server on this port to see if it is running. (Note: for
#                   "named" nameservers, this can be overridden by the presence
#                   of the file "named.port" in the server directory containing
#                   the number of the query port.)
#
#   --taskset cpus  Use taskset to signal which cpus can be used. For example
#                   cpus=fff0 means all cpus aexcept for 0, 1, 2, and 3 are
#                   eligible.
#
#   test            Name of the test directory.
#
#   server          Name of the server directory.  This will be of the form
#                   "nsN" or "ansN", where "N" is an integer between 1 and 8.
#                   If not given, the script will start all the servers in the
#                   test directory.
#
#   options         Alternate options for the server.
#
#                   NOTE: options must be specified with '-- "<option list>"',
#                   for instance: start.pl . ns1 -- "-c n.conf -d 43"
#
#                   ALSO NOTE: this variable will be filled with the contents
#                   of the first non-commented/non-blank line of args in a file
#                   called "named.args" in an ns*/ subdirectory. Only the FIRST
#                   non-commented/non-blank line is used (everything else in
#                   the file is ignored). If "options" is already set, then
#                   "named.args" is ignored.

my $usage = "usage: $0 [--noclean] [--restart] [--port <port>] [--taskset <cpus>] test-directory [server-directory [server-options]]";
my $clean = 1;
my $restart = 0;
my $queryport = 5300;
my $taskset = "";

GetOptions(
	'clean!'    => \$clean,
	'restart!'  => \$restart,
	'port=i'    => \$queryport,
	'taskset=s' => \$taskset,
) or die "$usage\n";

my( $test, $server_arg, $options_arg ) = @ARGV;

if (!$test) {
	die "$usage\n";
}

# Global variables
my $builddir = $ENV{'builddir'};
my $srcdir = $ENV{'srcdir'};
my $testdir = "$srcdir/$test";

if (! -d $testdir) {
	die "No test directory: \"$testdir\"\n";
}

if ($server_arg && ! -d "$testdir/$server_arg") {
	die "No server directory: \"$testdir/$server_arg\"\n";
}

my $NAMED = $ENV{'NAMED'};
my $DIG = $ENV{'DIG'};
my $PERL = $ENV{'PERL'};
my $PYTHON = $ENV{'PYTHON'};

# Start the server(s)

my @ns;
my @ans;

if ($server_arg) {
	if ($server_arg =~ /^ns/) {
		push(@ns, $server_arg);
	} elsif ($server_arg =~ /^ans/) {
		push(@ans, $server_arg);
	} else {
		print "$0: ns or ans directory expected";
		print "I:$test:failed";
	}
} else {
	# Determine which servers need to be started for this test.
	opendir DIR, $testdir or die "unable to read test directory: \"$test\" ($OS_ERROR)\n";
	my @files = sort readdir DIR;
	closedir DIR;

	@ns = grep /^ns[0-9]*$/, @files;
	@ans = grep /^ans[0-9]*$/, @files;
}

# Start the servers we found.

foreach my $name(@ns) {
	my $instances_so_far = count_running_lines($name);
	&check_ns_port($name);
	&start_ns_server($name, $options_arg);
	&verify_ns_server($name, $instances_so_far);
}

foreach my $name(@ans) {
	&start_ans_server($name);
}

# Subroutines

sub read_ns_port {
	my ( $server ) = @_;
	my $port = $queryport;
	my $options = "";

	if ($server) {
		my $file = $testdir . "/" . $server . "/named.port";

		if (-e $file) {
			open(my $fh, "<", $file) or die "unable to read ports file \"$file\" ($OS_ERROR)";

			my $line = <$fh>;

			if ($line) {
				chomp $line;
				$port = $line;
			}
		}
	}
	return ($port);
}

sub check_ns_port {
	my ( $server ) = @_;
	my $options = "";
	my $port = read_ns_port($server);

	if ($server =~ /(\d+)$/) {
		$options = "-i $1";
	}

	my $tries = 0;

	while (1) {
		my $return = system("$PERL $srcdir/testsock.pl -p $port $options");

		if ($return == 0) {
			last;
		}

		$tries++;

		if ($tries > 4) {
			print "$0: could not bind to server addresses, still running?\n";
			print "I:$test:server sockets not available\n";
			print "I:$test:failed\n";

			system("$PERL $srcdir/stop.pl $test"); # Is this the correct behavior?

			exit 1;
		}

		print "I:$test:Couldn't bind to socket (yet)\n";
		sleep 2;
	}
}

sub start_server {
	my ( $server, $command, $pid_file ) = @_;

	chdir "$testdir/$server" or die "unable to chdir \"$testdir/$server\" ($OS_ERROR)\n";

	# start the server
	my $child = `$command`;
	chomp($child);

	# wait up to 90 seconds for the server to start and to write the
	# pid file otherwise kill this server and any others that have
	# already been started
	my $tries = 0;
	while (!-s $pid_file) {
		if (++$tries > 900) {
			print "I:$test:Couldn't start server $command (pid=$child)\n";
			print "I:$test:failed\n";
			kill "ABRT", $child if ("$child" ne "");
			chdir "$testdir";
			system "$PERL $srcdir/stop.pl $test";
			exit 1;
		}
		sleep 0.1;
	}

	# go back to the top level directory
	chdir $builddir;
}

sub construct_ns_command {
	my ( $server, $options ) = @_;

	my $command;

	if ($taskset) {
		$command = "taskset $taskset $NAMED ";
	} elsif ($ENV{'USE_RR'}) {
		$ENV{'_RR_TRACE_DIR'} = ".";
		$command = "rr record --chaos $NAMED ";
	} else {
		$command = "$NAMED ";
	}

	my $args_file = $testdir . "/" . $server . "/" . "named.args";

	if ($options) {
		$command .= $options;
	} elsif (-e $args_file) {
		open(my $fh, "<", $args_file) or die "unable to read args_file \"$args_file\" ($OS_ERROR)\n";

		while(my $line=<$fh>) {
			next if ($line =~ /^\s*$/); #discard blank lines
			next if ($line =~ /^\s*#/); #discard comment lines

			chomp $line;

			$line =~ s/#.*$//;

			$command .= $line;

			last;
		}
	} else {
		$command .= "-D $test-$server ";
		$command .= "-m record ";

		foreach my $t_option(
			"dropedns", "ednsformerr", "ednsnotimp", "ednsrefused",
			"cookiealwaysvalid", "noaa", "noedns", "nonearest",
			"nosoa", "maxudp512", "maxudp1460", "tat=1", "tat=3"
		    ) {
			if (-e "$testdir/$server/named.$t_option") {
				$command .= "-T $t_option "
			}
		}

		$command .= "-c named.conf -d 99 -g -T maxcachesize=2097152";
	}

	if (-e "$testdir/$server/named.notcp") {
		$command .= " -T notcp"
	}

	if ($restart) {
		$command .= " >>named.run 2>&1 &";
	} else {
		$command .= " >named.run 2>&1 &";
	}

	# get the shell to report the pid of the server ($!)
	$command .= " echo \$!";

	return $command;
}

sub start_ns_server {
	my ( $server, $options ) = @_;

	my $cleanup_files;
	my $command;
	my $pid_file;

	$cleanup_files = "{./*.jnl,./*.bk,./*.st,./named.run}";

	$command = construct_ns_command($server, $options);

	$pid_file = "named.pid";

	if ($clean) {
		unlink glob $cleanup_files;
	}

	start_server($server, $command, $pid_file);
}

sub construct_ans_command {
	my ( $server, $options ) = @_;

	my $command;
	my $n;

	if ($server =~ /^ans(\d+)/) {
		$n = $1;
	} else {
		die "unable to parse server number from name \"$server\"\n";
	}

	if (-e "$testdir/$server/ans.py") {
		$ENV{'PYTHONPATH'} = $testdir . ":" . $srcdir;
		$command = "$PYTHON -u -m $test.$server.ans 10.53.0.$n $queryport";
	} elsif (-e "$testdir/$server/ans.pl") {
		$command = "$PERL ans.pl";
	} else {
		die "unable to find ans.pl or ans.py in \"$testdir/$server\"\n";
	}

	if ($options) {
		$command .= $options;
	}

	if ($restart) {
		$command .= " >>ans.run 2>&1 &";
	} else {
		$command .= " >ans.run 2>&1 &";
	}

	# get the shell to report the pid of the server ($!)
	$command .= " echo \$!";

	return $command;
}

sub start_ans_server {
	my ( $server, $options ) = @_;

	my $cleanup_files;
	my $command;
	my $pid_file;

	$cleanup_files = "{./ans.run}";
	$command = construct_ans_command($server, $options);
	$pid_file = "ans.pid";

	if ($clean) {
		unlink glob $cleanup_files;
	}

	start_server($server, $command, $pid_file);
}

sub count_running_lines {
	my ( $server ) = @_;

	my $runfile = "$testdir/$server/named.run";

	# the shell *ought* to have created the file immediately, but this
	# logic allows the creation to be delayed without issues
	if (open(my $fh, "<", $runfile)) {
		# the whitespace blob should be an ISO timestamp
		# but we don't care about them really, only that they are there
		return scalar(grep /^\S+[ T]\S+ running\R/, <$fh>);
	} else {
		return 0;
	}
}

sub verify_ns_server {
	my ( $server, $instances_so_far ) = @_;

	my $tries = 0;

	while (count_running_lines($server) < $instances_so_far + 1) {
		$tries++;

		if ($tries >= 30) {
			print "I:$test:server $server seems to have not started\n";
			print "I:$test:failed\n";

			system("$PERL $srcdir/stop.pl $test");

			exit 1;
		}

		sleep 2;
	}

	$tries = 0;

	my $port = read_ns_port($server);
	my $tcp = "+tcp";
	my $n;

	if ($server =~ /^ns(\d+)/) {
		$n = $1;
	} else {
		die "unable to parse server number from name \"$server\"\n";
	}

	if (-e "$testdir/$server/named.notcp") {
		$tcp = "";
	}

	my $ip = "10.53.0.$n";
	if (-e "$testdir/$server/named.ipv6-only") {
		$ip = "fd92:7065:b8e:ffff::$n";
	}

	while (1) {
		my $return = system("$DIG $tcp +noadd +nosea +nostat +noquest +nocomm +nocmd +noedns -p $port version.bind. chaos txt \@$ip > /dev/null");

		last if ($return == 0);

		$tries++;

		if ($tries >= 30) {
			print "I:$test:no response from $server\n";
			print "I:$test:failed\n";

			system("$PERL $srcdir/stop.pl $test");

			exit 1;
		}

		sleep 2;
	}
}
