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 )