NAME
Configd::Language - Base class for the languages: how one config file format is read, merged and written back.
VERSION
version 0.002
SYNOPSIS
package Configd::Language::example;
use parent qw{Configd::Language};
sub files { return ( { path => '/etc/example.conf', mode => 0o644 } ) }
sub units { return ('example.service') }
sub parse {
my ( $self, $text ) = @_;
return [ map { m/(\w+)=(.*)/ ? { key => $1, value => $2 } : { text => $_ } }
split( qq{\n}, $text ) ];
}
sub emit {
my ( $self, $directives ) = @_;
return join( qq{\n}, map { defined $_->{key} ? "$_->{key}=$_->{value}" : $_->{text} } @$directives );
}
sub accumulates { my ( $self, $key ) = @_; return $key eq 'domains' }
DESCRIPTION
Plenty of software has no 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 mydestination, and postconf -e only knows how to set it.
Configd gives that software a conf.d anyway. For each file it manages there is a directory beside it -- /etc/postfix/main.cf gets /etc/postfix/main.cf.d -- holding fragments in the file's own syntax. The file itself becomes 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 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.
Before you write a language: check there is not one already
Do not adopt a file whose software can already read a directory. A native 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:
Is there a directory the daemon reads?
ls /etc/<thing>/conf.d, and then whether the config actually names it. chrony ships /etc/chrony/conf.d and reads it only ifchrony.confsaysconfdir. A directory that exists and is never read looks exactly like one that works.Is there an include directive, and does it take a glob or a directory? A single-file include is not a
conf.d: adding a fragment still means editing the main file, which is the thing we are trying to stop. redis'sincludeis a fatal error on a glob. opendkim'sIncludereads one file, refuses a glob, and 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.
What the four here answered:
postfix nothing at all -> ours
opendmarc Include is not a directive it knows -> ours
redis include of one file; a glob is a fatal error -> ours
opendkim Include of one file; glob refused, directory ignored -> ours
chrony confdir, shipped and supported since 4.0 -> NOT ours
chrony had a language here and lost it. The fleet's own template was overwriting the vendor's 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.
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.
The first fragment is what was already there
Adopting a file moves it into its own fragment directory as 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 00-original back and remove the drop-in.
NAME
Configd::Language - base class for the languages: how one config file format is read, merged and written back.
READING AND WRITING
Done with core Perl rather than the usual conveniences. This runs from ExecStartPre, so it stands between a service and starting: a dependency that has to be installed first is a service that does not come up on a fresh guest.
For the same reason this distribution asks for perl 5.34 rather than the 5.41 the rest of the fleet is written against. The perl that runs it is whichever one the guest already has -- Ubuntu 24.04 ships 5.38, 22.04 ships 5.34 -- and a config generator that needs a newer perl installed before it can generate a config is no use on the machines that most need it. 5.34 is what 0oNNN octal literals want; nothing here needs more than that.
slurp($path)
The whole of a file, decoded as UTF-8, or an exception naming what could not be read.
spew($path, $text, $mode, $owner)
Writes through a temporary file in the same directory and renames over the target, so a daemon reading at that moment gets the old file or the new one and never half of either.
A file that was already there keeps the mode and ownership it had. A new one is created $mode, or 0644, and owned by $owner -- "user:group" -- if one is given and the account exists.
$owner matters more than it looks. A service that runs as its own user and owns its own config, as opendkim and opendmarc both do, cannot read that config if it is recreated as root: it does not fail to load a setting, it fails to start. This belongs here rather than in the callers because File::Temp makes its file 0600 and the rename carries that onto the target -- so every path that writes a config file would otherwise have to remember to put the permissions back, and the one that forgot was release, which handed a 0644 main.cf back as 0600.
METHODS TO OVERRIDE
files()
The files this language manages, as a list of hashrefs:
{ path => '/etc/postfix/main.cf', owner => 'root:root', mode => 0644 }
path is the generated file; its fragment directory is path with .d appended. mode and owner are what a generated file is created as when there was nothing there before; owner is "user:group". A file that already exists keeps the mode and ownership it had, so adopting one never changes either.
Give 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.
units()
The systemd units to install the drop-in on, as a list of names.
A templated unit is named with the @ and no instance -- postfix@.service -- so that the drop-in applies to every instance of it.
reloads()
Whether systemctl reload on this service actually makes the daemon re-read its configuration.
True by default, which is right for most things. Say false when the daemon has no reload of its own: chronyd has none, so the packaged unit has no ExecReload, and adding one makes systemctl reload chrony start succeeding while the running daemon carries on with the configuration it started with. A command that reports success and does nothing is worse than one that fails.
Where this is false the drop-in installs ExecStartPre only, and a configuration change wants a restart.
services()
The units to actually restart once the drop-in is in place.
Usually the same ones, which is the default. They come apart when the drop-in belongs on a template: 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.
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 merge reads them:
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.value-- whatkeywas set to.
emit($directives)
The text of a config file holding those directives, ready to write.
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 myhostname disagree, and the later one wins. It is the wrong answer for the ones that are lists -- mydestination, 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.
repeats($key)
Whether a directive may appear more than once, each occurrence meaning something of its own.
False by default. Redis takes save 900 1 and save 300 10 and means both; chrony takes a 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; accumulates is checked first.
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.
METHODS
$class->new(%opts)
root relocates every path this language touches, so a test -- or a build for a guest that is not this machine -- works against a directory rather than the running system's /etc.
$language->name()
What this language is called on the command line: the last component of the package name.
$language->root()
The directory every path this language touches is relocated under, or the empty string for the running system.
$language->path($path)
$path under this language's root. Every path in this class goes through it, so that nothing writes outside the root it was given.
$language->fragment_dir($file)
The directory a file's fragments live in: the file's own path with .d on the end.
$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 conf.d entries do what they look like they do. Names starting with a dot are skipped, and so is anything ending in ~, .disabled, .bak, or one of the suffixes dpkg and rpm leave behind -- .dpkg-old, .dpkg-new, .dpkg-dist, .rpmsave, .rpmnew. Editors and package managers leave those lying about, and a stray backup silently taking part in the merge is a bad afternoon.
$language->merge(@fragment_sets)
One list of directives out of several, applying accumulates to decide which of two directives for the same key wins and which of them join up.
An accumulating directive with an 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 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 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 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 00-original keeps every one the file arrived with.
$language->build($file)
Read every fragment for one file, merge them, and return the text to write.
$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.
$language->write($file)
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.
$language->adopt()
Take over every file this language manages: make each one's fragment directory, move what is there now into it as 00-original, and generate the file.
Does nothing to a file it has already adopted, so running it twice is safe.
$language->release()
Give a file back: put 00-original where it came from and forget about it.
The counterpart to adopt, and the reason 00-original is kept rather than merged away. Removing the drop-in is Configd::Unit's half of it.
SEE ALSO
Please see those modules/websites for more information related to this module.
BUGS
Please report any bugs or feature requests on the bugtracker website https://github.com/teodesian/perl-configd/issues
When submitting a bug or request, please include a test-file or a patch to an existing test-file that illustrates the bug or desired feature.
AUTHORS
Current Maintainers:
George S. Baugh <george@troglodyne.net>
COPYRIGHT AND LICENSE
Copyright (c) 2026 Troglodyne LLC
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.