Configd
view release on metacpan or search on metacpan
t/Configd.t view on Meta::CPAN
#!/usr/bin/env perl
use 5.034;
use strict;
use warnings FATAL => 'all';
use re '/aa';
=head1 NAME
t/Configd.t - adopting a file, generating it from its fragments, wrapping the
service and handing it all back
=cut
use Test::More;
use Test::Fatal qw{exception};
use FindBin::libs;
use Test::Configd qw{scratch fragment};
use Configd();
use Configd::Unit();
use Configd::Language();
use Configd::Language::opendkim(); ## no critic (ProhibitUnusedImports)
use Configd::Language::opendmarc(); ## no critic (ProhibitUnusedImports)
use Configd::Language::redis(); ## no critic (ProhibitUnusedImports)
subtest 'adopting a file keeps what was in it' => sub {
my $root = scratch();
my $was = Configd::Language::slurp("$root/etc/postfix/main.cf");
my $result = Configd->adopt( 'postfix', root => $root );
is_deeply( $result->{adopted}, [ '/etc/postfix/main.cf', '/etc/postfix/master.cf' ], 'both files' );
# The distribution's defaults, and whatever the administrator had done, are
# the first fragment. Anything else and adopting a working mail server
# would silently reset it.
is( Configd::Language::slurp("$root/etc/postfix/main.cf.d/00-original"), $was, 'as 00-original, byte for byte' );
my $generated = Configd::Language::slurp("$root/etc/postfix/main.cf");
like( $generated, qr/^myhostname = mail\.example\.com$/m, 'and the generated file still says what it said' );
like( $generated, qr/Generated by configd/, 'with a header saying not to edit it' );
like( $generated, qr{\Q/etc/postfix/main.cf.d\E}, 'and where to edit instead' );
};
subtest 'comments stay in the fragment they were written in' => sub {
my $root = scratch();
Configd->adopt( 'postfix', root => $root );
# A comment is anchored to the setting below it. Once several fragments
# have had their say there may be no such setting any more, and reproducing
# the distribution's paragraph above a value that has since been replaced
# tells the reader something untrue.
my $generated = Configd::Language::slurp("$root/etc/postfix/main.cf");
unlike( $generated, qr/main\.cf\.dist/, 'the stock commentary is not carried into the generated file' );
like(
Configd::Language::slurp("$root/etc/postfix/main.cf.d/00-original"),
qr/main\.cf\.dist/,
'it is kept in 00-original, where it still describes what is around it'
);
like( $generated, qr/those stay in the/, 'and the header says where it went' );
};
subtest 'a second domain adds to the first rather than replacing it' => sub {
my $root = scratch();
Configd->adopt( 'postfix', root => $root );
fragment( $root, 'main.cf', '50-first.example.com.cf', "mydestination = first.example.com\n" );
fragment( $root, 'main.cf', '50-second.example.com.cf', "mydestination = second.example.com\n" );
Configd->build( 'postfix', root => $root );
my ($destination) = Configd::Language::slurp("$root/etc/postfix/main.cf") =~ m/^mydestination = (.*)$/m;
like( $destination, qr/\$myhostname/, 'what postfix shipped is still there' );
like( $destination, qr/\Qfirst.example.com\E/, 'and the first domain' );
like( $destination, qr/\Qsecond.example.com\E/, 'and the second' );
};
subtest 'adopting twice does not double anything' => sub {
# The mistake to avoid: taking the file we generated last time and making it
# the first fragment, which would duplicate every setting in it and grow the
# file every run.
my $root = scratch();
Configd->adopt( 'postfix', root => $root );
my $once = Configd::Language::slurp("$root/etc/postfix/main.cf");
Configd->adopt( 'postfix', root => $root );
my $twice = Configd::Language::slurp("$root/etc/postfix/main.cf");
is( $twice, $once, 'the generated file is the same' );
is( scalar( () = $twice =~ m/^myhostname =/mg ), 1, 'and says myhostname exactly once' );
};
subtest 'building again when nothing changed touches nothing' => sub {
# The unit runs this on every start and reload, so a build that always
# rewrites would churn mtimes and defeat anything watching the file.
my $root = scratch();
Configd->adopt( 'postfix', root => $root );
my @changed = Configd->build( 'postfix', root => $root );
is_deeply( \@changed, [], 'nothing reported as changed' );
fragment( $root, 'main.cf', '50-new.cf', "mydestination = new.example.com\n" );
@changed = Configd->build( 'postfix', root => $root );
is_deeply( \@changed, ['/etc/postfix/main.cf'], 'and the file that changed is named when one does' );
};
subtest 'the leavings of editors and package managers are not configuration' => sub {
my $root = scratch();
Configd->adopt( 'postfix', root => $root );
fragment( $root, 'main.cf', '50-real.cf', "mydestination = real.example.com\n" );
fragment( $root, 'main.cf', '50-real.cf.bak', "mydestination = stale.example.com\n" );
fragment( $root, 'main.cf', '50-old.cf.disabled', "mydestination = disabled.example.com\n" );
fragment( $root, 'main.cf', '50-emacs.cf~', "mydestination = emacs.example.com\n" );
fragment( $root, 'main.cf', '.hidden.cf', "mydestination = hidden.example.com\n" );
fragment( $root, 'main.cf', '50-apt.cf.dpkg-old', "mydestination = apt.example.com\n" );
Configd->build( 'postfix', root => $root );
my ($destination) = Configd::Language::slurp("$root/etc/postfix/main.cf") =~ m/^mydestination = (.*)$/m;
like( $destination, qr/\Qreal.example.com\E/, 'the fragment is used' );
foreach my $ignored (qw{stale disabled emacs hidden apt}) {
unlike( $destination, qr/\Q$ignored.example.com\E/, "and $ignored.example.com is not" );
}
};
subtest 'a file keeps the permissions it had' => sub {
my $root = scratch();
# The mail recipe chmods master.cf to 0600 on purpose. A rename puts the
# temporary file's own permissions on the target, so regenerating it would
# hand that back to 0644 -- a loosening nobody would go looking for, done by
# something advertised as only rewriting a file.
Configd->adopt( 'postfix', root => $root );
is( ( stat "$root/etc/postfix/master.cf" )[2] & 0o7777, 0o600, 'master.cf is still 0600 after adopting' );
is( ( stat "$root/etc/postfix/main.cf" )[2] & 0o7777, 0o644, 'and main.cf is still 0644' );
fragment( $root, 'master.cf', '50-more.cf', "submission inet n - y - - smtpd\n" );
Configd->build( 'postfix', root => $root );
is( ( stat "$root/etc/postfix/master.cf" )[2] & 0o7777, 0o600, 'and after rebuilding it' );
# And on the way back out. release writes through the same temporary file,
# which File::Temp makes 0600 -- so releasing a 0644 main.cf handed it back
# tightened, which is the sort of thing found by looking at a real machine
# rather than by looking at this test.
Configd->release( 'postfix', root => $root );
is( ( stat "$root/etc/postfix/main.cf" )[2] & 0o7777, 0o644, 'main.cf is 0644 again after releasing' );
is( ( stat "$root/etc/postfix/master.cf" )[2] & 0o7777, 0o600, 'and master.cf is still 0600' );
};
subtest 'a file created from nothing gets the owner its language names' => sub {
my $root = scratch();
# The case that matters: a service running as its own user, owning its own
# config. Recreated as root it does not lose a setting -- opendkim and
# opendmarc cannot read the file at all, and do not start.
my ($file) = Configd->language( 'opendmarc', root => $root )->files();
is( $file->{owner}, 'opendmarc:opendmarc', 'opendmarc names its own account' );
($file) = Configd->language( 'opendkim', root => $root )->files();
is( $file->{owner}, 'opendkim:opendkim', 'and so does opendkim' );
($file) = Configd->language( 'redis', root => $root )->files();
is( $file->{owner}, 'root:redis', 'redis.conf is root:redis, the way the package ships it' );
# Every language has to answer this, because the field did nothing at all
# until a guest turned up where it mattered.
foreach my $name ( Configd->languages() ) {
foreach my $each ( Configd->language( $name, root => $root )->files() ) {
ok( defined $each->{owner}, "$name says who owns $each->{path}" );
}
}
# And the field has to be read, not just declared. Chowning to an account
# that does not exist here would need root, so this checks the parse and
# that a missing account is not fatal -- building under --root for a guest
# this machine is not is the normal case for that.
my $made = "$root/etc/made-from-nothing.conf";
is( Configd::Language::spew( $made, "x\n", 0o600, 'nosuchuser:nosuchgroup' ), $made, 'an unknown account is not an error' );
is( ( stat $made )[2] & 0o7777, 0o600, 'and the mode asked for is applied' );
my $kept = "$root/etc/postfix/main.cf";
chmod 0o640, $kept;
Configd::Language::spew( $kept, "y\n", 0o600, 'nosuchuser:nosuchgroup' );
is( ( stat $kept )[2] & 0o7777, 0o640, 'a file that already existed keeps its mode, owner ignored' );
};
subtest 'the drop-in is what makes the file true' => sub {
my $root = scratch();
my $language = Configd->language( 'postfix', root => $root );
my $unit = Configd::Unit->new( language => $language, configd => '/usr/bin/configd' );
ok( !$unit->installed(), 'nothing wrapped to begin with' );
my @written = $unit->install();
is_deeply( \@written, ["$root/etc/systemd/system/postfix\@.service.d/10-configd.conf"], 'one drop-in' );
ok( $unit->installed(), 'and the service is wrapped' );
my $dropin = Configd::Language::slurp( $written[0] );
# Before it starts and before it reloads: those are the two moments the
# daemon reads the file, and between them there is nothing to be stale.
#
# The leading + runs the command with full privileges, outside User=, the
# namespace options and the seccomp filter. Ubuntu 24.04's redis-server
# unit sets NoExecPaths=/ with an ExecPaths that does not include
# /usr/bin/configd, so without it systemd answers 203/EXEC and leaves redis
# failed -- adopting a hardened service took that service down.
like( $dropin, qr{^ExecStartPre=\+/usr/bin/configd build postfix$}m, 'rebuilt before the daemon starts' );
like( $dropin, qr{^ExecReload=\+/usr/bin/configd build postfix$}m, 'and before it reloads' );
# postfix.service is a oneshot whose ExecStart is /bin/true; the daemon that
# reads main.cf is an instance of the template.
like( $written[0], qr/\Qpostfix@.service.d\E/, 'on the templated unit, so every instance gets it' );
# And the restart goes somewhere else, because systemctl refuses a template:
# "Unit name postfix@.service is missing the instance name." Getting this
# wrong makes adopt report a failure after having done its job perfectly.
is_deeply( [ $language->units() ], ['postfix@.service'], 'the drop-in goes on the template' );
is_deeply( [ $language->services() ], ['postfix.service'], 'and the restart goes to the unit that runs' );
is_deeply( [ $unit->install() ], [], 'installing again writes nothing' );
};
subtest 'a copy of a secret file is as secret as the file' => sub {
my $root = scratch();
# 00-original is the file, so it needs the file's mode. Falling through to
# 0644 published redis.conf's requirepass, and opendkim.conf's key
# locations, to every local user on any distro whose /etc/<package> can be
# traversed.
chmod 0o600, "$root/etc/postfix/master.cf";
Configd->adopt( 'postfix', root => $root );
is(
( stat "$root/etc/postfix/master.cf.d/00-original" )[2] & 0o7777,
0o600, 'master.cf.d/00-original is 0600, like the file it copies'
);
is(
( stat "$root/etc/postfix/main.cf.d/00-original" )[2] & 0o7777,
0o644, 'and main.cf.d/00-original is 0644, like the file it copies'
);
};
subtest 'releasing puts the file back and lets go of the service' => sub {
my $root = scratch();
my $was = Configd::Language::slurp("$root/etc/postfix/main.cf");
Configd->adopt( 'postfix', root => $root );
fragment( $root, 'main.cf', '50-domain.cf', "mydestination = domain.example.com\n" );
Configd->build( 'postfix', root => $root );
my $result = Configd->release( 'postfix', root => $root );
is( Configd::Language::slurp("$root/etc/postfix/main.cf"), $was, 'the original file is back' );
is_deeply( $result->{dropins}, ["$root/etc/systemd/system/postfix\@.service.d/10-configd.conf"], 'the drop-in is gone' );
# Throwing the fragments away would mean a release followed by an adopt
# loses everything anything ever added.
ok( -f "$root/etc/postfix/main.cf.d/50-domain.cf", 'and the fragments are left alone' ); ## no critic (ValuesAndExpressions::ProhibitFiletest_f)
};
subtest 'status says whether this is actually in effect' => sub {
my $root = scratch();
my $before = Configd->status( 'postfix', root => $root );
is( $before->{files}[0]{adopted}, 0, 'not adopted before adopting' );
is( $before->{wrapped}, 0, 'and not wrapped' );
Configd->adopt( 'postfix', root => $root );
fragment( $root, 'main.cf', '50-domain.cf', "mydestination = domain.example.com\n" );
my $after = Configd->status( 'postfix', root => $root );
is( $after->{files}[0]{adopted}, 1, 'adopted after' );
is( $after->{wrapped}, 1, 'and wrapped' );
is_deeply(
$after->{files}[0]{fragments},
[ '00-original', '50-domain.cf' ],
'listing the fragments in the order they are merged'
);
};
subtest 'a language nobody has says so, and says what there is' => sub {
my $error = exception { Configd->language('nosuchthing') };
like( $error, qr/No language 'nosuchthing'/, 'names what was asked for' );
like( $error, qr/postfix/, 'and what it could have been' );
# It becomes part of a module name.
like( exception { Configd->language('../../etc/passwd') }, qr/is not a language name/, 'and a path is not a name' );
my @known = Configd->languages();
ok( scalar( grep { $_ eq 'postfix' } @known ), 'postfix is one of the languages found on disk' );
};
done_testing();
( run in 1.386 second using v1.01-cache-2.11-cpan-364913b4093 )