Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

nixos/printers: declarative configuration #55510

Merged
merged 1 commit into from
Sep 3, 2019
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions nixos/doc/manual/release-notes/rl-1909.xml
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,17 @@
<literal>./programs/dwm-status.nix</literal>
</para>
</listitem>
<listitem>
<para>
The new <varname>hardware.printers</varname> module allows to declaratively configure CUPS printers
via the <varname>ensurePrinters</varname> and
<varname>ensureDefaultPrinter</varname> options.
<varname>ensurePrinters</varname> will never delete existing printers,
but will make sure that the given printers are configured as declared.
</para>
</listitem>
</itemizedlist>

</section>

<section xmlns="http://docbook.org/ns/docbook"
Expand Down
135 changes: 135 additions & 0 deletions nixos/modules/hardware/printers.nix
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
{ config, lib, pkgs, ... }:
with lib;
let
cfg = config.hardware.printers;
ppdOptionsString = options: optionalString (options != {})
(concatStringsSep " "
(mapAttrsToList (name: value: "-o '${name}'='${value}'") options)
);
ensurePrinter = p: ''
${pkgs.cups}/bin/lpadmin -p '${p.name}' -E \
${optionalString (p.location != null) "-L '${p.location}'"} \
${optionalString (p.description != null) "-D '${p.description}'"} \
-v '${p.deviceUri}' \
-m '${p.model}' \
${ppdOptionsString p.ppdOptions}
'';
ensureDefaultPrinter = name: ''
${pkgs.cups}/bin/lpoptions -d '${name}'
'';

# "graph but not # or /" can't be implemented as regex alone due to missing lookahead support
noInvalidChars = str: all (c: c != "#" && c != "/") (stringToCharacters str);
printerName = (types.addCheck (types.strMatching "[[:graph:]]+") noInvalidChars)
// { description = "printable string without spaces, # and /"; };


in {
options = {
hardware.printers = {
ensureDefaultPrinter = mkOption {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we drop the "ensure" part? It seems redundant.

Copy link
Contributor Author

@florianjacob florianjacob Nov 18, 2019

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@edolstra You see a conceptual difference between ensurePrinters and ensureDefaultPrinter here, and therefore it's redundant for defaultPrinter but not for ensurePrinters, is that correct? I'm at your service. Maybe you could provide some general guidance on this, though?

I started to use those ensure prefixes to mark options that can only be enforced on a best effort basis on each service reload. In this concrete case, both ensurePrinters and ensureDefaultPrinter are translated into lpadmin command. I can't stop the user from modifying printers / change default printer, but the state will be restored. In case of ensurePrinters, I deliberately do not try to delete non-listed printers that the user might have added manually. Similar options in NixOS exist though which don't have that prefix.

There's a discussion going on about that, sparked off by similar mysql / postgresql options
I explained my point there as well.

EDIT: corrected link to discussion

type = types.nullOr printerName;
default = null;
description = ''
Ensures the named printer is the default CUPS printer / printer queue.
'';
};
ensurePrinters = mkOption {
description = ''
Will regularly ensure that the given CUPS printers are configured as declared here.
If a printer's options are manually changed afterwards, they will be overwritten eventually.
This option will never delete any printer, even if removed from this list.
You can check existing printers with <command>lpstat -s</command>
and remove printers with <command>lpadmin -x &lt;printer-name&gt;</command>.
Printers not listed here can still be manually configured.
'';
default = [];
type = types.listOf (types.submodule {
options = {
name = mkOption {
type = printerName;
example = "BrotherHL_Workroom";
description = ''
Name of the printer / printer queue.
May contain any printable characters except "/", "#", and space.
'';
};
location = mkOption {
type = types.nullOr types.str;
default = null;
example = "Workroom";
description = ''
Optional human-readable location.
'';
};
description = mkOption {
type = types.nullOr types.str;
default = null;
example = "Brother HL-5140";
description = ''
Optional human-readable description.
'';
};
deviceUri = mkOption {
type = types.str;
example = [
"ipp://printserver.local/printers/BrotherHL_Workroom"
"usb://HP/DESKJET%20940C?serial=CN16E6C364BH"
];
description = ''
How to reach the printer.
<command>lpinfo -v</command> shows a list of supported device URIs and schemes.
'';
};
model = mkOption {
type = types.str;
example = literalExample ''
gutenprint.''${lib.version.majorMinor (lib.getVersion pkgs.cups)}://brother-hl-5140/expert
'';
description = ''
Location of the ppd driver file for the printer.
<command>lpinfo -m</command> shows a list of supported models.
'';
};
ppdOptions = mkOption {
type = types.attrsOf types.str;
example = {
"PageSize" = "A4";
"Duplex" = "DuplexNoTumble";
};
default = {};
description = ''
Sets PPD options for the printer.
<command>lpoptions [-p printername] -l</command> shows suported PPD options for the given printer.
'';
};
};
});
};
};
};

config = mkIf (cfg.ensurePrinters != [] && config.services.printing.enable) {
systemd.services."ensure-printers" = let
cupsUnit = if config.services.printing.startWhenNeeded then "cups.socket" else "cups.service";
in {
description = "Ensure NixOS-configured CUPS printers";
wantedBy = [ "multi-user.target" ];
requires = [ cupsUnit ];
# in contrast to cups.socket, for cups.service, this is actually not enough,
# as the cups service reports its activation before clients can actually interact with it.
# Because of this, commands like `lpinfo -v` will report a bad file descriptor
# due to the missing UNIX socket without sufficient sleep time.
after = [ cupsUnit ];

serviceConfig = {
Type = "oneshot";
};

# sleep 10 is required to wait until cups.service is actually initialized and has created its UNIX socket file
script = (optionalString (!config.services.printing.startWhenNeeded) "sleep 10\n")
+ (concatMapStringsSep "\n" ensurePrinter cfg.ensurePrinters)
+ optionalString (cfg.ensureDefaultPrinter != null) (ensureDefaultPrinter cfg.ensureDefaultPrinter);
};
};
}
1 change: 1 addition & 0 deletions nixos/modules/module-list.nix
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@
./hardware/nitrokey.nix
./hardware/opengl.nix
./hardware/pcmcia.nix
./hardware/printers.nix
./hardware/raid/hpsa.nix
./hardware/steam-hardware.nix
./hardware/usb-wwan.nix
Expand Down
177 changes: 96 additions & 81 deletions nixos/tests/printing.nix
Original file line number Diff line number Diff line change
@@ -1,99 +1,114 @@
# Test printing via CUPS.

