nixos/rundeck: init (#375846)

This commit is contained in:
Pol Dellaiera
2026-08-24 17:25:33 +00:00
committed by GitHub
5 changed files with 766 additions and 0 deletions

View File

@@ -108,6 +108,8 @@
- [Umbriel](https://docs.noctalia.dev/umbriel/), a Wayland compositor built on wlroots and SceneFX. Available as [programs.umbriel](#opt-programs.umbriel.enable).
- [Rundeck](https://www.rundeck.com), Self-Service Operations [services.rundeck](#opt-services.rundeck.enable).
## Backward Incompatibilities {#sec-release-26.11-incompatibilities}
<!-- To avoid merge conflicts, consider adding your item at an arbitrary place in the list instead. -->

View File

@@ -1824,6 +1824,7 @@
./services/web-apps/romm.nix
./services/web-apps/rss-bridge.nix
./services/web-apps/rsshub.nix
./services/web-apps/rundeck.nix
./services/web-apps/rustical.nix
./services/web-apps/rutorrent.nix
./services/web-apps/screego.nix

View File

@@ -0,0 +1,651 @@
{
config,
lib,
pkgs,
...
}:
let
cfg = config.services.rundeck;
settingsFormat = pkgs.formats.javaProperties { };
effectivePort = if cfg.ssl.enable then cfg.ssl.port else cfg.serverPort;
scheme = if cfg.ssl.enable then "https" else "http";
configFile = settingsFormat.generate "rundeck-config.properties" cfg.settings;
frameworkFile = settingsFormat.generate "framework.properties" cfg.frameworkSettings;
realmFile = pkgs.writeText "realm.properties" ''
${cfg.adminUser}:@ADMIN_PASSWORD@,user,admin
'';
replaceSecret =
placeholder: file: target:
"replace-secret ${
lib.escapeShellArgs [
placeholder
file
target
]
}";
rundeckStartScript = pkgs.writeShellScript "start-rundeck" ''
# Generate SSH
if [ ! -f ${cfg.dataDir}/.ssh/id_${cfg.sshKeyType} ]; then
umask 0077
${lib.getExe' pkgs.openssh "ssh-keygen"} -t ${cfg.sshKeyType} ${
lib.optionalString (cfg.sshKeyType == "rsa") "-b 4096"
} -N "" -f ${cfg.dataDir}/.ssh/id_${cfg.sshKeyType}
chmod 644 ${cfg.dataDir}/.ssh/id_${cfg.sshKeyType}.pub
chown ${cfg.user}:${cfg.group} ${cfg.dataDir}/.ssh/id_${cfg.sshKeyType} ${cfg.dataDir}/.ssh/id_${cfg.sshKeyType}.pub
fi
${lib.getExe cfg.package} \
--skipinstall \
-b ${cfg.dataDir} \
-c ${cfg.configDir} \
-p ${cfg.dataDir}/projects
'';
in
{
options = {
services.rundeck = {
enable = lib.mkEnableOption "Rundeck service";
package = lib.mkPackageOption pkgs "rundeck" { };
adminUser = lib.mkOption {
type = lib.types.str;
default = "admin";
description = "Username for the Rundeck admin user";
example = "rundeck-admin";
};
adminPasswordFile = lib.mkOption {
type = lib.types.path;
description = "Path to a file containing the admin password";
example = "/run/secrets/rundeck-admin-password";
};
user = lib.mkOption {
type = lib.types.str;
default = "rundeck";
description = "User account under which Rundeck runs";
};
group = lib.mkOption {
type = lib.types.str;
default = "rundeck";
description = "Group account under which Rundeck runs";
};
serverHostname = lib.mkOption {
type = lib.types.str;
default = "localhost";
description = "Hostname for the Rundeck server";
};
serverURL = lib.mkOption {
type = lib.types.str;
default = "${scheme}://${cfg.serverHostname}:${toString effectivePort}";
defaultText = lib.literalMD ''
`<scheme>://<serverHostname>:<port>`, where scheme is `https` when
`ssl.enable` else `http`, and port is `ssl.port` when `ssl.enable`
else `serverPort`.
'';
description = "Complete Grails server URL";
example = "https://myhost:4443/rundeck";
};
serverPort = lib.mkOption {
type = lib.types.port;
default = 4440;
description = "Port on which Rundeck will listen";
};
serverUUID = lib.mkOption {
type = lib.types.str;
default = "";
description = "UUID for the Rundeck server (automatically generated if not specified)";
};
dataDir = lib.mkOption {
type = lib.types.path;
default = "/var/lib/rundeck";
description = "Directory for Rundeck runtime data (RDECK_BASE)";
};
configDir = lib.mkOption {
type = lib.types.path;
default = "/etc/rundeck";
description = "Directory for Rundeck configuration files";
};
javaOpts = lib.mkOption {
type = lib.types.listOf lib.types.str;
default = [
"-Xmx1024m"
"-Xms256m"
"-XX:MaxMetaspaceSize=256m"
"-server"
];
description = "Additional Java options for Rundeck";
};
aclPolicies = lib.mkOption {
type = lib.types.attrsOf lib.types.str;
default = { };
description = "ACL policies for Rundeck, where the attribute name is the filename and the value is the policy content";
example = lib.literalExpression ''
{
"admin.aclpolicy" = '''
description: Admin access for administrators
context:
project: '.*'
for:
resource:
- allow: '*'
job:
- allow: '*'
node:
- allow: '*'
by:
group: admin
---
description: Admin access in application scope
context:
application: 'rundeck'
for:
resource:
- allow: '*'
project:
- allow: '*'
by:
group: admin
''';
}
'';
};
sshKeyType = lib.mkOption {
type = lib.types.enum [
"rsa"
"ed25519"
];
default = "rsa";
description = "Type of SSH key to generate (rsa for compatibility, ed25519 for better security)";
};
openFirewall = lib.mkOption {
type = lib.types.bool;
default = false;
description = "Whether to open the Rundeck port in the firewall";
};
startTimeout = lib.mkOption {
type = lib.types.int;
default = 180;
description = "Timeout in seconds before systemd considers the service startup as failed";
example = 120;
};
database = {
type = lib.mkOption {
type = lib.types.enum [
"h2"
"postgresql"
"mysql"
];
default = "h2";
description = "Database type to use (h2, postgresql, or mysql)";
};
host = lib.mkOption {
type = lib.types.str;
default = "localhost";
description = "Database host";
};
port = lib.mkOption {
type = lib.types.nullOr lib.types.port;
default =
if cfg.database.type == "postgresql" then
5432
else if cfg.database.type == "mysql" then
3306
else
null;
defaultText = lib.literalExpression ''
if config.services.rundeck.database.type == "postgresql" then
5432
else if config.services.rundeck.database.type == "mysql" then
3306
else
null
'';
description = "Database port (defaults: PostgreSQL: 5432, MySQL: 3306)";
};
name = lib.mkOption {
type = lib.types.str;
default = "rundeck";
description = "Database name";
};
username = lib.mkOption {
type = lib.types.str;
default = "rundeck";
description = "Database username";
};
passwordFile = lib.mkOption {
type = lib.types.nullOr lib.types.path;
default = null;
description = "Path to a file containing the database password";
example = "/run/secrets/rundeck-db-password";
};
};
settings = lib.mkOption {
type = lib.types.submodule {
freeformType = settingsFormat.type;
};
default = { };
description = ''
Configuration written to `rundeck-config.properties`.
See <https://docs.rundeck.com/docs/administration/configuration/config-file-reference.html>
for available options.
Secrets must not be set here, as this ends up world-readable in the Nix
store. Use the dedicated `*File` options instead.
'';
example = lib.literalExpression ''
{
"rundeck.feature.repository.enabled" = "true";
"rundeck.projectsStorageType" = "db";
"rundeck.gui.title" = "My Rundeck Instance";
"rdeck.security.useHMacRequestTokens" = "true";
"rundeck.web.jetty.servlet.MaxFormKeys" = "2000";
}
'';
};
frameworkSettings = lib.mkOption {
type = lib.types.submodule {
freeformType = settingsFormat.type;
};
default = { };
description = ''
Configuration written to `framework.properties`.
See <https://docs.rundeck.com/docs/administration/configuration/config-file-reference.html>
for available options.
'';
example = lib.literalExpression ''
{
"framework.ssh.timeout" = "120";
"framework.ssh.user" = "deploy";
}
'';
};
ssl = {
enable = lib.mkEnableOption "SSL support";
port = lib.mkOption {
type = lib.types.port;
default = 4443;
description = "Port on which Rundeck will listen for HTTPS when ssl.enable is true";
};
keyStore = lib.mkOption {
type = lib.types.path;
example = "/etc/rundeck/ssl/keystore";
description = "Path to the keystore containing the SSL certificate";
};
keyStorePasswordFile = lib.mkOption {
type = lib.types.path;
description = "Path to a file containing the SSL keystore password";
example = "/run/secrets/rundeck-keystore-password";
};
};
};
};
config = lib.mkIf cfg.enable {
assertions = [
{
assertion =
cfg.database.type == "h2"
|| (cfg.database.host != "" && cfg.database.username != "" && cfg.database.passwordFile != null);
message = "When using external database (PostgreSQL/MySQL), host, username, and passwordFile must be provided";
}
{
assertion = cfg.database.type == "h2" || cfg.database.port != null;
message = "Database port must be set when using an external database";
}
];
services.rundeck.settings = {
"server.address" = lib.mkDefault "0.0.0.0";
"server.port" = lib.mkDefault (toString cfg.serverPort);
"grails.serverURL" = lib.mkDefault cfg.serverURL;
"logging.config" = lib.mkDefault "${cfg.configDir}/log4j2.properties";
"dataSource.url" = lib.mkDefault (
if cfg.database.type == "h2" then
"jdbc:h2:file:${cfg.dataDir}/data/rundeckdb;DB_CLOSE_ON_EXIT=FALSE;NON_KEYWORDS=MONTH,HOUR,MINUTE,YEAR,SECONDS"
else if cfg.database.type == "postgresql" then
"jdbc:postgresql://${cfg.database.host}:${toString cfg.database.port}/${cfg.database.name}"
else
"jdbc:mysql://${cfg.database.host}:${toString cfg.database.port}/${cfg.database.name}?autoReconnect=true&useSSL=false"
);
"dataSource.driverClassName" = lib.mkDefault (
if cfg.database.type == "h2" then
"org.h2.Driver"
else if cfg.database.type == "postgresql" then
"org.postgresql.Driver"
else
"org.mariadb.jdbc.Driver"
);
"dataSource.username" = lib.mkDefault cfg.database.username;
}
// lib.optionalAttrs (cfg.database.passwordFile != null) {
"dataSource.password" = lib.mkDefault "@DB_PASSWORD@";
}
// lib.optionalAttrs (cfg.database.type == "h2") {
"dataSource.dialect" = lib.mkDefault "org.hibernate.dialect.H2Dialect";
}
// lib.optionalAttrs (cfg.database.type == "mysql") {
"dataSource.dialect" = lib.mkDefault "org.hibernate.dialect.MariaDB103Dialect";
}
// lib.optionalAttrs cfg.ssl.enable {
"server.https.port" = lib.mkDefault (toString cfg.ssl.port);
"server.ssl.keyStore" = lib.mkDefault (toString cfg.ssl.keyStore);
"server.ssl.keyStorePassword" = lib.mkDefault "@KEYSTORE_PASSWORD@";
};
services.rundeck.frameworkSettings = {
"framework.server.name" = lib.mkDefault cfg.serverHostname;
"framework.server.hostname" = lib.mkDefault cfg.serverHostname;
"framework.server.port" = lib.mkDefault (toString effectivePort);
"framework.server.url" = lib.mkDefault cfg.serverURL;
"framework.ssh.keypath" = lib.mkDefault "${cfg.dataDir}/.ssh/id_${cfg.sshKeyType}";
"framework.ssh.user" = lib.mkDefault cfg.user;
"framework.ssh.timeout" = lib.mkDefault "60";
"rdeck.base" = cfg.dataDir;
"framework.projects.dir" = "${cfg.dataDir}/projects";
"framework.etc.dir" = toString cfg.configDir;
"framework.var.dir" = "${cfg.dataDir}/var";
"framework.tmp.dir" = "${cfg.dataDir}/var/tmp";
"framework.logs.dir" = "${cfg.dataDir}/var/logs";
"framework.libext.dir" = "${cfg.dataDir}/libext";
"rundeck.server.uuid" = if cfg.serverUUID != "" then cfg.serverUUID else "@SERVER_UUID@";
};
users.users.${cfg.user} = {
isSystemUser = true;
group = cfg.group;
home = cfg.dataDir;
createHome = true;
};
users.groups.${cfg.group} = { };
systemd.tmpfiles.settings."10-rundeck" = {
"${cfg.dataDir}" = {
d = {
mode = "0750";
user = cfg.user;
group = cfg.group;
};
};
"${cfg.dataDir}/etc" = {
d = {
mode = "0750";
user = cfg.user;
group = cfg.group;
};
};
"${cfg.dataDir}/data" = {
d = {
mode = "0750";
user = cfg.user;
group = cfg.group;
};
};
"${cfg.dataDir}/projects" = {
d = {
mode = "0750";
user = cfg.user;
group = cfg.group;
};
};
"${cfg.dataDir}/libext" = {
d = {
mode = "0750";
user = cfg.user;
group = cfg.group;
};
};
"${cfg.dataDir}/var" = {
d = {
mode = "0750";
user = cfg.user;
group = cfg.group;
};
};
"${cfg.dataDir}/var/logs" = {
d = {
mode = "0750";
user = cfg.user;
group = cfg.group;
};
};
"${cfg.dataDir}/var/tmp" = {
d = {
mode = "0750";
user = cfg.user;
group = cfg.group;
};
};
"${cfg.dataDir}/.ssh" = {
d = {
mode = "0700";
user = cfg.user;
group = cfg.group;
};
};
"${cfg.configDir}" = {
d = {
mode = "0750";
user = cfg.user;
group = cfg.group;
};
};
"${cfg.configDir}/ssl" = {
d = {
mode = "0750";
user = cfg.user;
group = cfg.group;
};
};
"/var/log/rundeck" = {
L = {
argument = "${cfg.dataDir}/var/logs";
};
};
};
environment.etc."rundeck/jaas-loginmodule.conf" = {
mode = "0640";
user = cfg.user;
group = cfg.group;
text = ''
RDpropertyfilelogin {
org.eclipse.jetty.jaas.spi.PropertyFileLoginModule required
debug="true"
file="/etc/rundeck/realm.properties";
};
'';
};
environment.etc."rundeck/log4j2.properties" = {
mode = "0640";
user = cfg.user;
group = cfg.group;
text = ''
status = info
name = RundeckPro
appender.console.type = Console
appender.console.name = STDOUT
appender.console.layout.type = PatternLayout
appender.console.layout.pattern = %d{DEFAULT} %-5p %c{1} - %m%n
appender.file.type = RollingFile
appender.file.name = FILE
appender.file.fileName = ${cfg.dataDir}/var/logs/rundeck.log
appender.file.filePattern = ${cfg.dataDir}/var/logs/rundeck.%d{yyyy-MM-dd}.log
appender.file.layout.type = PatternLayout
appender.file.layout.pattern = %d{DEFAULT} [%t] %-5p %c{1} - %m%n
appender.file.policies.type = Policies
appender.file.policies.time.type = TimeBasedTriggeringPolicy
appender.file.policies.time.interval = 1
appender.file.policies.time.modulate = true
rootLogger.level = info
rootLogger.appenderRef.stdout.ref = STDOUT
rootLogger.appenderRef.file.ref = FILE
logger.hibernate.name = org.hibernate
logger.hibernate.level = ERROR
'';
};
systemd.services.rundeck = {
description = "Rundeck Service";
wantedBy = [ "multi-user.target" ];
after = [
"network.target"
]
++ lib.optional (cfg.database.type == "mysql") "mysql.service"
++ lib.optional (cfg.database.type == "postgresql") "postgresql.service";
wants =
lib.optional (cfg.database.type == "mysql") "mysql.service"
++ lib.optional (cfg.database.type == "postgresql") "postgresql.service";
environment = {
RDECK_BASE = cfg.dataDir;
RUNDECK_CONFIG_DIR = cfg.configDir;
JAVA_OPTS = lib.concatStringsSep " " cfg.javaOpts;
};
path = [ pkgs.replace-secret ];
serviceConfig = {
User = cfg.user;
Group = cfg.group;
ExecStart = rundeckStartScript;
WorkingDirectory = cfg.dataDir;
RuntimeDirectory = "rundeck";
RuntimeDirectoryMode = "0750";
UMask = "0027";
LimitNOFILE = 65536;
ReadWritePaths = [
cfg.dataDir
cfg.configDir
];
RestartSec = "10s";
Restart = "always";
TimeoutStartSec = "${toString cfg.startTimeout}s";
CapabilityBoundingSet = [ "CAP_SYS_ADMIN" ];
AmbientCapabilities = [ "CAP_SYS_ADMIN" ];
LimitCORE = 0;
LockPersonality = true;
MemorySwapMax = 0;
MemoryZSwapMax = 0;
PrivateDevices = true;
PrivateTmp = true;
ProtectClock = true;
ProtectControlGroups = true;
ProtectHome = true;
ProtectHostname = true;
ProtectKernelLogs = true;
ProtectKernelModules = true;
ProtectKernelTunables = true;
ProtectProc = "invisible";
RemoveIPC = true;
RestrictAddressFamilies = [
"AF_UNIX"
"AF_INET"
"AF_INET6"
"AF_NETLINK"
];
RestrictNamespaces = true;
RestrictRealtime = true;
RestrictSUIDSGID = true;
SystemCallArchitectures = "native";
};
preStart = ''
${lib.optionalString (cfg.serverUUID == "") ''
# Generate UUID
UUID_FILE="${cfg.dataDir}/.uuid"
if [ ! -f "$UUID_FILE" ]; then
umask 0137
${lib.getExe' pkgs.util-linux "uuidgen"} > "$UUID_FILE"
fi
''}
install -m 0640 ${configFile} ${cfg.configDir}/rundeck-config.properties
install -m 0640 ${frameworkFile} ${cfg.configDir}/framework.properties
install -m 0600 ${realmFile} ${cfg.configDir}/realm.properties
${replaceSecret "@ADMIN_PASSWORD@" cfg.adminPasswordFile "${cfg.configDir}/realm.properties"}
${lib.optionalString (cfg.database.passwordFile != null) (
replaceSecret "@DB_PASSWORD@" cfg.database.passwordFile "${cfg.configDir}/rundeck-config.properties"
)}
${lib.optionalString cfg.ssl.enable (
replaceSecret "@KEYSTORE_PASSWORD@" cfg.ssl.keyStorePasswordFile
"${cfg.configDir}/rundeck-config.properties"
)}
${lib.optionalString (cfg.serverUUID == "") (
replaceSecret "@SERVER_UUID@" "${cfg.dataDir}/.uuid" "${cfg.configDir}/framework.properties"
)}
if [ -f ${cfg.dataDir}/etc/framework.properties ]; then
install -m 0640 ${cfg.configDir}/framework.properties ${cfg.dataDir}/etc/framework.properties
fi
${lib.concatStringsSep "\n" (
lib.mapAttrsToList (
name: content:
"install -m 0640 ${pkgs.writeText "rundeck-${name}" content} ${cfg.dataDir}/etc/${name}"
) cfg.aclPolicies
)}
'';
};
networking.firewall.allowedTCPPorts = lib.mkIf cfg.openFirewall [
(if cfg.ssl.enable then cfg.ssl.port else cfg.serverPort)
];
};
}

