Merge pull request #253428 from Yarny0/tsm-freeform
nixos/tsm-client: migrate to freeform settings (RFC42)
This commit is contained in:
commit
d1fc3a5f9f
3 changed files with 166 additions and 150 deletions
|
@ -1,193 +1,144 @@
|
|||
{ config, lib, pkgs, ... }:
|
||||
{ config, lib, options, pkgs, ... }: # XXX migration code for freeform settings: `options` can be removed in 2025
|
||||
let optionsGlobal = options; in
|
||||
|
||||
let
|
||||
|
||||
inherit (builtins) length map;
|
||||
inherit (lib.attrsets) attrNames filterAttrs hasAttr mapAttrs mapAttrsToList optionalAttrs;
|
||||
inherit (lib.attrsets) attrNames attrValues mapAttrsToList removeAttrs;
|
||||
inherit (lib.lists) all allUnique concatLists elem isList map;
|
||||
inherit (lib.modules) mkDefault mkIf;
|
||||
inherit (lib.options) literalExpression mkEnableOption mkOption mkPackageOption;
|
||||
inherit (lib.strings) concatLines optionalString toLower;
|
||||
inherit (lib.types) addCheck attrsOf lines nonEmptyStr nullOr package path port str strMatching submodule;
|
||||
inherit (lib.options) mkEnableOption mkOption mkPackageOption;
|
||||
inherit (lib.strings) concatLines match optionalString toLower;
|
||||
inherit (lib.trivial) isInt;
|
||||
inherit (lib.types) addCheck attrsOf coercedTo either enum int lines listOf nonEmptyStr nullOr oneOf path port singleLineStr strMatching submodule;
|
||||
|
||||
# Checks if given list of strings contains unique
|
||||
# elements when compared without considering case.
|
||||
# Type: checkIUnique :: [string] -> bool
|
||||
# Example: checkIUnique ["foo" "Foo"] => false
|
||||
checkIUnique = lst:
|
||||
let
|
||||
lenUniq = l: length (lib.lists.unique l);
|
||||
in
|
||||
lenUniq lst == lenUniq (map toLower lst);
|
||||
scalarType =
|
||||
# see the option's description below for the
|
||||
# handling/transformation of each possible type
|
||||
oneOf [ (enum [ true null ]) int path singleLineStr ];
|
||||
|
||||
# TSM rejects servername strings longer than 64 chars.
|
||||
servernameType = strMatching ".{1,64}";
|
||||
servernameType = strMatching "[^[:space:]]{1,64}";
|
||||
|
||||
serverOptions = { name, config, ... }: {
|
||||
options.name = mkOption {
|
||||
freeformType = attrsOf (either scalarType (listOf scalarType));
|
||||
# Client system-options file directives are explained here:
|
||||
# https://www.ibm.com/docs/en/storage-protect/8.1.20?topic=commands-processing-options
|
||||
options.servername = mkOption {
|
||||
type = servernameType;
|
||||
default = name;
|
||||
example = "mainTsmServer";
|
||||
description = lib.mdDoc ''
|
||||
Local name of the IBM TSM server,
|
||||
must be uncapitalized and no longer than 64 chars.
|
||||
The value will be used for the
|
||||
`server`
|
||||
directive in {file}`dsm.sys`.
|
||||
must not contain space or more than 64 chars.
|
||||
'';
|
||||
};
|
||||
options.server = mkOption {
|
||||
options.tcpserveraddress = mkOption {
|
||||
type = nonEmptyStr;
|
||||
example = "tsmserver.company.com";
|
||||
description = lib.mdDoc ''
|
||||
Host/domain name or IP address of the IBM TSM server.
|
||||
The value will be used for the
|
||||
`tcpserveraddress`
|
||||
directive in {file}`dsm.sys`.
|
||||
'';
|
||||
};
|
||||
options.port = mkOption {
|
||||
options.tcpport = mkOption {
|
||||
type = addCheck port (p: p<=32767);
|
||||
default = 1500; # official default
|
||||
description = lib.mdDoc ''
|
||||
TCP port of the IBM TSM server.
|
||||
The value will be used for the
|
||||
`tcpport`
|
||||
directive in {file}`dsm.sys`.
|
||||
TSM does not support ports above 32767.
|
||||
'';
|
||||
};
|
||||
options.node = mkOption {
|
||||
options.nodename = mkOption {
|
||||
type = nonEmptyStr;
|
||||
example = "MY-TSM-NODE";
|
||||
description = lib.mdDoc ''
|
||||
Target node name on the IBM TSM server.
|
||||
The value will be used for the
|
||||
`nodename`
|
||||
directive in {file}`dsm.sys`.
|
||||
'';
|
||||
};
|
||||
options.genPasswd = mkEnableOption (lib.mdDoc ''
|
||||
automatic client password generation.
|
||||
This option influences the
|
||||
`passwordaccess`
|
||||
directive in {file}`dsm.sys`.
|
||||
This option does *not* cause a line in
|
||||
{file}`dsm.sys` by itself, but generates a
|
||||
corresponding `passwordaccess` directive.
|
||||
The password will be stored in the directory
|
||||
given by the option {option}`passwdDir`.
|
||||
given by the option {option}`passworddir`.
|
||||
*Caution*:
|
||||
If this option is enabled and the server forces
|
||||
to renew the password (e.g. on first connection),
|
||||
a random password will be generated and stored
|
||||
'');
|
||||
options.passwdDir = mkOption {
|
||||
type = path;
|
||||
options.passwordaccess = mkOption {
|
||||
type = enum [ "generate" "prompt" ];
|
||||
visible = false;
|
||||
};
|
||||
options.passworddir = mkOption {
|
||||
type = nullOr path;
|
||||
default = null;
|
||||
example = "/home/alice/tsm-password";
|
||||
description = lib.mdDoc ''
|
||||
Directory that holds the TSM
|
||||
node's password information.
|
||||
The value will be used for the
|
||||
`passworddir`
|
||||
directive in {file}`dsm.sys`.
|
||||
'';
|
||||
};
|
||||
options.includeExclude = mkOption {
|
||||
type = lines;
|
||||
default = "";
|
||||
options.inclexcl = mkOption {
|
||||
type = coercedTo lines
|
||||
(pkgs.writeText "inclexcl.dsm.sys")
|
||||
(nullOr path);
|
||||
default = null;
|
||||
example = ''
|
||||
exclude.dir /nix/store
|
||||
include.encrypt /home/.../*
|
||||
'';
|
||||
description = lib.mdDoc ''
|
||||
`include.*` and
|
||||
`exclude.*` directives to be
|
||||
used when sending files to the IBM TSM server.
|
||||
The lines will be written into a file that the
|
||||
`inclexcl`
|
||||
directive in {file}`dsm.sys` points to.
|
||||
Text lines with `include.*` and `exclude.*` directives
|
||||
to be used when sending files to the IBM TSM server,
|
||||
or an absolute path pointing to a file with such lines.
|
||||
'';
|
||||
};
|
||||
options.extraConfig = mkOption {
|
||||
# TSM option keys are case insensitive;
|
||||
# we have to ensure there are no keys that
|
||||
# differ only by upper and lower case.
|
||||
type = addCheck
|
||||
(attrsOf (nullOr str))
|
||||
(attrs: checkIUnique (attrNames attrs));
|
||||
default = {};
|
||||
example.compression = "yes";
|
||||
example.passwordaccess = null;
|
||||
description = lib.mdDoc ''
|
||||
Additional key-value pairs for the server stanza.
|
||||
Values must be strings, or `null`
|
||||
for the key not to be used in the stanza
|
||||
(e.g. to overrule values generated by other options).
|
||||
'';
|
||||
};
|
||||
options.text = mkOption {
|
||||
type = lines;
|
||||
example = literalExpression
|
||||
''lib.modules.mkAfter "compression no"'';
|
||||
description = lib.mdDoc ''
|
||||
Additional text lines for the server stanza.
|
||||
This option can be used if certion configuration keys
|
||||
must be used multiple times or ordered in a certain way
|
||||
as the {option}`extraConfig` option can't
|
||||
control the order of lines in the resulting stanza.
|
||||
Note that the `server`
|
||||
line at the beginning of the stanza is
|
||||
not part of this option's value.
|
||||
'';
|
||||
};
|
||||
options.stanza = mkOption {
|
||||
type = str;
|
||||
internal = true;
|
||||
visible = false;
|
||||
description = lib.mdDoc "Server stanza text generated from the options.";
|
||||
};
|
||||
config.name = mkDefault name;
|
||||
# Client system-options file directives are explained here:
|
||||
# https://www.ibm.com/docs/en/spectrum-protect/8.1.13?topic=commands-processing-options
|
||||
config.extraConfig =
|
||||
mapAttrs (lib.trivial.const mkDefault) (
|
||||
{
|
||||
commmethod = "v6tcpip"; # uses v4 or v6, based on dns lookup result
|
||||
tcpserveraddress = config.server;
|
||||
tcpport = builtins.toString config.port;
|
||||
nodename = config.node;
|
||||
passwordaccess = if config.genPasswd then "generate" else "prompt";
|
||||
passworddir = ''"${config.passwdDir}"'';
|
||||
} // optionalAttrs (config.includeExclude!="") {
|
||||
inclexcl = ''"${pkgs.writeText "inclexcl.dsm.sys" config.includeExclude}"'';
|
||||
}
|
||||
);
|
||||
config.text =
|
||||
let
|
||||
attrset = filterAttrs (k: v: v!=null) config.extraConfig;
|
||||
mkLine = k: v: k + optionalString (v!="") " ${v}";
|
||||
lines = mapAttrsToList mkLine attrset;
|
||||
in
|
||||
concatLines lines;
|
||||
config.stanza = ''
|
||||
server ${config.name}
|
||||
${config.text}
|
||||
'';
|
||||
config.commmethod = mkDefault "v6tcpip"; # uses v4 or v6, based on dns lookup result
|
||||
config.passwordaccess = if config.genPasswd then "generate" else "prompt";
|
||||
# XXX migration code for freeform settings, these can be removed in 2025:
|
||||
options.warnings = optionsGlobal.warnings;
|
||||
options.assertions = optionsGlobal.assertions;
|
||||
imports = let inherit (lib.modules) mkRemovedOptionModule mkRenamedOptionModule; in [
|
||||
(mkRemovedOptionModule [ "extraConfig" ] "Please just add options directly to the server attribute set, cf. the description of `programs.tsmClient.servers`.")
|
||||
(mkRemovedOptionModule [ "text" ] "Please just add options directly to the server attribute set, cf. the description of `programs.tsmClient.servers`.")
|
||||
(mkRenamedOptionModule [ "name" ] [ "servername" ])
|
||||
(mkRenamedOptionModule [ "server" ] [ "tcpserveraddress" ])
|
||||
(mkRenamedOptionModule [ "port" ] [ "tcpport" ])
|
||||
(mkRenamedOptionModule [ "node" ] [ "nodename" ])
|
||||
(mkRenamedOptionModule [ "passwdDir" ] [ "passworddir" ])
|
||||
(mkRenamedOptionModule [ "includeExclude" ] [ "inclexcl" ])
|
||||
];
|
||||
};
|
||||
|
||||
options.programs.tsmClient = {
|
||||
enable = mkEnableOption (lib.mdDoc ''
|
||||
IBM Spectrum Protect (Tivoli Storage Manager, TSM)
|
||||
IBM Storage Protect (Tivoli Storage Manager, TSM)
|
||||
client command line applications with a
|
||||
client system-options file "dsm.sys"
|
||||
'');
|
||||
servers = mkOption {
|
||||
type = attrsOf (submodule [ serverOptions ]);
|
||||
type = attrsOf (submodule serverOptions);
|
||||
default = {};
|
||||
example.mainTsmServer = {
|
||||
server = "tsmserver.company.com";
|
||||
node = "MY-TSM-NODE";
|
||||
extraConfig.compression = "yes";
|
||||
tcpserveraddress = "tsmserver.company.com";
|
||||
nodename = "MY-TSM-NODE";
|
||||
compression = "yes";
|
||||
};
|
||||
description = lib.mdDoc ''
|
||||
Server definitions ("stanzas")
|
||||
for the client system-options file.
|
||||
The name of each entry will be used for
|
||||
the internal `servername` by default.
|
||||
Each attribute will be transformed into a line
|
||||
with a key-value pair within the server's stanza.
|
||||
Integers as values will be
|
||||
canonically turned into strings.
|
||||
The boolean value `true` will be turned
|
||||
into a line with just the attribute's name.
|
||||
The value `null` will not generate a line.
|
||||
A list as values generates an entry for
|
||||
each value, according to the rules above.
|
||||
'';
|
||||
};
|
||||
defaultServername = mkOption {
|
||||
|
@ -222,45 +173,107 @@ let
|
|||
to add paths to the client system-options file.
|
||||
'';
|
||||
};
|
||||
wrappedPackage = mkOption {
|
||||
type = package;
|
||||
readOnly = true;
|
||||
description = lib.mdDoc ''
|
||||
The TSM client derivation, wrapped with the path
|
||||
to the client system-options file "dsm.sys".
|
||||
This option is to provide the effective derivation
|
||||
wrappedPackage = mkPackageOption pkgs "tsm-client" {
|
||||
default = null;
|
||||
extraDescription = ''
|
||||
This option is to provide the effective derivation,
|
||||
wrapped with the path to the
|
||||
client system-options file "dsm.sys".
|
||||
It should not be changed, but exists
|
||||
for other modules that want to call TSM executables.
|
||||
'';
|
||||
};
|
||||
} // { readOnly = true; };
|
||||
};
|
||||
|
||||
cfg = config.programs.tsmClient;
|
||||
servernames = map (s: s.servername) (attrValues cfg.servers);
|
||||
|
||||
assertions = [
|
||||
{
|
||||
assertion = checkIUnique (mapAttrsToList (k: v: v.name) cfg.servers);
|
||||
assertions =
|
||||
[
|
||||
{
|
||||
assertion = allUnique (map toLower servernames);
|
||||
message = ''
|
||||
TSM server names
|
||||
(option `programs.tsmClient.servers`)
|
||||
contain duplicate name
|
||||
(note that server names are case insensitive).
|
||||
'';
|
||||
}
|
||||
{
|
||||
assertion = (cfg.defaultServername!=null)->(elem cfg.defaultServername servernames);
|
||||
message = ''
|
||||
TSM default server name
|
||||
`programs.tsmClient.defaultServername="${cfg.defaultServername}"`
|
||||
not found in server names in
|
||||
`programs.tsmClient.servers`.
|
||||
'';
|
||||
}
|
||||
] ++ (mapAttrsToList (name: serverCfg: {
|
||||
assertion = all (key: null != match "[^[:space:]]+" key) (attrNames serverCfg);
|
||||
message = ''
|
||||
TSM servernames contain duplicate name
|
||||
(note that case doesn't matter!)
|
||||
TSM server setting names in
|
||||
`programs.tsmClient.servers.${name}.*`
|
||||
contain spaces, but that's not allowed.
|
||||
'';
|
||||
}) cfg.servers) ++ (mapAttrsToList (name: serverCfg: {
|
||||
assertion = allUnique (map toLower (attrNames serverCfg));
|
||||
message = ''
|
||||
TSM server setting names in
|
||||
`programs.tsmClient.servers.${name}.*`
|
||||
contain duplicate names
|
||||
(note that setting names are case insensitive).
|
||||
'';
|
||||
}) cfg.servers)
|
||||
# XXX migration code for freeform settings, this can be removed in 2025:
|
||||
++ (enrichMigrationInfos "assertions" (addText: { assertion, message }: { inherit assertion; message = addText message; }));
|
||||
|
||||
makeDsmSysLines = key: value:
|
||||
# Turn a key-value pair from the server options attrset
|
||||
# into zero (value==null), one (scalar value) or
|
||||
# more (value is list) configuration stanza lines.
|
||||
if isList value then map (makeDsmSysLines key) value else # recurse into list
|
||||
if value == null then [ ] else # skip `null` value
|
||||
[ (" ${key}${
|
||||
if value == true then "" else # just output key if value is `true`
|
||||
if isInt value then " ${builtins.toString value}" else
|
||||
if path.check value then " \"${value}\"" else # enclose path in ".."
|
||||
if singleLineStr.check value then " ${value}" else
|
||||
throw "assertion failed: cannot convert type" # should never happen
|
||||
}") ];
|
||||
|
||||
makeDsmSysStanza = {servername, ... }@serverCfg:
|
||||
let
|
||||
# drop special values that should not go into server config block
|
||||
attrs = removeAttrs serverCfg [ "servername" "genPasswd"
|
||||
# XXX migration code for freeform settings, these can be removed in 2025:
|
||||
"assertions" "warnings"
|
||||
"extraConfig" "text"
|
||||
"name" "server" "port" "node" "passwdDir" "includeExclude"
|
||||
];
|
||||
in
|
||||
''
|
||||
servername ${servername}
|
||||
${concatLines (concatLists (mapAttrsToList makeDsmSysLines attrs))}
|
||||
'';
|
||||
}
|
||||
{
|
||||
assertion = (cfg.defaultServername!=null)->(hasAttr cfg.defaultServername cfg.servers);
|
||||
message = "TSM defaultServername not found in list of servers";
|
||||
}
|
||||
];
|
||||
|
||||
dsmSysText = ''
|
||||
**** IBM Spectrum Protect (Tivoli Storage Manager)
|
||||
**** IBM Storage Protect (Tivoli Storage Manager)
|
||||
**** client system-options file "dsm.sys".
|
||||
**** Do not edit!
|
||||
**** This file is generated by NixOS configuration.
|
||||
|
||||
${optionalString (cfg.defaultServername!=null) "defaultserver ${cfg.defaultServername}"}
|
||||
|
||||
${concatLines (mapAttrsToList (k: v: v.stanza) cfg.servers)}
|
||||
${concatLines (map makeDsmSysStanza (attrValues cfg.servers))}
|
||||
'';
|
||||
|
||||
# XXX migration code for freeform settings, this can be removed in 2025:
|
||||
enrichMigrationInfos = what: how: concatLists (
|
||||
mapAttrsToList
|
||||
(name: serverCfg: map (how (text: "In `programs.tsmClient.servers.${name}`: ${text}")) serverCfg."${what}")
|
||||
cfg.servers
|
||||
);
|
||||
|
||||
in
|
||||
|
||||
{
|
||||
|
@ -275,6 +288,8 @@ in
|
|||
dsmSysApi = dsmSysCli;
|
||||
};
|
||||
environment.systemPackages = [ cfg.wrappedPackage ];
|
||||
# XXX migration code for freeform settings, this can be removed in 2025:
|
||||
warnings = enrichMigrationInfos "warnings" (addText: addText);
|
||||
};
|
||||
|
||||
meta.maintainers = [ lib.maintainers.yarny ];
|
||||
|
|
|
@ -3,6 +3,7 @@
|
|||
let
|
||||
|
||||
inherit (lib.attrsets) hasAttr;
|
||||
inherit (lib.meta) getExe';
|
||||
inherit (lib.modules) mkDefault mkIf;
|
||||
inherit (lib.options) mkEnableOption mkOption;
|
||||
inherit (lib.types) nonEmptyStr nullOr;
|
||||
|
@ -10,7 +11,7 @@ let
|
|||
options.services.tsmBackup = {
|
||||
enable = mkEnableOption (lib.mdDoc ''
|
||||
automatic backups with the
|
||||
IBM Spectrum Protect (Tivoli Storage Manager, TSM) client.
|
||||
IBM Storage Protect (Tivoli Storage Manager, TSM) client.
|
||||
This also enables
|
||||
{option}`programs.tsmClient.enable`
|
||||
'');
|
||||
|
@ -78,10 +79,10 @@ in
|
|||
config = mkIf cfg.enable {
|
||||
inherit assertions;
|
||||
programs.tsmClient.enable = true;
|
||||
programs.tsmClient.servers.${cfg.servername}.passwdDir =
|
||||
programs.tsmClient.servers.${cfg.servername}.passworddir =
|
||||
mkDefault "/var/lib/tsm-backup/password";
|
||||
systemd.services.tsm-backup = {
|
||||
description = "IBM Spectrum Protect (Tivoli Storage Manager) Backup";
|
||||
description = "IBM Storage Protect (Tivoli Storage Manager) Backup";
|
||||
# DSM_LOG needs a trailing slash to have it treated as a directory.
|
||||
# `/var/log` would be littered with TSM log files otherwise.
|
||||
environment.DSM_LOG = "/var/log/tsm-backup/";
|
||||
|
@ -89,12 +90,12 @@ in
|
|||
environment.HOME = "/var/lib/tsm-backup";
|
||||
serviceConfig = {
|
||||
# for exit status description see
|
||||
# https://www.ibm.com/docs/en/spectrum-protect/8.1.13?topic=clients-client-return-codes
|
||||
# https://www.ibm.com/docs/en/storage-protect/8.1.20?topic=clients-client-return-codes
|
||||
SuccessExitStatus = "4 8";
|
||||
# The `-se` option must come after the command.
|
||||
# The `-optfile` option suppresses a `dsm.opt`-not-found warning.
|
||||
ExecStart =
|
||||
"${cfgPrg.wrappedPackage}/bin/dsmc ${cfg.command} -se='${cfg.servername}' -optfile=/dev/null";
|
||||
"${getExe' cfgPrg.wrappedPackage "dsmc"} ${cfg.command} -se='${cfg.servername}' -optfile=/dev/null";
|
||||
LogsDirectory = "tsm-backup";
|
||||
StateDirectory = "tsm-backup";
|
||||
StateDirectoryMode = "0750";
|
||||
|
|
|
@ -18,9 +18,9 @@ import ./make-test-python.nix ({ lib, pkgs, ... }: {
|
|||
defaultServername = "testserver";
|
||||
servers.testserver = {
|
||||
# 192.0.0.8 is a "dummy address" according to RFC 7600
|
||||
server = "192.0.0.8";
|
||||
node = "SOME-NODE";
|
||||
passwdDir = "/tmp";
|
||||
tcpserveraddress = "192.0.0.8";
|
||||
nodename = "SOME-NODE";
|
||||
passworddir = "/tmp";
|
||||
};
|
||||
};
|
||||
};
|
||||
|
|
Loading…
Reference in a new issue