import ./make-test.nix ({pkgs, ... }: {
import ./make-test.nix ({pkgs, ... }:
let
printingServer = startWhenNeeded: {
services.printing.enable = true;
services.printing.startWhenNeeded = startWhenNeeded;
services.printing.listenAddresses = [ "*:631" ];
services.printing.defaultShared = true;
services.printing.extraConf =
''
<Location />
Order allow,deny
Allow from all
</Location>
'';
networking.firewall.allowedTCPPorts = [ 631 ];
# Add a HP Deskjet printer connected via USB to the server.
hardware.printers.ensurePrinters = [{
name = "DeskjetLocal";
deviceUri = "usb://foobar/printers/foobar";
model = "drv:///sample.drv/deskjet.ppd";
}];
};
printingClient = startWhenNeeded: {
services.printing.enable = true;
services.printing.startWhenNeeded = startWhenNeeded;
# Add printer to the client as well, via IPP.
hardware.printers.ensurePrinters = [{
name = "DeskjetRemote";
deviceUri = "ipp://${if startWhenNeeded then "socketActivatedServer" else "serviceServer"}/printers/DeskjetLocal";
model = "drv:///sample.drv/deskjet.ppd";
}];
hardware.printers.ensureDefaultPrinter = "DeskjetRemote";
};

in

{
name = "printing";
meta = with pkgs.stdenv.lib.maintainers; {
maintainers = [ domenkozar eelco matthewbauer ];
};

nodes = {
socketActivatedServer = { ... }: (printingServer true);
serviceServer = { ... }: (printingServer false);

server =
{ ... }:
{ services.printing.enable = true;
services.printing.listenAddresses = [ "*:631" ];
services.printing.defaultShared = true;
services.printing.extraConf =
''
<Location />
Order allow,deny
Allow from all
</Location>
'';
networking.firewall.allowedTCPPorts = [ 631 ];
};

client =
{ ... }:
{ services.printing.enable = true;
};

socketActivatedClient = { ... }: (printingClient true);
serviceClient = { ... }: (printingClient false);
};

testScript =
''
startAll;

$client->succeed("lpstat -r") =~ /scheduler is running/ or die;
# check local encrypted connections work without error
$client->succeed("lpstat -E -r") =~ /scheduler is running/ or die;
# Test that UNIX socket is used for connections.
$client->succeed("lpstat -H") =~ "/run/cups/cups.sock" or die;
# Test that HTTP server is available too.
$client->succeed("curl --fail http://localhost:631/");
$client->succeed("curl --fail http://server:631/");
$server->fail("curl --fail --connect-timeout 2 http://client:631/");

# Add a HP Deskjet printer connected via USB to the server.
$server->succeed("lpadmin -p DeskjetLocal -E -v usb://foobar/printers/foobar");

# Add it to the client as well via IPP.
$client->succeed("lpadmin -p DeskjetRemote -E -v ipp://server/printers/DeskjetLocal");
$client->succeed("lpadmin -d DeskjetRemote");

# Do some status checks.
$client->succeed("lpstat -a") =~ /DeskjetRemote accepting requests/ or die;
$client->succeed("lpstat -h server:631 -a") =~ /DeskjetLocal accepting requests/ or die;
$client->succeed("cupsdisable DeskjetRemote");
$client->succeed("lpq") =~ /DeskjetRemote is not ready.*no entries/s or die;
$client->succeed("cupsenable DeskjetRemote");
$client->succeed("lpq") =~ /DeskjetRemote is ready.*no entries/s or die;

# Test printing various file types.
foreach my $file ("${pkgs.groff.doc}/share/doc/*/examples/mom/penguin.pdf",
"${pkgs.groff.doc}/share/doc/*/meref.ps",
"${pkgs.cups.out}/share/doc/cups/images/cups.png",
"${pkgs.pcre.doc}/share/doc/pcre/pcre.txt")
{
$file =~ /([^\/]*)$/; my $fn = $1;

subtest "print $fn", sub {

# Print the file on the client.
$client->succeed("lp $file");
$client->sleep(10);
$client->succeed("lpq") =~ /active.*root.*$fn/ or die;

# Ensure that a raw PCL file appeared in the server's queue
# (showing that the right filters have been applied). Of
# course, since there is no actual USB printer attached, the
# file will stay in the queue forever.
$server->waitForFile("/var/spool/cups/d*-001");
$server->sleep(10);
$server->succeed("lpq -a") =~ /$fn/ or die;

# Delete the job on the client. It should disappear on the
# server as well.
$client->succeed("lprm");
$client->sleep(10);
$client->succeed("lpq -a") =~ /no entries/;
Machine::retry sub {
return 1 if $server->succeed("lpq -a") =~ /no entries/;
# Make sure that cups is up on both sides.
$serviceServer->waitForUnit("cups.service");
$serviceClient->waitForUnit("cups.service");
# wait until cups is fully initialized and ensure-printers has executed with 10s delay
$serviceClient->sleep(20);
$socketActivatedClient->waitUntilSucceeds("systemctl status ensure-printers | grep -q -E 'code=exited, status=0/SUCCESS'");
sub testPrinting {
my ($client, $server) = (@_);
my $clientHostname = $client->name();
my $serverHostname = $server->name();
$client->succeed("lpstat -r") =~ /scheduler is running/ or die;
# Test that UNIX socket is used for connections.
$client->succeed("lpstat -H") =~ "/var/run/cups/cups.sock" or die;
# Test that HTTP server is available too.
$client->succeed("curl --fail http://localhost:631/");
$client->succeed("curl --fail http://$serverHostname:631/");
$server->fail("curl --fail --connect-timeout 2 http://$clientHostname:631/");
# Do some status checks.
$client->succeed("lpstat -a") =~ /DeskjetRemote accepting requests/ or die;
$client->succeed("lpstat -h $serverHostname:631 -a") =~ /DeskjetLocal accepting requests/ or die;
$client->succeed("cupsdisable DeskjetRemote");
$client->succeed("lpq") =~ /DeskjetRemote is not ready.*no entries/s or die;
$client->succeed("cupsenable DeskjetRemote");
$client->succeed("lpq") =~ /DeskjetRemote is ready.*no entries/s or die;
# Test printing various file types.
foreach my $file ("${pkgs.groff.doc}/share/doc/*/examples/mom/penguin.pdf",
"${pkgs.groff.doc}/share/doc/*/meref.ps",
"${pkgs.cups.out}/share/doc/cups/images/cups.png",
"${pkgs.pcre.doc}/share/doc/pcre/pcre.txt")
{
$file =~ /([^\/]*)$/; my $fn = $1;
subtest "print $fn", sub {
# Print the file on the client.
$client->succeed("lp $file");
$client->waitUntilSucceeds("lpq | grep -q -E 'active.*root.*$fn'");
# Ensure that a raw PCL file appeared in the server's queue
# (showing that the right filters have been applied). Of
# course, since there is no actual USB printer attached, the
# file will stay in the queue forever.
$server->waitForFile("/var/spool/cups/d*-001");
$server->waitUntilSucceeds("lpq -a | grep -q -E '$fn'");
# Delete the job on the client. It should disappear on the
# server as well.
$client->succeed("lprm");
$client->waitUntilSucceeds("lpq -a | grep -q -E 'no entries'");
Machine::retry sub {
return 1 if $server->succeed("lpq -a") =~ /no entries/;
};
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also here:

$server->waitUntilSucceeds("lpq -a | grep -q -E 'no entries'");

# The queue is empty already, so this should be safe.
# Otherwise, pairs of "c*"-"d*-001" files might persist.
$server->execute("rm /var/spool/cups/*");
};
# The queue is empty already, so this should be safe.
# Otherwise, pairs of "c*"-"d*-001" files might persist.
$server->execute("rm /var/spool/cups/*");
};
}
}
'';
testPrinting($serviceClient, $serviceServer);
testPrinting($socketActivatedClient, $socketActivatedServer);
'';
})