diff --git a/nixos/doc/manual/release-notes/rl-2611.section.md b/nixos/doc/manual/release-notes/rl-2611.section.md index 0242987a8009..d921a1ec1cb2 100644 --- a/nixos/doc/manual/release-notes/rl-2611.section.md +++ b/nixos/doc/manual/release-notes/rl-2611.section.md @@ -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} diff --git a/nixos/modules/module-list.nix b/nixos/modules/module-list.nix index a7a07eff0c22..5c77c2014238 100644 --- a/nixos/modules/module-list.nix +++ b/nixos/modules/module-list.nix @@ -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 diff --git a/nixos/modules/services/web-apps/rundeck.nix b/nixos/modules/services/web-apps/rundeck.nix new file mode 100644 index 000000000000..71526d651a82 --- /dev/null +++ b/nixos/modules/services/web-apps/rundeck.nix @@ -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 '' + `://:`, 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 + 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 + 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) + ]; + }; +} diff --git a/nixos/tests/all-tests.nix b/nixos/tests/all-tests.nix index c8d8ce62fdc2..c3165c3ea4d3 100644 --- a/nixos/tests/all-tests.nix +++ b/nixos/tests/all-tests.nix @@ -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; diff --git a/nixos/tests/rundeck.nix b/nixos/tests/rundeck.nix new file mode 100644 index 000000000000..8bfd209816f3 --- /dev/null +++ b/nixos/tests/rundeck.nix @@ -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\")'" + ) + ''; +}