#!/usr/bin/perl -w

=head1 NAME

dh_installs6 - install s6-rc unit files

=cut

use strict;
use warnings;
use Debian::Debhelper::Dh_Lib;
use File::Find;
use Cwd qw(getcwd abs_path);

our $VERSION = DH_BUILTIN_VERSION;

=head1 SYNOPSIS

B<dh_installs6> [S<I<debhelper options>>]

=head1 DESCRIPTION

B<dh_installs6> is a debhelper program that is responsible for
installing package maintainer supplied s6-rc unit folders.

Generates F<postinst>, F<prerm> and F<postrm> code blocks for enabling,
disabling and restarting the corresponding s6-rc services, when the package
is installed, updated, or removed. These snippets are added to the
maintainer scripts by L<dh_installdeb(1)>.

A unit is enabled exactly once: the first time F<postinst> ever runs for it,
tracked by a marker file under F</var/lib/dh-s6/enabled/>I<unit> (removed on
remove/purge). The marker is only ever written by that unit's own postinst,
so it can't be affected by another package's maintainer scripts in the same
dpkg run. This mirrors B<deb-systemd-helper>'s own
F</var/lib/systemd/deb-systemd-helper-enabled/>I<unit>B<.dsh-also> state
file, and for the same reason: consulting the live s6-rc database instead
(the former C<svctl isenabled> check) breaks, because other maintainer
scripts in the same dpkg run (notably B<deb-systemd-helper>, which always
runs C<svctl compile && svctl update>) may have compiled the freshly
unpacked unit into it already, which made C<svctl isenabled> see "known,
disabled" and never enable it.

=begin comment

BEGIN TEMPORARY: delete through END TEMPORARY below, and the matching
block in F<autoscripts/postinst-s6-enable>, once the fleet is past
packages built before markers existed.

=end comment

A missing marker normally means "genuinely new, enable it" -- except on a
package's very first run under this scheme, where that's ambiguous with
"predates markers entirely", and wrongly enabling would override an
admin's earlier C<svctl disable>. A sibling unit of the same package
already having a marker rules that out; so does dpkg's old-version
argument (C<$2>) being empty, i.e. a genuine first install. Otherwise the
unit is assumed to predate markers and is left alone.

=begin comment

END TEMPORARY

=end comment

B<dh_installs6> automatically processes unit folders containing the file
F<producer-for> (services). For changing the generated blocks behaviour use
file hints (e.g. B<.noenable>) for specific unit folders.

To enable this addon in your source package use B<Build-Depends: dh-sequence-s6>
or set `--with s6` in your debian/rules file.

=head1 FILES

=over 4

=item F<debian/I<package>.s6-rc/*/>

If any of those folders exists, they are installed into
F<usr/lib/s6-rc/sources/> in the package build directory.

=item Choosing between F<.noenable> and F<.norestart>

The two hint files answer B<two different questions>, and confusing them has cost
us real breakage in both directions. Decide them separately:

=over 4

=item *

F<.noenable> answers B<"initial installation">: should this service come up when
the package is installed for the first time? Say yes to it for everything that
needs configuration a package cannot ship -- the AEP rule is "packages yes,
services off, Puppet switches on".

=item *

F<.norestart> answers B<"upgrade of a service that is already running">: when new
code arrives, may C<dpkg> decide the moment the service goes down? Say yes to it
only when the timing of that outage is an B<operational> decision -- a database, a
process manager holding connections, a filesystem provider other processes depend
on. For a stateless service that holds nothing, C<.norestart> only means the
process keeps running the old code.

=back

B<F<.noenable> alone already prevents a start on initial installation.> The restart
block only touches a unit when C<svctl isenabled || svctl isup> says yes, and on a
first installation with F<.noenable> neither is true. So adding F<.norestart> "to
be safe" buys nothing there -- it only costs you the restart on later upgrades.

Where F<.norestart> is the right answer, the state it creates -- new package,
old process -- is not visible without help. The C<restart_pending> field of
C<aep-zabbix-checks> (>= 0.4.5) reports it per unit; wire a trigger to it, or the
pending restart will simply be forgotten. Measured case: aep-zabbix-checks-vrrp
0.1.7 was installed on rt-1.tick1 while the vrrp-gap process was 40908 seconds
old, so the watchdog's heartbeat file, added in 0.1.6, never appeared at all.

=item F<debian/I<package>.s6-rc/*/.noenable>

