Configd

 view release on metacpan or  search on metacpan

Changes  view on Meta::CPAN

    - postfix: accumulate sender_dependent_relayhost_maps and
      smtpd_sender_login_maps, which are lists of lookup tables like the rest of
      the *_maps family and were missed.  Not accumulating the second one is
      dangerous rather than merely wrong: it is what
      reject_authenticated_sender_login_mismatch reads, so a host with two
      domains ends up naming one domain's table and refuses the other domain's
      users when they send.
    - postfix: document what adopting main.cf does not reach -- the lookup
      tables it names, and what to do about the ones behind a restriction list.
    - An empty value on an accumulating directive now resets it, the way a
      systemd drop-in does.  Without it a fragment could only ever add to what
      00-original held, and 00-original is sometimes wrong rather than
      incomplete: a guest whose hostname is a domain it hosts has the package's
      own mydestination naming a domain that must be a virtual mailbox domain
      instead, postfix refuses to have it in both, and nothing could take it out.

0.001 2026-09-05 TEODESIAN
    - Initial Release
    - Languages: postfix (main.cf, master.cf), opendkim, opendmarc, redis

bin/configd  view on Meta::CPAN


    return 0;
}

sub adopt {
    my ( $name, $opt, $opts ) = @_;

    my $result = Configd->adopt( $name, %$opts );

    say_unless_quiet( $opt,
        ( map { "Adopted $_ (its fragments are in $_.d)" } @{ $result->{adopted} } ),
        ( map { "Wrote $_" } @{ $result->{dropins} } ),
    );

    # Nothing the daemon reads has changed until systemd is told about the
    # drop-in, so an adopt that skips this leaves a service running on config it
    # will regenerate differently the next time anything restarts it.
    return systemd( $result->{services}, $opt ) if $opt->{restart};

    say_unless_quiet( $opt, 'Not restarting; run systemctl daemon-reload and restart the service to pick this up.' );
    return 0;

bin/configd  view on Meta::CPAN

    return $opt->{restart} ? systemd( $result->{services}, $opt ) : 0;
}

sub status {
    my ( $name, $opt, $opts ) = @_;

    my $status = Configd->status( $name, %$opts );

    my @lines = ("$status->{language}:");
    foreach my $file ( @{ $status->{files} } ) {
        my $count = scalar @{ $file->{fragments} };
        push @lines, sprintf(
            '  %-30s %s, %d fragment%s',
            $file->{path},
            $file->{adopted} ? 'adopted' : 'NOT adopted',
            $count, $count == 1 ? q{} : 's',
        );
        push @lines, "      $_" for @{ $file->{fragments} };
    }
    push @lines, '  ' . join( ', ', @{ $status->{units} } ) . ( $status->{wrapped} ? ': wrapped' : ': NOT wrapped' );

    say_unless_quiet( $opt, @lines );

    # So that `configd status x && ...` means what it looks like it means.
    return $status->{wrapped} ? 0 : 1;
}


bin/configd  view on Meta::CPAN

    configd status  postfix
    configd build   postfix
    configd release postfix

=head1 DESCRIPTION

Postfix, and plenty like it, keeps its configuration in one file with no
C<conf.d> to add to. Two things configuring the same server therefore cannot
both win.

C<configd adopt> puts a fragment directory beside each such file, moves what is
there now into it as C<00-original>, and installs a systemd drop-in that
regenerates the file every time the service starts or reloads. After that,
configuring the service means writing a file into the fragment directory, which
any number of things can do without treading on each other.

=head1 COMMANDS

=head2 languages

What this installation knows how to adopt.

=head2 adopt LANGUAGE

Take the language's files over and wrap its service. Reloads systemd and
restarts the service unless C<--no-restart> says not to.

Running it twice is safe.

=head2 build LANGUAGE

Regenerate the files from their fragments. This is what the drop-in runs; you
would run it by hand to see what a fragment does without restarting anything.

=head2 status LANGUAGE

Whether the files are adopted, how many fragments each has, and whether the
service is wrapped.

=head2 release LANGUAGE

Put the original files back and remove the drop-in. The fragment directories
are left alone, so adopting again picks up where this left off.

=head1 OPTIONS

=over 4

=item B<--root> DIR

Work under DIR rather than C</>. For building an image, or for looking at what
would happen without touching the running system.

lib/Configd.pm  view on Meta::CPAN



sub status {
    my ( $class, $name, %opts ) = @_;

    my $language = $class->language( $name, %opts );
    my $unit     = Configd::Unit->new( language => $language, %opts );

    my @files;
    foreach my $file ( $language->files() ) {
        my @fragments = $language->fragments($file);
        push @files, {
            path      => $file->{path},
            adopted   => ( scalar grep { basename($_) eq '00-original' } @fragments ) ? 1 : 0,
            fragments => [ map { basename($_) } @fragments ],
        };
    }

    return {
        language => $language->name(),
        files    => \@files,
        units    => [ $language->units() ],
        wrapped  => $unit->installed(),
    };
}

lib/Configd.pm  view on Meta::CPAN

file and the daemon reads them all.  Plenty of software does not ship one.

=head2 What this does

C<configd adopt postfix> gives it one anyway:

=over 4

=item * F</etc/postfix/main.cf> becomes B<generated output>.

=item * F</etc/postfix/main.cf.d/> appears beside it, holding fragments in
main.cf's own syntax.

=item * Whatever was in main.cf becomes C<main.cf.d/00-original>, so the
distribution's defaults and anything the administrator had done keep winning
wherever nothing later has an opinion.

=item * A systemd drop-in regenerates the file every time the service starts or
reloads, so what the daemon reads is always what the fragments say.