View File

@@ -1554,6 +1554,7 @@ in
rsyslogd = handleTest ./rsyslogd.nix { };
rtkit = runTest ./rtkit.nix;
rtorrent = runTest ./rtorrent.nix;
rundeck = runTest ./rundeck.nix;
rush = runTest ./rush.nix;
rustfs = runTest ./rustfs.nix;
rustical = runTest ./web-apps/rustical.nix;

111
nixos/tests/rundeck.nix Normal file
View File

@@ -0,0 +1,111 @@
{
name = "rundeck";
nodes = {
rundeck =
{ pkgs, ... }:
{
environment.etc."rundeck-admin-password" = {
text = "testpassword";
mode = "0400";
user = "rundeck";
group = "rundeck";
};
services.rundeck = {
enable = true;
serverHostname = "rundeck";
adminUser = "testadmin";
adminPasswordFile = "/etc/rundeck-admin-password";
serverPort = 4441;
database.type = "h2";
openFirewall = true;
};
environment.systemPackages = with pkgs; [
curl
jq
];
};
};
testScript = ''
start_all()
def login_and_verify_api(machine, host, port, user, password, timeout=300):
"""Authenticate via Rundeck form login and verify API access."""
machine.wait_until_succeeds(
f"curl -s -c /tmp/cookies -L"
f" -d 'j_username={user}&j_password={password}'"
f" http://{host}:{port}/j_security_check -o /dev/null"
f" && curl -s -b /tmp/cookies -H 'Accept: application/json'"
f" http://{host}:{port}/api/26/system/info"
f" | jq -e '.system.rundeck.version'",
timeout=timeout,
)
def properties(machine, path):
return machine.succeed(f"cat {path}").replace("\\", "").replace(" = ", "=")
with subtest("Rundeck starts and serves homepage"):
rundeck.wait_for_unit("rundeck.service")
rundeck.wait_for_open_port(4441)
rundeck.wait_until_succeeds(
"curl -sL http://rundeck:4441 | grep -qi Rundeck"
)
with subtest("Regression - port is included in generated URLs"):
config = properties(rundeck, "/etc/rundeck/rundeck-config.properties")
framework = properties(rundeck, "/etc/rundeck/framework.properties")
assert "grails.serverURL=http://rundeck:4441" in config, config
assert "server.port=4441" in config, config
assert "framework.server.url=http://rundeck:4441" in framework, framework
with subtest("Structural settings end up in the generated config"):
config = properties(rundeck, "/etc/rundeck/rundeck-config.properties")
assert "dataSource.driverClassName=org.h2.Driver" in config, config
assert "dataSource.dialect=org.hibernate.dialect.H2Dialect" in config, config
with subtest("Server UUID is generated and substituted"):
framework = properties(rundeck, "/etc/rundeck/framework.properties")
assert "@SERVER_UUID@" not in framework, framework
rundeck.succeed(
"grep -qE '^rundeck\\.server\\.uuid = [0-9a-f-]{36}$'"
" /etc/rundeck/framework.properties"
)
with subtest("Secrets are substituted and not world-readable"):
rundeck.succeed(
"grep -qxF 'testadmin:testpassword,user,admin'"
" /etc/rundeck/realm.properties"
)
rundeck.fail("grep -q '@ADMIN_PASSWORD@' /etc/rundeck/realm.properties")
rundeck.succeed(
"[ \"$(stat -c %a /etc/rundeck/realm.properties)\" = 600 ]"
)
rundeck.succeed(
"[ \"$(stat -c %U /etc/rundeck/realm.properties)\" = rundeck ]"
)
with subtest("API authentication via form login"):
login_and_verify_api(rundeck, "rundeck", 4441, "testadmin", "testpassword")
rundeck.succeed(
"curl -s -b /tmp/cookies -X POST"
" -H 'Accept: application/json'"
" -H 'Content-Type: application/json'"
" -d '{\"name\":\"test-project\",\"config\":{}}'"
" http://rundeck:4441/api/26/projects"
" | jq -e '.name == \"test-project\"'"
)
rundeck.succeed(
"curl -s -b /tmp/cookies -H 'Accept: application/json'"
" http://rundeck:4441/api/26/projects"
" | jq -e 'any(.[]; .name == \"test-project\")'"
)
'';
}