Disable the service(s) on purge, but do not enable them on install.

B<Note> that this option also affects whether the services are
started/restarted. If a service is enabled or was running it will be
started/restarted. Use file B<.norestart> to mitigate that -- but read the
section above first: that is usually B<not> what you want.

=item F<debian/I<package>.s6-rc/*/.norestart>

Do not restart the service after upgrades or start after initial installation.

B<Note> that this option does not affect whether the services are
enabled. Please remember to also use file B<.noenable> if the services
should not be enabled.

=item F<debian/I<package>.s6-rc/*/.no-readiness>

B<Readiness rule (build error since 1.2.5).> Every longrun either reports
readiness -- it has a F<notification-fd>, usually together with F<data/check>
and B<s6-notifyoncheck>(8) in F<run> -- or carries this file, whose first
non-empty line says why it does not. A longrun with neither fails the build;
an empty F<.no-readiness> fails it too.

Without a F<notification-fd>, "up" is true from the first instant of the
process, and s6-rc, C<svctl start>/C<restart> and every acceptance check
treat a service as done that does not answer yet. A reason that fits in a
line is enough, e.g. "cron: nothing to answer, the process being there is
the function". Like the other hint files it is removed at build time and
never reaches the package; the reason lives in the source.

=item F<debian/I<package>.s6-rc/*/.permafail>

Opt in to permanent failure on a crash loop. B<dh_installs6> generates a
F<finish> script for that unit:

  #!/usr/bin/execlineb -S3
  s6-permafailon <arguments>
  exit 0

After the configured number of deaths inside the configured window,
B<s6-permafailon>(1) makes F<finish> exit 125, which tells B<s6-supervise>
to stop restarting the service. The service then reads as
C<up=false wantedup=false> in B<s6-svstat>(1) instead of looking healthy
while dying in a loop (C<svctl isup> only reports the s6-rc I<wanted> state
and cannot see a restart loop). Recover with C<svctl restart> and clear the
death tally with B<s6-svdt-clear>(1).

The content of the file is the argument list for B<s6-permafailon>, i.e.
I<seconds> I<deaths> I<events>. An empty file means the default

  120 5 1-255,SIGSEGV,SIGABRT,SIGKILL

(five deaths within two minutes, counting any non-zero exit code and the
usual fatal signals). Choose a generous window for services that legitimately
wait for a late network: permanent failure is deliberately sticky.

Only valid for units of type C<longrun> and only if the unit does not ship a
F<finish> script of its own; either condition raises a build error. Requires
B<s6> 2.7.1.0 or newer on the target system (jammy ships 2.11.1.0, resolute
2.13.1.0).

=back

=head1 NOTES

This command is not idempotent. L<dh_prep(1)> should be called between
invocations of this command (with the same arguments). Otherwise, it
may cause multiple instances of the same text to be added to maintainer
scripts.

=cut

init(options => {});

# Wie viele Tode s6-supervise je Longrun behaelt. s6s eigene Vorgabe ist 100, und
# die ist zu klein, um ein Flappen zu VERMESSEN: ein Dienst, der 25-mal je Minute
# stirbt (postfix auf dns-1 am 22.09.2026: 2063 Neustarts in 82 Minuten), fuellt
# einen 100er-Tally in vier Minuten -- danach melden die Fenster 600 s und 3600 s
# dieselbe Zahl, und aus "es wird schlimmer" wird "unveraendert".
#
# 4096 Eintraege sind 48 KB je Unit im tmpfs, und zwar nur, wenn sie auch anfallen:
# s6-supervise schreibt je Tod 12 Bytes. Bei 25 Longruns also hoechstens 1,2 MB im
# Fehlerfall und 0 im Normalbetrieb.
use constant MAX_DEATH_TALLY => 4096;

# s6-permafailon arguments used when .permafail is empty: five deaths within
# two minutes, counting every non-zero exit code and the usual fatal signals.
use constant PERMAFAIL_DEFAULT => '120 5 1-255,SIGSEGV,SIGABRT,SIGKILL';

sub slurp_first_line {
	# First non-empty line of a file, whitespace-trimmed; '' if unreadable.
	my ($file) = @_;
	open(my $fh, '<', $file) or return '';
	while (my $line = <$fh>) {
		$line =~ s/\s+$//;
		$line =~ s/^\s+//;
		next unless length $line;
		close($fh);
		return $line;
	}
	close($fh);
	return '';
}

sub quote {
	# Add single quotes around the argument.
	return '\'' . $_[0] . '\'';
}

sub uniq {
	my %seen;
	return grep { !$seen{$_}++ } @_;
}

sub list_installed_service_units {
	my ($tmpdir) = @_;

	my @installed;

	foreach my $unitdir (glob("$tmpdir/usr/lib/s6-rc/sources/*")) {
		next unless -d $unitdir;
		next if -f "$unitdir/consumer-for"; # only service units (not log units)
		push @installed, basename($unitdir);
	}

	return @installed;
}

# Install package maintainer supplied unit folders
foreach my $package (@{$dh{DOPACKAGES}}) {
	my $tmpdir = tmpdir($package);

	# Install all unit folders in the debian/ directory in the
	# form $package.s6-rc/$name.
	my $basedir = "debian/$package.s6-rc";
	my $targetdir = "$tmpdir/usr/lib/s6-rc/sources";

	next unless -d $basedir;

	install_dir($targetdir);
	foreach my $namepath (glob("$basedir/*")) {
		doit("cp", '--reflink=auto', "-r", "-a", $namepath, $targetdir);
	}
}

# Add postinst, prerm, and postrm code blocks to handle activation,
# deactivation, start and stopping of services when the package is
# installed, upgraded or removed.
foreach my $package (@{$dh{DOPACKAGES}}) {
	my $tmpdir = tmpdir($package);
	my (@args, @enable_units, @restart_units);

	my @service_units = list_installed_service_units($tmpdir);

	# find service units that should not be enabled or restarted
	for my $unit (@service_units) {
		my $path = "${tmpdir}/usr/lib/s6-rc/sources/${unit}";
		push @enable_units, $unit unless (-f "$path/.noenable");
		push @restart_units, $unit unless (-f "$path/.norestart");
	}

	# max-death-tally je Longrun setzen, wenn das Paket keinen eigenen Wert
	# mitbringt. Ein Paket, das es selbst schreibt, behaelt seinen Wert.
	for my $unit (@service_units) {
		my $path = "${tmpdir}/usr/lib/s6-rc/sources/${unit}";
		my $type = slurp_first_line("$path/type");
		next unless $type eq 'longrun';
		next if -f "$path/max-death-tally";
		open(my $fh, '>', "$path/max-death-tally")
			or error("cannot write $path/max-death-tally: $!");
		print $fh MAX_DEATH_TALLY . "\n";
		close($fh) or error("cannot write $path/max-death-tally: $!");
	}

	# Bauregel Bereitschaft (s6-Readiness-Analyse, E1-E13 angenommen 23.09.2026):
	# jeder Longrun meldet Bereitschaft (notification-fd) oder sagt in
	# .no-readiness, warum nicht. Sonst bricht der Bau ab.
	#
	# Warum ein Abbruch und keine Warnung: ohne notification-fd ist "up" ab dem
	# ersten Byte des Prozesses wahr, und s6-rc, svctl start/restart und jede
	# Abnahme halten einen Dienst fuer fertig, der noch nichts beantwortet.
	# Gemessen: puppet-1.tick1 hatte keinen einzigen Dienst mit
	# Bereitschaftsmeldung; 26 Longruns in unseren Paketen hatten keine, und
	# niemand hatte das entschieden -- es war einfach nie gefragt worden. Eine
	# Warnung im Baulog liest niemand; eine Zeile Begruendung beim naechsten
	# Anfassen des Pakets kostet eine Minute.
	#
	# notification-fd allein genuegt: der Dienst kann selbst melden (s6-log -d3,
	# eine Meldung im run-Skript, spaeter s6rc_notify_native in go-base) --
	# data/check mit s6-notifyoncheck ist der haeufigste Weg, nicht der einzige.
	# Logger (consumer-for) erreichen diese Schleife nicht.
	for my $unit (@service_units) {
		my $path = "${tmpdir}/usr/lib/s6-rc/sources/${unit}";
		my $type = slurp_first_line("$path/type");
		next unless $type eq 'longrun';
		next if -f "$path/notification-fd";
		if (-f "$path/.no-readiness") {
			error("$unit: .no-readiness ist leer -- die Datei traegt die Begruendung, "
				. "warum dieser Longrun keine Bereitschaft meldet")
				unless length slurp_first_line("$path/.no-readiness");
			next;
		}
		error("$unit: Longrun ohne Bereitschaftsmeldung. Entweder notification-fd "
			. "(meist mit data/check und s6-notifyoncheck im run-Skript) oder "
			. "debian/$package.s6-rc/$unit/.no-readiness mit einer Zeile Begruendung "
			. "-- siehe dh_installs6(1), Bereitschaft.");
	}

	# generate ./finish scripts for units that opted in to permanent failure
	for my $unit (@service_units) {
		my $path = "${tmpdir}/usr/lib/s6-rc/sources/${unit}";
		next unless -f "$path/.permafail";
		error("$unit: .permafail and finish cannot be used together, remove one")
			if -f "$path/finish";
		my $type = slurp_first_line("$path/type");
		error("$unit: .permafail needs a longrun unit, type is '$type'")
			unless $type eq 'longrun';
		my $args = slurp_first_line("$path/.permafail");
		$args = PERMAFAIL_DEFAULT unless length $args;
		open(my $fh, '>', "$path/finish")
			or error("cannot write $path/finish: $!");
		print $fh "#!/usr/bin/execlineb -S3\n";
		print $fh "s6-permafailon $args\n";
		print $fh "exit 0\n";
		close($fh) or error("cannot write $path/finish: $!");
		doit("chmod", "0755", "$path/finish");
	}

	# remove .noenable, .norestart, .permafail and .no-readiness hint files
	foreach my $unitdir (glob("${tmpdir}/usr/lib/s6-rc/sources/*")) {
		next unless -d $unitdir;
		doit("rm", "-f", "$unitdir/.noenable");
		doit("rm", "-f", "$unitdir/.norestart");
		doit("rm", "-f", "$unitdir/.permafail");
		doit("rm", "-f", "$unitdir/.no-readiness");
	}

	# quote and sort to be used in autoscript template
	@service_units = map { quote($_) } uniq sort @service_units;
	@enable_units = map { quote($_) } uniq sort @enable_units;
	@restart_units = map { quote($_) } uniq sort @restart_units;

	if (@enable_units) {
		# postinst enables a unit the first time it sees no marker for it (see POD)
		autoscript($package, 'postinst', 'postinst-s6-enable', {'UNITFILES' => join(' ', @enable_units) });
	} else {
		autoscript($package, 'postinst', 'postinst-s6-compile');
	}

	if (@service_units) {
		autoscript($package, 'postrm', 'postrm-s6-compile', { 'UNITFILES' => join(' ', @enable_units) });
		autoscript($package, 'prerm', 'prerm-s6-stop', { 'UNITFILES' => join(' ', @service_units) })
	}

	if (@restart_units) {
		autoscript($package, 'postinst', 'postinst-s6-restart', { 'UNITFILES' => join(' ', @restart_units) });
	}
}

=head1 SEE ALSO

L<debhelper(7)>

=head1 AUTHORS

info@automatic-server.com

=cut