=back

Two domains can then each write their own file and both get what they asked for:

    mydestination = $myhostname, localhost, first.example.com, second.example.com

=head2 Which settings merge, and which do not

This is the whole design decision, and it is per setting.

Most are B<values>: two fragments setting C<myhostname> disagree, and the later
one wins.  Some are B<lists>: two fragments each naming a domain in
C<mydestination> both meant it, and joining them is the only answer that does not
lose one.  A language says which is which by overriding C<accumulates>.

L<Configd::Language::postfix> deliberately does B<not> accumulate
C<smtpd_recipient_restrictions> and its relatives, even though they are lists.
They are I<ordered> lists where the order is the meaning, and joining two end to
end produces something that parses and that neither fragment asked for -- a
C<permit_> landing ahead of a check that was supposed to run first is an open
relay.  Two fragments disagreeing about a restriction list is something a person
should look at.

=head2 Caveats

B<Comments are not carried into the generated file.>  A comment is anchored to
the setting below it, and once several fragments have had their say there may be
no such setting any more -- reproducing the distribution's paragraph above a
value that has since been replaced tells the reader something untrue.  They stay
in the fragment they were written in, and C<00-original> keeps every one the file
arrived with.

B<Editing the generated file works until the next restart.>  That is deliberate:
long enough to test something, short enough that nobody comes to rely on it.  The
header on the file says so.

=head2 Requirements

Core perl 5.34 or newer, and nothing else.  Deliberately: this runs from
C<ExecStartPre>, so it stands between a service and starting, and it has to work

lib/Configd.pm  view on Meta::CPAN


Regenerate every file a language manages, returning the paths that changed.

This is what the systemd drop-in runs, so it does exactly this and nothing else:
no C<systemctl>, which would deadlock against the start it is part of, and no
adopting, because a service starting is not the time to be taking files over.

=head2 adopt($name, %opts)

Take a language's files over and wrap its service: move each file into its own
fragment directory as C<00-original>, generate it, and install the drop-in.

Returns a hashref of what happened, which is what the command line prints.  Its
C<services> is what to restart, which is not always what the drop-in went on --
see L<Configd::Language/services()>.

Safe to run again: a file already adopted is regenerated rather than adopted a
second time, and re-adopting is the one thing that would duplicate every setting
in it.

Reloading systemd and restarting the service are the caller's, so that a caller
building an image rather than configuring a running machine can skip them.

=head2 release($name, %opts)

Give a language's files back: restore each C<00-original> and remove the
drop-in.

The fragment directories are left alone. They are somebody's configuration, and
throwing them away on the way out means a release followed by an adopt loses
everything that was ever added.

=head2 status($name, %opts)

What a language is doing right now: its files, whether each is adopted, how many
fragments it has, and whether the drop-in is in place.

=head1 SEE ALSO

Please see those modules/websites for more information related to this module.

=over 4

=item *

L<Configd::Language>, L<Configd::Unit>, L<Configd::Language::postfix>

lib/Configd/Language.pm  view on Meta::CPAN

    return $self->{root};
}


sub path {
    my ( $self, $path ) = @_;
    return $self->{root} . $path;
}


sub fragment_dir {
    my ( $self, $file ) = @_;
    return $self->path( $file->{path} ) . '.d';
}


sub fragments {
    my ( $self, $file ) = @_;

    my $dir = $self->fragment_dir($file);
    opendir( my $dh, $dir ) or return ();
    my @names = sort grep { !m/\A[.]/ && !m/(?:[.](?:disabled|bak|dpkg-old|dpkg-new|dpkg-dist|rpmsave|rpmnew)|~)\z/ } readdir($dh);
    closedir $dh;

    return map { "$dir/$_" } grep { -f "$dir/$_" } @names;    ## no critic (ValuesAndExpressions::ProhibitFiletest_f)
}


sub merge {
    my ( $self, @sets ) = @_;

    my ( @order, %by_key );
    foreach my $set (@sets) {
        foreach my $directive (@$set) {
            my $key = $directive->{key};

            # Comments and blank lines.  Deliberately dropped rather than
            # collected: see the POD above.  A comment is anchored to the
            # setting under it, and once several fragments have had their say
            # there is no longer a setting for it to be anchored to.
            next if !defined $key;

            # A repeatable directive is identified by everything it says, so
            # two different `save` lines are two settings and two identical ones
            # are one.
            $key = "$key\0$directive->{value}"
              if !$self->accumulates($key) && $self->repeats($key);

            if ( !exists $by_key{$key} ) {
                push @order, $key;
                $by_key{$key} = {%$directive};
                next;
            }

            if ( $self->accumulates($key) ) {

                # An empty value resets rather than adding nothing, which is the
                # one thing accumulation otherwise cannot express: a fragment can
                # add to what came before it and never take anything out.  That
                # matters where 00-original is wrong rather than merely
                # incomplete -- a guest whose hostname is the domain it hosts has
                # the package's own mydestination naming a domain that must be a
                # virtual mailbox domain instead, and no amount of adding fixes
                # it.  systemd drop-ins spell the same thing the same way, and
                # for these parameters an empty value is what postfix reads it
                # as anyway.
                if ( !length( $directive->{value} // q{} ) ) {
                    $by_key{$key} = {%$directive};

lib/Configd/Language.pm  view on Meta::CPAN


                my $separator = $self->separator($key);
                $by_key{$key}{value} = join(
                    $separator,
                    grep { defined && length }
                      map { _trim( $_, $separator ) } $by_key{$key}{value}, $directive->{value}
                );
                next;
            }

            # The later fragment is the one that meant it.
            $by_key{$key} = {%$directive};
        }
    }

    return [ map { $by_key{$_} } @order ];
}

# A value that already ends in the separator is common -- postfix's own
# mydestination is written over several lines and the last one keeps its comma
# -- and joining onto it gives ",, " which postfix reads but nobody meant.

lib/Configd/Language.pm  view on Meta::CPAN

    $value =~ s/\A$class+//;
    $value =~ s/$class+\z//;
    return $value;
}


sub build {
    my ( $self, $file ) = @_;

    my @sets;
    foreach my $fragment ( $self->fragments($file) ) {
        push @sets, $self->parse( slurp($fragment) );
    }

    return $self->emit( $self->merge(@sets) );
}


sub header {
    my ( $self, $file ) = @_;

    my $dir = $file->{path} . '.d';
    return <<"HEADER";
# Generated by configd.  Do not edit: this file is rebuilt from
# $dir every time the service starts or reloads,
# and anything you change here will be gone the next time that happens.
#
# Add a file to that directory instead, in this file's own syntax.  They are
# read in order by name, and later ones win.  What was here when configd
# adopted this file is 00-original, comments and all -- those stay in the
# fragments rather than being merged into this file, where they could only
# describe settings something later has since changed.
HEADER
}


sub write {
    my ( $self, $file ) = @_;

    my $target = $self->path( $file->{path} );
    my $wanted = $self->header($file) . $self->build($file);

lib/Configd/Language.pm  view on Meta::CPAN

    return 1;
}


sub adopt {
    my ($self) = @_;

    my @adopted;
    foreach my $file ( $self->files() ) {
        my $target   = $self->path( $file->{path} );
        my $dir      = $self->fragment_dir($file);
        my $original = "$dir/00-original";

        unless ( -d $dir ) {
            make_path($dir);

            # No more permissive than the directory the config file lives in:
            # the fragments are the configuration now, and a 0755 directory
            # beside a 0700 one hands them to anybody.
            my ( undef, $parent ) = File::Basename::fileparse($target);
            my @stat = stat $parent;
            chmod( $stat[2] & 0o7777, $dir ) if @stat;
        }

        # Already ours.  Re-adopting would take the file we generated last time
        # and make it the first fragment, which duplicates every setting in it.
        if ( -f $original ) {    ## no critic (ValuesAndExpressions::ProhibitFiletest_f)
            $self->write($file);
            next;
        }

        # 00-original is a copy of the file, so it is as secret as the file.
        # Letting spew fall 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.
        my @was = stat $target;

lib/Configd/Language.pm  view on Meta::CPAN


    return @adopted;
}


sub release {
    my ($self) = @_;

    my @released;
    foreach my $file ( $self->files() ) {
        my $original = $self->fragment_dir($file) . '/00-original';
        next unless -f $original;    ## no critic (ValuesAndExpressions::ProhibitFiletest_f)

        spew( $self->path( $file->{path} ), slurp($original) );
        push @released, $file->{path};
    }

    return @released;
}


lib/Configd/Language.pm  view on Meta::CPAN


Plenty of software has no C<conf.d>.  Its configuration is one file, and
anything wanting to add to it has to edit that file -- which works exactly once.
The second thing to try it either overwrites what the first did or appends a
duplicate, and neither is what anybody wanted.  Provisioning two domains onto
one mail server is the case that keeps coming up: both want to be in
C<mydestination>, and C<postconf -e> only knows how to set it.

Configd gives that software a C<conf.d> anyway.  For each file it manages there
is a directory beside it -- C</etc/postfix/main.cf> gets C</etc/postfix/main.cf.d>
-- holding fragments in the file's own syntax.  The file itself becomes
B<generated output>: Configd reads every fragment in order, merges them, and
writes the result.  Nothing edits the file any more, and two things adding to it
no longer have to know about each other.

A B<language> is one config file format, and what it has to know is how to read
that format, how two fragments of it combine, and how to write it back.

=head2 Before you write a language: check there is not one already

B<Do not adopt a file whose software can already read a directory.>  A native
C<conf.d> is better than anything here by every measure -- the daemon reads the
fragments itself, there is no generated file to be edited by mistake, no drop-in
to go wrong on a hardened unit, and nothing to go stale if configd is removed.
Configd exists for the software that has no such thing, and using it where a
real mechanism exists trades a working feature for a moving part.

The check is quick, and the answers here were all surprising in one direction or
the other, so make it rather than assuming:

=over 4

=item * Is there a directory the daemon reads? C<ls /etc/E<lt>thingE<gt>/conf.d>,
and then whether the config actually names it.  chrony ships
F</etc/chrony/conf.d> and reads it only if C<chrony.conf> says C<confdir>.  A
directory that exists and is never read looks exactly like one that works.

=item * Is there an include directive, and B<does it take a glob or a
directory?>  A single-file include is not a C<conf.d>: adding a fragment still
means editing the main file, which is the thing we are trying to stop.  redis's
C<include> is a fatal error on a glob.  opendkim's C<Include> reads one file,
refuses a glob, and B<silently ignores a directory> -- it exits zero having read
nothing at all, so testing that it "worked" proves nothing unless the file you
point it at contains something it would reject.

=back

What the four here answered:

lib/Configd/Language.pm  view on Meta::CPAN

overwriting the vendor's C<confdir> line out of the file, which left a conf.d
that looked like it worked and did nothing; putting the line back was one line
of template against a language, a drop-in and three bugs.

=head2 How it is kept honest

A generated file that anything else can edit will be edited, and the edit will
be lost the next time it is generated.  So the file is generated at the moment
the service reads it: Configd installs a systemd drop-in on the units the
language names, rebuilding the file before the daemon starts and again before it
is reloaded.  Whatever is in the fragments is what the running service has.

=head2 The first fragment is what was already there

Adopting a file moves it into its own fragment directory as C<00-original>
before anything else is written.  The distribution's defaults, and whatever the
administrator had done to it, become the first fragment and keep winning
wherever nothing later has an opinion.  That is also what makes adoption
reversible: put C<00-original> back and remove the drop-in.

=head1 NAME

Configd::Language - base class for the languages: how one config file format is
read, merged and written back.

=head1 READING AND WRITING

lib/Configd/Language.pm  view on Meta::CPAN

0644 main.cf back as 0600.

=head1 METHODS TO OVERRIDE

=head2 files()

The files this language manages, as a list of hashrefs:

    { path => '/etc/postfix/main.cf', owner => 'root:root', mode => 0644 }

C<path> is the generated file; its fragment directory is C<path> with C<.d>
appended.  C<mode> and C<owner> are what a generated file is created as when there was
nothing there before; C<owner> is C<"user:group">.  A file that already exists
keeps the mode and ownership it had, so adopting one never changes either.

Give C<owner> whenever the service runs as its own user and owns its config.
Recreated as root, such a file does not lose a setting -- the daemon cannot read
it at all, and does not start.

=head2 units()

lib/Configd/Language.pm  view on Meta::CPAN

belongs on a template: C<systemctl try-restart postfix@.service> is refused,
because a template is not a thing that runs --

    Unit name postfix@.service is missing the instance name.

-- so the drop-in goes on the template and the restart goes to whatever unit
actually has a process behind it.

=head2 parse($text)

The directives in a fragment, as an arrayref, in the order they were written.

Each directive is a hashref.  What is in it is the language's business, but two
keys are common to all of them because C<merge> reads them:

=over 4

=item * C<key> -- what makes two directives the same directive.  Two with the
same key are the same setting said twice, and the later one wins unless the
language says otherwise.  A directive with no key is never merged with anything
and is kept in the order it arrived, which is what comments and blank lines are.

lib/Configd/Language.pm  view on Meta::CPAN

=item * C<value> -- what C<key> was set to.

=back

=head2 emit($directives)

The text of a config file holding those directives, ready to write.

=head2 accumulates($key)

Whether a directive is a list that fragments add to, rather than a value that a
later fragment replaces.

False by default, which is the right answer for most settings: two fragments
setting C<myhostname> disagree, and the later one wins.  It is the wrong answer
for the ones that are lists -- C<mydestination>, C<virtual_mailbox_domains> --
where two fragments each naming a domain both meant it, and replacing loses one
of them.  That distinction is the whole reason this exists.

=head2 repeats($key)

Whether a directive may appear more than once, each occurrence meaning something
of its own.

False by default.  Redis takes C<save 900 1> and C<save 300 10> and means both;
chrony takes a C<server> line per time source.  Neither is a value a later
fragment replaces, and neither is a list to join with commas -- they are separate
lines that all have to survive.

Two occurrences that say exactly the same thing still collapse into one, which
is what makes a fragment safe to write without checking whether somebody else
already asked for it.

A key cannot both accumulate and repeat; C<accumulates> is checked first.

=head2 separator($key)

What joins the parts of an accumulating directive.  A comma and a space by
default, which is what postfix uses; whitespace-separated languages override it.

=head1 METHODS

lib/Configd/Language.pm  view on Meta::CPAN

=head2 $language->root()

The directory every path this language touches is relocated under, or the empty
string for the running system.

=head2 $language->path($path)

C<$path> under this language's C<root>.  Every path in this class goes through
it, so that nothing writes outside the root it was given.

=head2 $language->fragment_dir($file)

The directory a file's fragments live in: the file's own path with C<.d> on the
end.

=head2 $language->fragments($file)

The fragment files for one managed file, in the order they are merged.

Sorted by name, so the numeric prefixes everybody already writes on C<conf.d>
entries do what they look like they do.  Names starting with a dot are skipped,
and so is anything ending in C<~>, C<.disabled>, C<.bak>, or one of the suffixes
dpkg and rpm leave behind -- C<.dpkg-old>, C<.dpkg-new>, C<.dpkg-dist>,
C<.rpmsave>, C<.rpmnew>.  Editors and package managers leave those lying about,
and a stray backup silently taking part in the merge is a bad afternoon.

=head2 $language->merge(@fragment_sets)

One list of directives out of several, applying C<accumulates> to decide which
of two directives for the same key wins and which of them join up.

An accumulating directive with an B<empty value> resets it: whatever earlier
fragments put there is dropped, and anything after this adds to nothing rather
than to that.  It is the one thing accumulation cannot otherwise say, since a
fragment can add to what came before it and never take something out -- and
C<00-original> is sometimes wrong rather than merely incomplete.  A guest whose
hostname is a domain it hosts is the case that keeps coming up: the package's
own C<mydestination> names that domain, the domain has to be a virtual mailbox
domain instead, postfix will not have it in both, and no amount of adding fixes
it.

    mydestination =
    mydestination = $myhostname, localhost

systemd drop-ins spell it the same way, for the same reason.

Order is the order the keys were first seen, so a generated file reads like the
fragments that made it rather than like a hash.

Comments and blank lines are B<not> carried through.  A merged file cannot say
where a comment belongs -- the distribution's paragraph explaining a default sits
above a setting some later fragment has since replaced, and reproducing it there
tells the reader something that is no longer true.  They stay in the fragment
they were written in, which is where somebody editing will be looking, and
C<00-original> keeps every one the file arrived with.

=head2 $language->build($file)

Read every fragment for one file, merge them, and return the text to write.

=head2 $language->header($file)

The comment Configd puts at the top of a file it generates, saying so.

Somebody is going to edit the generated file -- it is where the settings are,
and it is where every piece of documentation on the internet says they live.
This is the one chance to tell them their edit will not survive the next restart
and where to put it instead.

lib/Configd/Language.pm  view on Meta::CPAN


Generate one file and put it in place, returning true if what is on disk
changed.

Written through a temporary file in the same directory and renamed over the
target, so that a service reading it at that moment sees the old file or the
new one and never half of either.

=head2 $language->adopt()

Take over every file this language manages: make each one's fragment directory,
move what is there now into it as C<00-original>, and generate the file.

Does nothing to a file it has already adopted, so running it twice is safe.

=head2 $language->release()

Give a file back: put C<00-original> where it came from and forget about it.

The counterpart to C<adopt>, and the reason C<00-original> is kept rather than
merged away.  Removing the drop-in is L<Configd::Unit>'s half of it.

lib/Configd/Language/postfix.pm  view on Meta::CPAN

use 5.034;

use strict;
use warnings FATAL => 'all';

use re '/aa';

use parent qw{Configd::Language};


# The parameters where two fragments each naming something both meant it.  This
# is the list postfix's own documentation describes as taking "a comma and/or
# space separated list of domain names" and friends -- setting one of these from
# two places is the case this whole distribution exists for.
#
# Deliberately not here: the *_restrictions parameters.  They are lists too, but
# ordered ones where the meaning depends on which check comes first and a
# permit_ in the wrong place opens a relay.  Joining two of them end to end
# produces something that parses and is not what either fragment meant, so they
# stay a value a later fragment replaces, and disagreeing about one is something
# somebody should have to notice.
my %ACCUMULATES = map { $_ => 1 } qw{
  mydestination
  myhostname_aliases
  masquerade_domains
  mynetworks
  relay_domains
  virtual_alias_domains
  virtual_mailbox_domains
  local_recipient_maps

lib/Configd/Language/postfix.pm  view on Meta::CPAN


Postfix has C<postconf -e> and nothing else.  It sets a parameter by rewriting
C<main.cf>, which is fine for a person at a terminal and wrong for anything
automated: two things configuring the same server cannot both set
C<mydestination>, because the second one to run replaces what the first wrote
rather than adding to it.  Hosting two domains on one mail server is enough to
hit it, and the failure is quiet -- mail for the first domain simply stops being
local.

So C<main.cf> and C<master.cf> become generated files with C<main.cf.d> and
C<master.cf.d> beside them, and each domain drops in a fragment naming itself.
The parameters that are lists are merged as lists; see L</ACCUMULATING PARAMETERS>.

=head2 What the fragments look like

Exactly like the file they add to, because that is the point -- anything you
would have written in C<main.cf> is a fragment:

    # /etc/postfix/main.cf.d/50-example.com.cf
    mydestination = example.com
    virtual_mailbox_domains = example.com
    virtual_mailbox_maps = hash:/etc/postfix/virtual/maps

=head1 NAME

Configd::Language::postfix - main.cf and master.cf, which postfix has never had
a conf.d for.

lib/Configd/Language/postfix.pm  view on Meta::CPAN

=head1 ACCUMULATING PARAMETERS

C<sender_dependent_relayhost_maps> and C<smtpd_sender_login_maps> are here for
the same reason as the rest and were missed the first time, which is worth
naming because the second one fails dangerously.  It is what
C<reject_authenticated_sender_login_mismatch> reads, so a host where it does not
accumulate ends up naming one domain's table -- and every other domain's users,
whose addresses are then owned by nobody, are refused when they try to send.

The parameters postfix documents as comma-or-space separated lists, where two
fragments each naming a domain, a map or a milter both meant it:
C<mydestination>, C<mynetworks>, C<relay_domains>, the C<virtual_*> family, the
C<*_maps> and C<*_checks> families, C<smtpd_milters> and C<inet_interfaces>
among them.  Anything else is a value, and a later fragment replaces it.

The C<*_restrictions> parameters are deliberately B<not> accumulated even though
they are lists.  They are ordered, the order is what they mean, and joining two
of them end to end gives something that parses and that neither fragment asked
for -- a C<permit_> landing ahead of a check that was supposed to run first is
an open relay.  Two fragments disagreeing about a restriction list is something
a person should look at.

=head1 WHAT THIS DOES NOT REACH: THE LOOKUP TABLES

main.cf is full of paths, and none of them are this language's business.
C<virtual_mailbox_maps> names a file of addresses; C<header_checks> names a file
of patterns; C<check_recipient_access> names one from inside a restriction list.
Adopting main.cf merges the parameters that B<name> those tables and does
nothing whatever to the tables themselves, which is worth saying out loud
because the parameter merging cleanly is exactly what makes it easy to believe

lib/Configd/Language/postfix.pm  view on Meta::CPAN

    # 50-second.example.com
    virtual_mailbox_maps = hash:/etc/postfix/virtual/second.example.com

That is the whole answer for C<virtual_mailbox_maps>, C<virtual_alias_maps>,
C<transport_maps>, C<header_checks> and the rest of the accumulating list, and
it needs nothing from this distribution.

It is B<not> available for a table named from inside a restriction list.
C<check_recipient_access pcre:/etc/postfix/recipient_access> lives inside
C<smtpd_recipient_restrictions>, which does not accumulate and must not, so the
path in it is whatever the last fragment to mention that parameter said.  Every
domain therefore shares one table, and the second one provisioned overwrites the
first one's -- the same failure adopting main.cf was meant to end, one level
down and out of reach.

Two things follow, and both are the caller's rather than this language's:

=over 4

=item * A shared table has to be B<assembled> rather than merged, because these
are ordered files.  A pcre or regexp table is read top to bottom and the first
match wins, so a catch-all belongs at the end and concatenating two domains'
tables puts one in the middle.  Numeric prefixes on the fragments, and the
catch-all last, is the shape that works -- the same shape configd gives a config
file, which is not a coincidence but is not implemented here either.

=item * Before building any of that, check whether the table is needed at all.
Postfix rejects a recipient in a virtual mailbox domain that is absent from
C<virtual_mailbox_maps> by itself -- "User unknown in virtual mailbox table" --
and one in a local domain absent from C<local_recipient_maps> likewise, so an
access table written to reject unknown recipients is often restating a check
postfix already makes, and is only load-bearing because the configuration has a
domain in two address classes at once.  L<https://www.postfix.org/ADDRESS_CLASS_README.html>

lib/Configd/Language/postfix.pm  view on Meta::CPAN


C<postfix@.service>, the templated unit.

=head2 services()

C<postfix.service>, which is what can actually be restarted.

=head2 accumulates($key)

True for the list parameters above.  Never true of a master.cf entry, which is a
row rather than a list: two fragments configuring one service disagree about it,
and the later one wins.  The base class's comma is therefore the only separator
this language ever needs.

=head2 parse($text)

Read a fragment of either file.

Which one is worked out from the text rather than from a filename, because a
C<main.cf> line and a C<master.cf> line cannot be mistaken for each other: the
first has an C<=> and the second is a row of columns.

=head2 emit($directives)

Write the file back.  Which file, again, from what is in it.

=head1 SEE ALSO

lib/Configd/Language/redis.pm  view on Meta::CPAN

    Configd->build('redis');

=head1 DESCRIPTION

Redis has an C<include> directive, which is not the same thing as a C<conf.d>:
the included file has to be named from the file doing the including, so adding
one still means editing C<redis.conf>.  Include order also decides precedence in
a way that surprises people -- a directive in the main file B<after> an include
wins over the included one.

So C<redis.conf> becomes generated, and a fragment is a file rather than a file
plus an edit.

=head1 NAME

Configd::Language::redis - redis.conf, which has no conf.d and a handful of
directives you say more than once.

=head1 REPEATED DIRECTIVES

Most of redis.conf is one value per directive: a second C<maxmemory> replaces

lib/Configd/Language/redis.pm  view on Meta::CPAN

=item * C<save> -- one line per snapshot point, C<save 900 1> and C<save 300 10>
being two conditions rather than one overriding the other.

=item * C<client-output-buffer-limit> -- one line per client class.

=item * C<rename-command>, C<module>, C<include>, C<bind> when written as
several lines.

=back

Those are matched on everything they say, so two fragments both asking for
C<save 900 1> get one line and two asking for different snapshot points get
both.  That is what lets a fragment be written without checking whether somebody
else already asked for the same thing.

=head1 METHODS

=head2 files()

F</etc/redis/redis.conf>, 0640 root:redis if it has to be created.

=head2 units()

lib/Configd/Syntax/Spaced.pm  view on Meta::CPAN


=head1 NAME

Configd::Syntax::Spaced - config files that are a directive, some whitespace and
a value.

=head1 METHODS

=head2 $language->parse($text)

The directives in a fragment.  Comments and blank lines come back as text with
no key, so they keep their place in a file that is not merged.

=head2 $language->emit($directives)

The file those directives make.

Written as C<directive value>, one space, rather than reproducing whatever
column alignment the original had.  Alignment is a property of a file somebody
maintained by hand, and this file is generated from several of them.

lib/Configd/Unit.pm  view on Meta::CPAN

    push @exec, "ExecReload=+$configd build $language" if $self->{language}->reloads();

    my $preamble = <<"UNIT";
# Installed by configd.  Removing this file and running `systemctl daemon-reload`
# is all it takes to stop configd having anything to do with this service; the
# files below stay exactly as they were last generated.
#
$list
#
# Each of those is built from the directory of the same name with .d on the end.
# Edit the fragments, not the file: this rebuilds it on every start and reload.

[Service]
UNIT

    return $preamble . join( "\n", @exec ) . "\n";
}


sub install {
    my ($self) = @_;

lib/Configd/Unit.pm  view on Meta::CPAN

    my $wrapped = $unit->installed();

=head1 DESCRIPTION

A generated file is only true until somebody edits it, and the file Configd
generates is the one every piece of documentation on the internet tells people
to edit.  Rather than trying to stop that, Configd regenerates the file at the
moment it matters: a systemd drop-in on the units the language names, running
C<configd build> before the daemon starts and again before it is told to reload.

The daemon therefore always reads what the fragments say, and an edit to the
generated file survives exactly until the next restart -- which is long enough
to test something and short enough that nobody comes to rely on it.

=head2 Why a drop-in rather than a unit of our own

Replacing the packaged unit means owning it: every upgrade that changes it is a
conflict to resolve, and a distribution that reworks how the service starts
breaks a copy that was accurate when it was made.  A drop-in in
C</etc/systemd/system/E<lt>unitE<gt>.d/> adds to whatever the package ships and
keeps working across an upgrade that rewrites it.

t/Configd-Language-postfix.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-Language-postfix.t - reading main.cf and master.cf, and what happens
when two fragments have something to say about the same parameter

=cut

use Test::More;
use Test::Fatal qw{exception};

use FindBin::libs;

use Configd::Language::postfix();    ## no critic (ProhibitUnusedImports)

t/Configd-Language-postfix.t  view on Meta::CPAN

    my @comments = grep { !defined $_->{key} } @$parsed;
    ok( scalar @comments, 'comments and blank lines survive parsing' );
};

subtest 'a parameter said twice is the later one' => sub {
    my $merged = $postfix->merge(
        $postfix->parse("myhostname = first.example.com\n"),
        $postfix->parse("myhostname = second.example.com\n"),
    );

    is( value_of( $merged, 'myhostname' ), 'second.example.com', 'the later fragment wins' );
};

subtest 'two domains on one mail server both stay local' => sub {

    # The case the whole distribution exists for.  postconf -e sets
    # mydestination, so provisioning the second domain onto a server that
    # already hosts the first replaces it -- and mail for the first domain
    # quietly stops being delivered locally.
    my $merged = $postfix->merge(
        $postfix->parse("mydestination = \$myhostname, localhost\n"),

t/Configd-Language-postfix.t  view on Meta::CPAN

    is(
        $destination,
        '$myhostname, localhost, www.example.com, second.example.com',
        'just the one between each pair'
    );
};

subtest 'restriction lists are not joined, on purpose' => sub {

    # They are lists, but ordered ones where the order is the meaning.  Joining
    # two end to end gives something that parses and that neither fragment
    # asked for -- a permit_ ahead of a check that was meant to run first is an
    # open relay.
    ok( !$postfix->accumulates('smtpd_recipient_restrictions'), 'not accumulated' );
    ok( $postfix->accumulates('mydestination'),                 'unlike the lists that are just sets' );

    my $merged = $postfix->merge(
        $postfix->parse("smtpd_recipient_restrictions = reject_unauth_destination\n"),
        $postfix->parse("smtpd_recipient_restrictions = permit_mynetworks\n"),
    );

    is(
        value_of( $merged, 'smtpd_recipient_restrictions' ),
        'permit_mynetworks',
        'the later fragment replaces rather than appending'
    );
};

subtest 'what goes in comes back out' => sub {
    my $original = <<'CF';
# Managed by nobody in particular

myhostname = mail.example.com
mydestination = example.com, localhost
CF

t/Configd-Language-postfix.t  view on Meta::CPAN

    my $merged = $postfix->merge(
        $postfix->parse("smtp      inet  n       -       y       -       -       smtpd\n"),
        $postfix->parse("smtp      inet  n       -       y       -       1       smtpd\n"),
    );

    my ($smtp) = grep { defined $_->{key} && $_->{key} eq 'smtp/inet' } @$merged;
    is( $smtp->{columns}[6],                           '1', 'the later entry replaces the earlier one' );
    is( scalar( grep { defined $_->{key} } @$merged ), 1,   'rather than both being written' );
};

subtest 'which file a fragment is gets worked out from the fragment' => sub {

    # A filename would do it, except that fragments get named for the domain
    # that dropped them in rather than for the file they add to.
    ok( !$postfix->_is_master("mydestination = example.com\n"), 'a parameter is main.cf' );
    ok( $postfix->_is_master("smtp inet n - y - - smtpd\n"),    'a service row is master.cf' );
    ok( !$postfix->_is_master("# just a comment\n"),            'and nothing at all is main.cf' );
};

subtest 'a line that is neither is refused rather than dropped' => sub {

    # Silently skipping it would generate a main.cf missing a setting somebody
    # wrote, and they would have no way of telling.

t/Configd-Language-postfix.t  view on Meta::CPAN


    # The restriction lists still must not, whatever else does.
    ok( !$postfix->accumulates('smtpd_recipient_restrictions'), 'a restriction list still does not' );
};

subtest 'an empty accumulating value resets what came before it' => sub {

    # The case: a guest whose hostname is the domain it hosts.  The package's
    # own main.cf then names that domain in mydestination, it has to be a
    # virtual mailbox domain instead, and postfix will not have it in both --
    # so a fragment has to be able to take something out, not only add.
    my $merged = $postfix->merge(
        $postfix->parse("mydestination = \$myhostname, mail.example.com, localhost.example.com, localhost\n"),
        $postfix->parse("mydestination =\nmydestination = \$myhostname, localhost\n"),
    );
    is(
        value_of( $merged, 'mydestination' ), '$myhostname, localhost',
        'the reset drops it and what follows starts from nothing'
    );

    # And a later fragment still adds to what the reset left, so a reset is not
    # a way of claiming the parameter for good.
    $merged = $postfix->merge(
        $postfix->parse("mydestination = old.example.com\n"),
        $postfix->parse("mydestination =\nmydestination = localhost\n"),
        $postfix->parse("mydestination = extra.example.com\n"),
    );
    is(
        value_of( $merged, 'mydestination' ), 'localhost, extra.example.com',
        'a fragment after the reset accumulates onto it'
    );

    # Only where the parameter accumulates: everywhere else an empty value is a
    # value, and setting something to nothing is a thing people mean.
    $merged = $postfix->merge(
        $postfix->parse("relayhost = [smtp.example.com]\n"),
        $postfix->parse("relayhost =\n"),
    );
    is( value_of( $merged, 'relayhost' ), '', 'an ordinary parameter set to empty is just empty' );
};

t/Configd-Syntax-Spaced.t  view on Meta::CPAN

};

subtest 'opendkim and opendmarc are values, last one wins' => sub {
    foreach my $name (qw{opendkim opendmarc}) {
        my $language = "Configd::Language::$name"->new();

        my $merged = $language->merge(
            $language->parse("UMask 007\n"),
            $language->parse("UMask 000\n"),
        );
        is( value_of( $merged, 'UMask' ), '000', "$name: the later fragment wins" );

        # Nothing in either file is a list somebody adds to, so nothing should
        # be quietly joined.
        ok( !$language->accumulates('UMask'), "$name: nothing accumulates" );
        ok( !$language->repeats('UMask'),     "$name: nothing repeats" );
    }

    # Both files hold a signing key's location and are 0600 because of it.
    foreach my $name (qw{opendkim opendmarc}) {
        my ($file) = "Configd::Language::$name"->files();

t/Configd-Syntax-Spaced.t  view on Meta::CPAN

        $redis->parse("save 900 1\nsave 300 10\nmaxmemory 1gb\n"),
        $redis->parse("save 60 10000\nmaxmemory 2gb\n"),
    );

    is( scalar( lines_for( $redis, $merged, 'save' ) ), 3,     'all three snapshot points survive' );
    is( value_of( $merged, 'maxmemory' ),               '2gb', 'and maxmemory is still one value, the later one' );
};

subtest 'the same repeated line twice is one line' => sub {

    # What makes a fragment safe to write without checking whether somebody else
    # already asked for the same thing.
    my $redis  = 'Configd::Language::redis'->new();
    my $merged = $redis->merge(
        $redis->parse("save 900 1\n"),
        $redis->parse("save 900 1\n"),
    );

    is( scalar( lines_for( $redis, $merged, 'save' ) ), 1, 'collapsed rather than said twice' );
};

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' );

t/Configd.t  view on Meta::CPAN

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' );

t/Configd.t  view on Meta::CPAN

        ( 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' );

t/configd.t  view on Meta::CPAN

when it is allowed to touch systemd

=cut

use Test::More;
use Test::MockModule;

use FindBin;
use FindBin::libs;

use Test::Configd qw{scratch fragment};

use Configd::Language();

require_ok("$FindBin::RealBin/../bin/configd") or BAIL_OUT('the modulino does not load');

# Every command's output, and what it exited.  The commands are run in process
# rather than through system(), so that a test failure says which line was
# wrong rather than which exit code was.
sub run {
    my (@args) = @_;

t/configd.t  view on Meta::CPAN

};

subtest 'build says what it rebuilt, and says when there was nothing to do' => sub {
    my $root = scratch();
    run( '--root', $root, 'adopt', 'postfix' );

    my ( $exit, $out ) = run( '--root', $root, 'build', 'postfix' );
    is( $exit, 0, 'exits zero' );
    like( $out, qr/already up to date/, 'nothing changed' );

    fragment( $root, 'main.cf', '50-example.cf', "mydestination = example.com\n" );
    ( $exit, $out ) = run( '--root', $root, 'build', 'postfix' );
    like( $out, qr{Rebuilt /etc/postfix/main\.cf}, 'and names the file when one does' );
};

subtest 'status exits non-zero until the service is actually wrapped' => sub {

    # So that `configd status postfix && ...` means what it looks like it means.
    my $root = scratch();

    my ( $exit, $out ) = run( '--root', $root, 'status', 'postfix' );
    is( $exit, 1, 'not wrapped is a failure' );
    like( $out, qr/NOT adopted/, 'and it says the files are not adopted' );
    like( $out, qr/NOT wrapped/, 'nor the service wrapped' );

    run( '--root', $root, 'adopt', 'postfix' );
    fragment( $root, 'main.cf', '50-example.cf', "mydestination = example.com\n" );

    ( $exit, $out ) = run( '--root', $root, 'status', 'postfix' );
    is( $exit, 0, 'wrapped is a success' );
    like( $out, qr/2 fragments/,          'counting the fragments' );
    like( $out, qr/^\s+50-example\.cf$/m, 'and naming them' );

    # The drop-in is on the template, which is what status reports; the restart
    # goes elsewhere.  Reporting the restart target here would say the wrong
    # unit was wrapped.
    like( $out, qr/\Qpostfix@.service\E: wrapped/, 'the unit the drop-in is on' );
};

subtest 'release puts it back and lets go' => sub {
    @dispatched = ();

t/lib/Test/Configd.pm  view on Meta::CPAN


use re '/aa';

use parent qw{Exporter};

use File::Path qw{make_path};
use File::Temp qw{tempdir};

use Configd::Language();

our @EXPORT_OK = qw{scratch fragment};

=head1 NAME

Test::Configd - the fixture the tests share: a root with a postfix in it

=head1 SYNOPSIS

    use Test::Configd qw{scratch fragment};

    my $root = scratch();
    fragment( $root, 'main.cf', '50-example.cf', "mydestination = example.com\n" );

=head1 FUNCTIONS

=head2 scratch()

A temporary root holding the main.cf and master.cf a freshly installed postfix
has, at the modes a real one has them: 0644 and 0600.  Cleaned up with the
process.

=cut

t/lib/Test/Configd.pm  view on Meta::CPAN

# service type  private unpriv  chroot  wakeup  maxproc command
smtp      inet  n       -       y       -       -       smtpd
CF

    chmod 0o644, "$root/etc/postfix/main.cf";
    chmod 0o600, "$root/etc/postfix/master.cf";

    return $root;
}

=head2 fragment($root, $file, $name, $text)

Drop a fragment into one of those files' fragment directories.

=cut

sub fragment {
    my ( $root, $file, $name, $text ) = @_;

    make_path("$root/etc/postfix/$file.d");
    Configd::Language::spew( "$root/etc/postfix/$file.d/$name", $text );

    return;
}

1;



( run in 2.458 seconds using v1.01-cache-2.11-cpan-364913b4093 )