diff --git a/Jenkinsfile b/Jenkinsfile index 4385afa92..9873250b2 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -1,6 +1,6 @@ def failedBuild = false -def minor_version = "8.2" +def minor_version = "9.1" def version = "${minor_version}" def changeUrl = env.CHANGE_URL def slackResponse = null diff --git a/Jenkinsfile-security b/Jenkinsfile-security index 6da6afe91..a26c55b42 100644 --- a/Jenkinsfile-security +++ b/Jenkinsfile-security @@ -1,5 +1,5 @@ -def version = "8.2" +def version = "9.1" def changeUrl = env.CHANGE_URL def job = "" def errors = [] diff --git a/api-authorizations/packaging/metadata b/api-authorizations/packaging/metadata index 01a87a15f..b3003cd9a 100644 --- a/api-authorizations/packaging/metadata +++ b/api-authorizations/packaging/metadata @@ -8,5 +8,6 @@ "jar-files": [ "/opt/rudder/share/plugins/${plugin-name}/${plugin-name}.jar" ], "content": { "files.txz": "/opt/rudder/share/plugins" - } + }, + "requires-license": true } diff --git a/api-authorizations/src/main/scala-templates/default/com/normation/plugins/apiauthorizations/EnablePluginImpl.scala b/api-authorizations/src/main/scala-templates/default/com/normation/plugins/apiauthorizations/EnablePluginImpl.scala index 9cbd1b519..a94f25707 100644 --- a/api-authorizations/src/main/scala-templates/default/com/normation/plugins/apiauthorizations/EnablePluginImpl.scala +++ b/api-authorizations/src/main/scala-templates/default/com/normation/plugins/apiauthorizations/EnablePluginImpl.scala @@ -38,6 +38,6 @@ package com.normation.plugins.apiauthorizations import com.normation.plugins.PluginEnableImpl -import com.normation.rudder.services.nodes.NodeInfoService +import com.normation.rudder.facts.nodes.NodeFactRepository -final class CheckRudderPluginEnableImpl(nodeInfoService: NodeInfoService) extends PluginEnableImpl +final class CheckRudderPluginEnableImpl(nodeFactRepo: NodeFactRepository) extends PluginEnableImpl diff --git a/api-authorizations/src/main/scala-templates/limited/com/normation/plugins/apiauthorizations/EnablePluginImpl.scala b/api-authorizations/src/main/scala-templates/limited/com/normation/plugins/apiauthorizations/EnablePluginImpl.scala index 612ecdace..af29240d4 100644 --- a/api-authorizations/src/main/scala-templates/limited/com/normation/plugins/apiauthorizations/EnablePluginImpl.scala +++ b/api-authorizations/src/main/scala-templates/limited/com/normation/plugins/apiauthorizations/EnablePluginImpl.scala @@ -38,15 +38,15 @@ package com.normation.plugins.apiauthorizations import com.normation.plugins.LicensedPluginCheck -import com.normation.rudder.services.nodes.NodeInfoService +import com.normation.rudder.facts.nodes.NodeFactRepository import com.normation.zio.* -final class CheckRudderPluginEnableImpl(nodeInfoService: NodeInfoService) extends LicensedPluginCheck { +final class CheckRudderPluginEnableImpl(nodeFactRepo: NodeFactRepository) extends LicensedPluginCheck { // here are processed variables def pluginResourcePublickey = "${plugin-resource-publickey}" def pluginResourceLicense = "${plugin-resource-license}" def pluginDeclaredVersion = "${plugin-declared-version}" def pluginId = "${plugin-fullname}" - override def getNumberOfNodes: Int = nodeInfoService.getNumberOfManagedNodes.runNow + override def getNumberOfNodes: Int = nodeFactRepo.getNumberOfManagedNodes().runNow } diff --git a/api-authorizations/src/main/scala/bootstrap/rudder/plugin/ApiAuthorizationsConf.scala b/api-authorizations/src/main/scala/bootstrap/rudder/plugin/ApiAuthorizationsConf.scala index acd7ac069..96540ea7b 100644 --- a/api-authorizations/src/main/scala/bootstrap/rudder/plugin/ApiAuthorizationsConf.scala +++ b/api-authorizations/src/main/scala/bootstrap/rudder/plugin/ApiAuthorizationsConf.scala @@ -55,7 +55,7 @@ class AclLevel(status: PluginStatus) extends ApiAuthorizationLevelService { */ object ApiAuthorizationsConf extends RudderPluginModule { // by build convention, we have only one of that on the classpath - lazy val pluginStatusService = new CheckRudderPluginEnableImpl(RudderConfig.nodeInfoService) + lazy val pluginStatusService = new CheckRudderPluginEnableImpl(RudderConfig.nodeFactRepository) // override default service level RudderConfig.apiAuthorizationLevelService.overrideLevel(new AclLevel(pluginStatusService)) diff --git a/api-authorizations/src/main/scala/com/normation/plugins/apiauthorizations/UserInformationExtension.scala b/api-authorizations/src/main/scala/com/normation/plugins/apiauthorizations/UserInformationExtension.scala index 2c151f82b..31e05a638 100644 --- a/api-authorizations/src/main/scala/com/normation/plugins/apiauthorizations/UserInformationExtension.scala +++ b/api-authorizations/src/main/scala/com/normation/plugins/apiauthorizations/UserInformationExtension.scala @@ -85,7 +85,7 @@ class UserInformationExtension( -
- - - - + + + diff --git a/branding/src/main/scala-templates/default/com/normation/plugins/branding/EnablePluginImpl.scala b/branding/src/main/scala-templates/default/com/normation/plugins/branding/EnablePluginImpl.scala index c94bbb418..55f39b01d 100644 --- a/branding/src/main/scala-templates/default/com/normation/plugins/branding/EnablePluginImpl.scala +++ b/branding/src/main/scala-templates/default/com/normation/plugins/branding/EnablePluginImpl.scala @@ -38,6 +38,6 @@ package com.normation.plugins.branding import com.normation.plugins.PluginEnableImpl -import com.normation.rudder.services.nodes.NodeInfoService +import com.normation.rudder.facts.nodes.NodeFactRepository -final class CheckRudderPluginEnableImpl(nodeInfoService: NodeInfoService) extends PluginEnableImpl +final class CheckRudderPluginEnableImpl(nodeFactRepo: NodeFactRepository) extends PluginEnableImpl diff --git a/branding/src/main/scala-templates/limited/com/normation/plugins/branding/EnablePluginImpl.scala b/branding/src/main/scala-templates/limited/com/normation/plugins/branding/EnablePluginImpl.scala index 8236a551b..033928733 100644 --- a/branding/src/main/scala-templates/limited/com/normation/plugins/branding/EnablePluginImpl.scala +++ b/branding/src/main/scala-templates/limited/com/normation/plugins/branding/EnablePluginImpl.scala @@ -38,15 +38,15 @@ package com.normation.plugins.branding import com.normation.plugins.LicensedPluginCheck -import com.normation.rudder.services.nodes.NodeInfoService +import com.normation.rudder.facts.nodes.NodeFactRepository import com.normation.zio.* -final class CheckRudderPluginEnableImpl(nodeInfoService: NodeInfoService) extends LicensedPluginCheck { +final class CheckRudderPluginEnableImpl(nodeFactRepo: NodeFactRepository) extends LicensedPluginCheck { // here are processed variables def pluginResourcePublickey = "${plugin-resource-publickey}" def pluginResourceLicense = "${plugin-resource-license}" def pluginDeclaredVersion = "${plugin-declared-version}" def pluginId = "${plugin-fullname}" - override def getNumberOfNodes: Int = nodeInfoService.getNumberOfManagedNodes.runNow + override def getNumberOfNodes: Int = nodeFactRepo.getNumberOfManagedNodes().runNow } diff --git a/branding/src/main/scala/bootstrap/rudder/plugin/BrandingPluginConf.scala b/branding/src/main/scala/bootstrap/rudder/plugin/BrandingPluginConf.scala index 28f6bf758..e0114d39e 100644 --- a/branding/src/main/scala/bootstrap/rudder/plugin/BrandingPluginConf.scala +++ b/branding/src/main/scala/bootstrap/rudder/plugin/BrandingPluginConf.scala @@ -44,6 +44,7 @@ import com.normation.plugins.branding.BrandingPluginDef import com.normation.plugins.branding.CheckRudderPluginEnableImpl import com.normation.plugins.branding.api.BrandingApi import com.normation.plugins.branding.api.BrandingApiService +import com.normation.plugins.branding.snippet.BrandingResources import com.normation.plugins.branding.snippet.CommonBranding import com.normation.plugins.branding.snippet.LoginBranding @@ -53,7 +54,7 @@ import com.normation.plugins.branding.snippet.LoginBranding object BrandingPluginConf extends RudderPluginModule { // by build convention, we have only one of that on the classpath - lazy val pluginStatusService = new CheckRudderPluginEnableImpl(RudderConfig.nodeInfoService) + lazy val pluginStatusService = new CheckRudderPluginEnableImpl(RudderConfig.nodeFactRepository) lazy val pluginDef: BrandingPluginDef = new BrandingPluginDef(BrandingPluginConf.pluginStatusService) val brandingConfService: BrandingConfService = new BrandingConfService(BrandingConfService.defaultConfigFilePath) @@ -62,6 +63,7 @@ object BrandingPluginConf extends RudderPluginModule { new BrandingApi(brandingApiService, RudderConfig.stringUuidGenerator) RudderConfig.rudderApi.addModules(brandingApi.getLiftEndpoints()) + RudderConfig.snippetExtensionRegister.register(new BrandingResources(pluginDef.status)) RudderConfig.snippetExtensionRegister.register(new CommonBranding(pluginStatusService)) - RudderConfig.snippetExtensionRegister.register(new LoginBranding(pluginStatusService, pluginDef.version)) + RudderConfig.snippetExtensionRegister.register(new LoginBranding(pluginStatusService)) } diff --git a/branding/src/main/scala/com/normation/plugins/branding/BrandingConf.scala b/branding/src/main/scala/com/normation/plugins/branding/BrandingConf.scala index cfed8c18f..b96f0dbce 100644 --- a/branding/src/main/scala/com/normation/plugins/branding/BrandingConf.scala +++ b/branding/src/main/scala/com/normation/plugins/branding/BrandingConf.scala @@ -105,7 +105,7 @@ object JsonColor { final case class Logo(enable: Boolean, name: Option[String], data: Option[String]) { def loginLogo = (enable, data) match { case (true, Some(d)) => () - case (_, _) => Rudder + case (_, _) => Rudder } def commonWideLogo = (enable, data) match { @@ -116,7 +116,7 @@ final case class Logo(enable: Boolean, name: Option[String], data: Option[String .sidebar-collapse .logo-lg{{display: none !important;}} - case (_, _) => Rudder + case (_, _) => Rudder } def commonSmallLogo = (enable, data) match { case (true, Some(d)) => @@ -133,7 +133,7 @@ final case class Logo(enable: Boolean, name: Option[String], data: Option[String }} - case (_, _) => Rudder + case (_, _) => Rudder } } diff --git a/branding/src/main/scala/com/normation/plugins/branding/BrandingPluginDef.scala b/branding/src/main/scala/com/normation/plugins/branding/BrandingPluginDef.scala index 0abea7400..6d688229e 100644 --- a/branding/src/main/scala/com/normation/plugins/branding/BrandingPluginDef.scala +++ b/branding/src/main/scala/com/normation/plugins/branding/BrandingPluginDef.scala @@ -37,21 +37,12 @@ package com.normation.plugins.branding -import bootstrap.liftweb.Boot import bootstrap.liftweb.ConfigResource -import bootstrap.liftweb.MenuUtils import bootstrap.rudder.plugin.BrandingPluginConf import com.normation.plugins.DefaultPluginDef import com.normation.plugins.PluginStatus -import com.normation.rudder.AuthorizationType.Administration import com.normation.rudder.rest.EndpointSchema import com.normation.rudder.rest.lift.LiftApiModuleProvider -import net.liftweb.http.ClasspathTemplates -import net.liftweb.sitemap.Loc.LocGroup -import net.liftweb.sitemap.Loc.Template -import net.liftweb.sitemap.Loc.TestAccess -import net.liftweb.sitemap.LocPath.stringToLocPath -import net.liftweb.sitemap.Menu class BrandingPluginDef(override val status: PluginStatus) extends DefaultPluginDef { @@ -64,17 +55,4 @@ class BrandingPluginDef(override val status: PluginStatus) extends DefaultPlugin def oneTimeInit: Unit = {} val configFiles: Seq[ConfigResource] = Seq() - - override def pluginMenuEntry: List[(Menu, Option[String])] = { - ( - (Menu("640-brandingManagement", Branding) / - "secure" / "administration" / "brandingManagement" - >> LocGroup("administrationGroup") - >> TestAccess(() => Boot.userIsAllowed("/secure/administration/policyServerManagement", Administration.Read)) - >> Template(() => - ClasspathTemplates("template" :: "brandingManagement" :: Nil) openOr
Template not found
- )).toMenu, - Some(MenuUtils.utilitiesMenu) - ) :: Nil - } } diff --git a/branding/src/main/scala/com/normation/plugins/branding/snippet/BrandingResources.scala b/branding/src/main/scala/com/normation/plugins/branding/snippet/BrandingResources.scala index 2fcc0e5e8..d373965ad 100644 --- a/branding/src/main/scala/com/normation/plugins/branding/snippet/BrandingResources.scala +++ b/branding/src/main/scala/com/normation/plugins/branding/snippet/BrandingResources.scala @@ -37,18 +37,23 @@ package com.normation.plugins.branding.snippet -import net.liftweb.http.DispatchSnippet +import com.normation.plugins.PluginExtensionPoint +import com.normation.plugins.PluginStatus +import com.normation.rudder.web.ChooseTemplate +import com.normation.rudder.web.snippet.administration.Settings import net.liftweb.http.LiftRules +import scala.reflect.ClassTag import scala.xml.NodeSeq -class BrandingResources extends DispatchSnippet { +class BrandingResources(val status: PluginStatus)(implicit val ttag: ClassTag[Settings]) extends PluginExtensionPoint[Settings] { private[this] def link(s: String) = s"/${LiftRules.resourceServerPath}/branding/${s}" - override def dispatch = { - case "css" => - (_: NodeSeq) => - case "js" => - (_: NodeSeq) => - } + private def template: NodeSeq = ChooseTemplate("template" :: "brandingManagement" :: Nil, "component-body") + + override def pluginCompose(snippet: Settings): Map[String, NodeSeq => NodeSeq] = Map( + "body" -> Settings.addTab("brandingTab", "Branding configuration", template), + "css" -> ((_: NodeSeq) => ), + "js" -> ((_: NodeSeq) => ) + ) } diff --git a/branding/src/main/scala/com/normation/plugins/branding/snippet/CommonBranding.scala b/branding/src/main/scala/com/normation/plugins/branding/snippet/CommonBranding.scala index 0d4ea5f59..50e803aff 100644 --- a/branding/src/main/scala/com/normation/plugins/branding/snippet/CommonBranding.scala +++ b/branding/src/main/scala/com/normation/plugins/branding/snippet/CommonBranding.scala @@ -78,7 +78,7 @@ class CommonBranding(val status: PluginStatus)(implicit val ttag: ClassTag[Commo case _ => NodeSeq.Empty } - var (customWideLogo, customSmallLogo, rudderLogo) = data match { + val (customWideLogo, customSmallLogo, rudderLogo) = data match { case Right(d) => ( d.wideLogo.commonWideLogo, @@ -87,12 +87,11 @@ class CommonBranding(val status: PluginStatus)(implicit val ttag: ClassTag[Commo { if (d.wideLogo.enable && d.wideLogo.data.isDefined) - Rudder + Rudder else NodeSeq.Empty - } - { + }{ if (d.smallLogo.enable && d.smallLogo.data.isDefined) - Rudder + Rudder else NodeSeq.Empty } @@ -104,7 +103,7 @@ class CommonBranding(val status: PluginStatus)(implicit val ttag: ClassTag[Commo case _ => ( Rudder, - Rudder, + Rudder, NodeSeq.Empty ) } diff --git a/branding/src/main/scala/com/normation/plugins/branding/snippet/LoginBranding.scala b/branding/src/main/scala/com/normation/plugins/branding/snippet/LoginBranding.scala index 5c9ea6a33..ad472602d 100644 --- a/branding/src/main/scala/com/normation/plugins/branding/snippet/LoginBranding.scala +++ b/branding/src/main/scala/com/normation/plugins/branding/snippet/LoginBranding.scala @@ -40,7 +40,6 @@ package com.normation.plugins.branding.snippet import bootstrap.rudder.plugin.BrandingPluginConf import com.normation.plugins.PluginExtensionPoint import com.normation.plugins.PluginStatus -import com.normation.plugins.PluginVersion import com.normation.rudder.web.snippet.Login import com.normation.zio.UnsafeRun import net.liftweb.common.Loggable @@ -48,7 +47,7 @@ import net.liftweb.util.Helpers.* import scala.reflect.ClassTag import scala.xml.NodeSeq -class LoginBranding(val status: PluginStatus, version: PluginVersion)(implicit val ttag: ClassTag[Login]) +class LoginBranding(val status: PluginStatus)(implicit val ttag: ClassTag[Login]) extends PluginExtensionPoint[Login] with Loggable { def pluginCompose(snippet: Login): Map[String, NodeSeq => NodeSeq] = Map( @@ -90,7 +89,8 @@ class LoginBranding(val status: PluginStatus, version: PluginVersion)(implicit v }} ) - case _ => (Rudder, NodeSeq.Empty) + case _ => + (Rudder, NodeSeq.Empty) } val logoContainer = {
diff --git a/branding/src/main/scala/com/normation/rudder/rest/BrandingApiSchema.scala b/branding/src/main/scala/com/normation/rudder/rest/BrandingApiSchema.scala index 29cc580c7..6896e71ce 100644 --- a/branding/src/main/scala/com/normation/rudder/rest/BrandingApiSchema.scala +++ b/branding/src/main/scala/com/normation/rudder/rest/BrandingApiSchema.scala @@ -63,14 +63,16 @@ object BrandingApiEndpoints extends Enum[BrandingApiSchema] with ApiModuleProvid val z = implicitly[Line].value val description = "Update branding plugin configuration" val (action, path) = POST / "branding" - val dataContainer: Option[String] = None + val dataContainer: Option[String] = None + val authz: List[AuthorizationType] = AuthorizationType.Administration.Write :: Nil } final case object ReloadBrandingConf extends BrandingApiSchema with ZeroParam with StartsAtVersion10 with SortIndex { val z = implicitly[Line].value val description = "Reload branding plugin configuration from config file" val (action, path) = POST / "branding" / "reload" - val dataContainer: Option[String] = None + val dataContainer: Option[String] = None + val authz: List[AuthorizationType] = AuthorizationType.Administration.Write :: Nil } def endpoints = values.toList.sortBy(_.z) diff --git a/branding/src/main/style/media.css b/branding/src/main/style/media.css deleted file mode 100755 index a730e01d7..000000000 --- a/branding/src/main/style/media.css +++ /dev/null @@ -1,395 +0,0 @@ -html, body { - background: #fff; - padding: 0; - margin: 0; -} -main{ - padding-top: 20px; -} -.bg-pattern{ - background-image: url(data:image/png;base64,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); -} -label.form-control{ - background-color: #F8F9FC; - box-shadow: none; - border-left: none; - padding: 8px 12px !important; -} -label.form-control,label.input-group-text{ - cursor: pointer; -} -label.form-control > i { - font-weight: normal; - opacity: .8; - font-family: var(--font-mono); - font-size: .9em; - margin-left: 2px; - font-style: normal; -} -form{ - padding: 15px; -} -.panel-body, .panel-heading{ - padding: 0; -} -.panel-body > .row > div{ - padding: 15px 30px; -} -.panel-heading a{ - padding: 10px 15px; - display: block; - text-decoration: none !important; - transition-duration: .2s; - border-bottom: 2px solid #D6DEEF; - background-color: #fff; - font-weight: bold; - transition-duration: .2s; - color: #041922 !important; - cursor: default; -} -.panel:hover .panel-heading a{ - border-bottom-color: #13beb7; -} -.panel-heading a.collapsed{ - border-bottom: 2px solid #D6DEEF; -} -/* -- PANEL SEPARATION -- */ -.panel-body > .panel-col:first-child{ - padding: 15px; - margin-right: -1px; - border-right: 1px solid #D6DEEF; - position: relative; - background-color: #fff; -} -.panel-body > .panel-col:last-child:before{ - content: ""; - z-index: 100; - position: absolute; - left: -1px; - top: calc(50% - 20px); - width: 0; - height: 0; - border-top: 20px solid transparent; - border-bottom: 20px solid transparent; - border-left: 20px solid #fff; - display: block; -} -.panel-body > .panel-col:last-child:after{ - content: ""; - z-index: 95; - position: absolute; - left: 0px; - top: calc(50% - 20px); - width: 0; - height: 0; - border-top: 20px solid transparent; - border-bottom: 20px solid transparent; - border-left: 20px solid #D6DEEF; - display: block; -} -.panel-body > .panel-col:last-child{ - padding: 15px; - border-left: 1px solid #D6DEEF; - background-color: #F8F9FC; - display: flex; -} - -@media screen and (min-width: 992px){ - .panel-body > .panel-col:first-child{ - margin-right: -1px; - border-right: 1px solid #D6DEEF; - } - .panel-body > .panel-col:last-child:before{ - left: -1px; - top: calc(50% - 20px); - width: 0; - height: 0; - border-top: 20px solid transparent; - border-bottom: 20px solid transparent; - border-left: 20px solid #fff; - } - .panel-body > .panel-col:last-child:after{ - left: 0px; - top: calc(50% - 20px); - width: 0; - height: 0; - border-top: 20px solid transparent; - border-bottom: 20px solid transparent; - border-left: 20px solid #D6DEEF; - } -} -/* -- --------------- -- */ -.preview-window{ - background-color: #fff; - margin:auto; - border:1px solid #D6DEEF; - width:100%; - max-width: 400px; - position: relative; -} -.preview-window .custom-bar{ - width: 100%; - height:20px; - text-align: center; - position: absolute; - top: 0; - left: 0; -} -.preview-window .custom-bar > span{ - font-size: 12px; - line-height: 20px; - font-weight: bold; -} -.preview-window > .left-menu{ - width: 20%; - height:220px; - background-color: #041922; - margin-top: -20px; -} -.preview-window > .top-menu{ - height: 25px; - background-color: #fff; - position: relative; - width: 100%; - margin-top: 20px; -} -.preview-window > .top-menu:after { - content: ""; - box-shadow: 2px 2px 5px #dce2e4b8; - position: absolute; - display: block; - top: 0; - left: 20%; - right: 0; - bottom: 0; -} -.preview-window > .custom-bar.hidden + .top-menu, .preview-window > .custom-bar.hidden + .top-menu + .left-menu{ - margin-top: 0px; -} -.preview-window > .top-menu > div{ - position: absolute; - left: 0; - top: 0; - height: 25px; - width: 20%; - background-color: #041922; -} -.preview-window > .top-menu > div > span{ - position: absolute; - left : 10px; - right : 10px; - top : 5px; - bottom: 5px; - background-size: contain; - background-position: center; - background-repeat: no-repeat; -} - -/* === LOGIN === */ -.preview-window.login{ - padding: 15px; - background-color: #F8F9FC; - display: flex; - flex-direction: column; - justify-content: center; - align-items: center; -} -.login .login-container{ - width: 62%; -} -.login .logo-container > div{ - height: 40px; - width: 100%; - background-size: contain; - background-position: center; - background-repeat: no-repeat; - margin: 12px 0 10px 0; -} -.preview-window .fake-form{ - width: 100%; - border-radius: 14px; - padding: 20px; - box-shadow: 0 10px 20px 10px #dce3ef9e; - position: relative; - margin-bottom: 25px; -} -.preview-window .fake-form .custom-bar{ - height:15px; - position: absolute; - top: 0; - bottom: 0; - left: 0; - right: 0; - font-weight: 700; - border-top-left-radius: 14px; - border-top-right-radius: 14px; -} -.preview-window .fake-form .custom-bar > span{ - line-height: 15px; -} -.preview-window .fake-form .text-motd{ - margin-top: 5px; - padding: 0 8px; - font-size: .9em; - text-align: center; -} -.preview-window .fake-form .fake-input{ - width: 90%; - margin: auto; - height: 20px; - border-radius: 4px; - border:1px solid #D6DEEF; - margin-top: 12px; -} -.preview-window .fake-form .fake-btn{ - background-color: #13beb7; - width: 90%; - margin: auto; - height: 20px; - border-radius: 4px; - margin-top: 12px; -} - -/* LOGOS PREVIEW */ -.preview-logo{ - width: 100%; - max-width: 370px; - display: flex; - margin: auto; - transition-property: opacity; - transition-duration: .2s; - height: 40px; - position: relative; - padding: 15px; - box-sizing: content-box; -} -.preview-logo:before, -.preview-logo:after{ - content:""; - position: absolute; - top: 0; - bottom: 0; - z-index: 1; - transition-property: opacity; - transition-duration: .2s; -} -.preview-logo:before{ - left: 0; - width: 70px; - background-color: #041922; -} -.preview-logo:after{ - left: 70px; - width: calc(100% - 70px); - background-color: #041922; -} -.preview-logo.sm-disabled:before, -.preview-logo.lg-disabled:after{ - opacity: .5; -} -.preview-logo > div{ - background-position: center; - background-size: contain; - background-repeat: no-repeat; - position: relative; - z-index: 2; -} -.preview-logo > div:first-child{ - width: 40px; -} -.preview-logo > div:last-child{ - flex: 1; -} - -/* -- COLOR PICKER -- */ -ui-dropdown-panel{ - z-index:9999 !important; -} -.input-group > .input-group-text + ui-picker{ - border-color: #D6DEEF; - border-top-left-radius: 0; - border-bottom-left-radius: 0; -} -.input-group > .input-group-text + ui-picker > ui-picker-input{ - padding: 0; - height: 100%; -} -.input-group > .input-group-text + ui-picker > ui-picker-input > ui-color-picker-rect{ - top: 0; - right: -1px; - width: 34px; - height: 33.5px; - border-top-left-radius: 0; - border-bottom-left-radius: 0; - padding: 11px 10px; - background-image: none; - background-color: #F8F9FC; - border: 1px solid #D6DEEF; - border-top: none; - border-bottom: none; -} -.input-group > .input-group-text + ui-picker ui-color-picker-background{ - border-radius: 2px; -} -.input-group > .input-group-text + ui-picker ui-color-picker-text{ - padding: 6px 12px; -} - -/* RUDDER CSS CONFLICTS FIXES */ -#branding-main{ - padding-bottom: 40px; -} -#branding-main .panel-heading, -#branding-main .panel-heading .panel-title{ - margin:0; - padding:0; -} -#branding-main .input-group > .input-group-text:last-child > .fa{ - color: #72829D; - transition-duration: .2s; -} -.remove-btn , -.upload-btn { - transition-duration: .2s; -} -#branding-main .input-group > .input-group-text:last-child.remove-btn:hover > i.fa{ - color: #DA291C; -} -#branding-main .input-group > .input-group-text:last-child.upload-btn:hover > i.fa{ - color: #337ab7; -} -#branding-main .input-group > .input-group-text:last-child.remove-btn > i.fa-upload, -#branding-main .input-group > .input-group-text:last-child.upload-btn > i.fa-times{ - display: none; -} - -#branding-main .toolbar { - background-color: #fff; - border-top: 1px solid #D6DEEF; - position: fixed; - z-index: 200; - text-align: right; - bottom: 0; - left: 0; - width: 100%; - padding: 10px 30px; -} -#branding-main .toolbar .btn{ - min-width: 80px; -} -#branding-main .panel{ - overflow: hidden; - border: 1px solid #D6DEEF; - margin-bottom: 20px; - border-radius: 4px; -} -#branding-main .panel-body{ - padding: 0; - display: flex; -} - -/* CSS EFFECTS */ -#headerBar > .background{ - transition-duration:.6s; - transition-property: color, background-color; -} \ No newline at end of file diff --git a/branding/src/main/style/media.scss b/branding/src/main/style/media.scss new file mode 100755 index 000000000..7d412dc81 --- /dev/null +++ b/branding/src/main/style/media.scss @@ -0,0 +1,389 @@ +// Colors +$rudder-bg-light-gray : #F8F9FC; +$rudder-border-color-default : #D6DEEF; + +#brandingTab{ + width: 100%; + background-color: $rudder-bg-light-gray; +} +.bg-pattern{ + position: relative; + background-image: url(data:image/png;base64,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); +} + +label.form-control{ + background-color: $rudder-bg-light-gray; + box-shadow: none; + border-left: none; + padding: 8px 12px !important; + + i { + font-weight: normal; + opacity: .8; + font-family: var(--font-mono); + font-size: .9em; + margin-left: 2px; + font-style: normal; + } +} + +label.form-control, +label.input-group-text{ + cursor: pointer; +} + +.card-body > .row > div{ + padding: 15px 30px; +} +.card-header a{ + padding: 10px 15px; + display: flex; + border-bottom: 2px solid #D6DEEF; + background-color: #fff; + font-weight: bold; + transition-duration: 0.2s; + color: #041922 !important; +} +.card:hover .card-header a{ + border-bottom-color: #13beb7; +} +.card-header a.collapsed{ + border-bottom: 2px solid $rudder-border-color-default; +} +/* -- PANEL SEPARATION -- */ +.card-body > .card-col:first-child{ + padding: 15px; + margin-right: -1px; + border-right: 1px solid $rudder-border-color-default; + position: relative; + background-color: #fff; +} +.card-body > .card-col:last-child:before{ + content: ""; + z-index: 100; + position: absolute; + left: -1px; + top: calc(50% - 20px); + width: 0; + height: 0; + border-top: 20px solid transparent; + border-bottom: 20px solid transparent; + border-left: 20px solid #fff; + display: block; +} +.card-body > .card-col:last-child:after{ + content: ""; + z-index: 95; + position: absolute; + left: 0px; + top: calc(50% - 20px); + width: 0; + height: 0; + border-top: 20px solid transparent; + border-bottom: 20px solid transparent; + border-left: 20px solid $rudder-border-color-default; + display: block; +} +.card-body > .card-col:last-child{ + padding: 15px; + border-left: 1px solid $rudder-border-color-default; + background-color: $rudder-bg-light-gray; + display: flex; +} + +@media screen and (min-width: 992px){ + .card-body > .card-col:first-child{ + margin-right: -1px; + border-right: 1px solid $rudder-border-color-default; + } + .card-body > .card-col:last-child:before{ + left: -1px; + top: calc(50% - 20px); + width: 0; + height: 0; + border-top: 20px solid transparent; + border-bottom: 20px solid transparent; + border-left: 20px solid #fff; + } + .card-body > .card-col:last-child:after{ + left: 0px; + top: calc(50% - 20px); + width: 0; + height: 0; + border-top: 20px solid transparent; + border-bottom: 20px solid transparent; + border-left: 20px solid $rudder-border-color-default; + } +} +/* -- --------------- -- */ +.preview-window{ + background-color: #fff; + margin:auto; + border:1px solid $rudder-border-color-default; + width:100%; + max-width: 400px; + position: relative; + + .custom-bar{ + width: 100%; + height:20px; + text-align: center; + position: absolute; + top: 0; + left: 0; + + & > span{ + font-size: 12px; + line-height: 20px; + font-weight: bold; + } + + &.hidden { + & + .top-menu, + & + .left-menu{ + margin-top: 0px; + } + } + } + + & > .left-menu{ + width: 20%; + height:220px; + background-color: #041922; + margin-top: -20px; + } + + & > .top-menu{ + height: 25px; + background-color: #fff; + position: relative; + width: 100%; + margin-top: 20px; + + &:after { + content: ""; + box-shadow: 2px 2px 5px #dce2e4b8; + position: absolute; + display: block; + top: 0; + left: 20%; + right: 0; + bottom: 0; + } + + & > div{ + position: absolute; + left: 0; + top: 0; + height: 25px; + width: 20%; + background-color: #041922; + + & > span{ + position: absolute; + left : 10px; + right : 10px; + top : 5px; + bottom: 5px; + background-size: contain; + background-position: center; + background-repeat: no-repeat; + } + } + } +} + +/* === LOGIN === */ +.preview-window.login{ + padding: 15px; + background-color: $rudder-bg-light-gray; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; +} +.login .login-container{ + width: 62%; +} +.login .logo-container > div{ + height: 40px; + width: 100%; + background-size: contain; + background-position: center; + background-repeat: no-repeat; + margin: 12px 0 10px 0; +} +.preview-window .fake-form{ + width: 100%; + border-radius: 14px; + padding: 20px; + box-shadow: 0 10px 20px 10px #dce3ef9e; + position: relative; + margin-bottom: 25px; +} +.preview-window .fake-form .custom-bar{ + height:15px; + position: absolute; + top: 0; + bottom: 0; + left: 0; + right: 0; + font-weight: 700; + border-top-left-radius: 14px; + border-top-right-radius: 14px; +} +.preview-window .fake-form .custom-bar > span{ + line-height: 15px; +} +.preview-window .fake-form .text-motd{ + margin-top: 5px; + padding: 0 8px; + font-size: .9em; + text-align: center; +} +.preview-window .fake-form .fake-input{ + width: 90%; + margin: auto; + height: 20px; + border-radius: 4px; + border:1px solid $rudder-border-color-default; + margin-top: 12px; +} +.preview-window .fake-form .fake-btn{ + background-color: #13beb7; + width: 90%; + margin: auto; + height: 20px; + border-radius: 4px; + margin-top: 12px; +} + +/* LOGOS PREVIEW */ +.preview-logo{ + width: 100%; + max-width: 370px; + display: flex; + margin: auto; + transition-property: opacity; + transition-duration: .2s; + height: 40px; + position: relative; + padding: 15px; + box-sizing: content-box; + + &:before, + &:after{ + content:""; + position: absolute; + top: 0; + bottom: 0; + z-index: 1; + transition-property: opacity; + transition-duration: .2s; + } + &:before{ + left: 0; + width: 70px; + background-color: #041922; + } + &:after{ + left: 70px; + width: calc(100% - 70px); + background-color: #041922; + } + &.sm-disabled:before, + &.lg-disabled:after{ + opacity: .5; + } + + & > div{ + background-position: center; + background-size: contain; + background-repeat: no-repeat; + position: relative; + z-index: 2; + + &:first-child{ + width: 40px; + } + &:last-child{ + flex: 1; + } + } +} + +/* -- COLOR PICKER -- */ +ui-dropdown-panel{ + z-index:9999 !important; +} +.input-group > .input-group-text + ui-picker{ + border-color: $rudder-border-color-default; + border-top-left-radius: 0; + border-bottom-left-radius: 0; + + & > ui-picker-input{ + padding: 0; + height: 100%; + + & > ui-color-picker-rect{ + top: 0; + right: -1px; + width: 34px; + height: 33.5px; + border-top-left-radius: 0; + border-bottom-left-radius: 0; + padding: 11px 10px; + background-image: none; + background-color: $rudder-bg-light-gray; + border: 1px solid $rudder-border-color-default; + border-top: none; + border-bottom: none; + } + } +} +.input-group > .input-group-text + ui-picker ui-color-picker-background{ + border-radius: 2px; +} +.input-group > .input-group-text + ui-picker ui-color-picker-text{ + padding: 6px 12px; +} + +#brandingTab .input-group > .input-group-text:last-child > .fa{ + color: #72829D; + transition-duration: .2s; +} +.remove-btn , +.upload-btn { + transition-duration: .2s; +} +#brandingTab .input-group > .input-group-text.remove-btn:hover > i.fa{ + color: #DA291C; +} +#brandingTab .input-group > .input-group-text.upload-btn:hover > i.fa{ + color: #337ab7; +} +#brandingTab .input-group > .input-group-text.remove-btn > i.fa-upload, +#brandingTab .input-group > .input-group-text.upload-btn > i.fa-times{ + display: none; +} + +#brandingTab .toolbar { + background-color: #fff; + border-top: 1px solid $rudder-border-color-default; + position: fixed; + z-index: 200; + text-align: right; + bottom: 0; + left: 0; + width: 100%; + padding: 10px 30px; + .btn{ + min-width: 80px; + } +} + +/* CSS EFFECTS */ +#headerBar > .background{ + transition-duration:.6s; + transition-property: color, background-color; +} \ No newline at end of file diff --git a/change-validation/README.adoc b/change-validation/README.adoc index 1e357de4e..5a74e765f 100644 --- a/change-validation/README.adoc +++ b/change-validation/README.adoc @@ -71,7 +71,7 @@ image::docs/images/States.png[] == Change request management page -All Change requests can be seen on the /secure/plugins/changes/changeRequests page. +All Change requests can be seen on the /secure/configurationManager/changes/changeRequests page. There is a table containing all requests, you can access to each of them by clicking on their id. You can filter change requests by status and only display what you need. @@ -79,7 +79,7 @@ image::docs/images/Management.png[] === Change request detail page -Each Change request is reachable on the /secure/plugins/changes/changeRequest/id. +Each Change request is reachable on the /secure/configurationManager/changes/changeRequest/id. image::docs/images/Details.png[] diff --git a/change-validation/packaging/metadata b/change-validation/packaging/metadata index 206921d9e..5a1309939 100644 --- a/change-validation/packaging/metadata +++ b/change-validation/packaging/metadata @@ -8,5 +8,6 @@ "jar-files": [ "/opt/rudder/share/plugins/${plugin-name}/${plugin-name}.jar" ], "content": { "files.txz": "/opt/rudder/share/plugins" - } + }, + "requires-license": true } diff --git a/change-validation/pom-template.xml b/change-validation/pom-template.xml index 61de0fadc..a2e4527a6 100644 --- a/change-validation/pom-template.xml +++ b/change-validation/pom-template.xml @@ -51,7 +51,7 @@ com.github.spullara.mustache.java compiler - 0.9.11 + 0.9.14 com.sun.mail @@ -63,7 +63,7 @@ com.github.davidmoten subethasmtp - 7.0.2 + 7.1.1 test diff --git a/change-validation/src/main/elm/elm.json b/change-validation/src/main/elm/elm.json index df468af9a..70a0dd8e6 100644 --- a/change-validation/src/main/elm/elm.json +++ b/change-validation/src/main/elm/elm.json @@ -12,11 +12,13 @@ "elm/html": "1.0.0", "elm/http": "2.0.0", "elm/json": "1.1.3", - "elm/regex": "1.0.0" + "elm/regex": "1.0.0", + "elm-explorations/test": "2.2.0" }, "indirect": { "elm/bytes": "1.0.8", "elm/file": "1.0.5", + "elm/random": "1.0.0", "elm/time": "1.0.0", "elm/url": "1.0.0", "elm/virtual-dom": "1.0.3" diff --git a/change-validation/src/main/elm/sources/ApiCalls.elm b/change-validation/src/main/elm/sources/ApiCalls.elm index 701e74e16..3ce480a9e 100644 --- a/change-validation/src/main/elm/sources/ApiCalls.elm +++ b/change-validation/src/main/elm/sources/ApiCalls.elm @@ -1,58 +1,89 @@ module ApiCalls exposing (..) -import Http exposing (emptyBody, expectJson, header, jsonBody, request) -import JsonDecoders exposing (decodeApiDeleteUsername, decodeUserList) import DataTypes exposing (..) -import JsonEncoders exposing (encodeUsernames) +import Http exposing (emptyBody, expectJson, header, jsonBody, request) +import JsonDecoders exposing (decodeApiDeleteUsername, decodeSetting, decodeUserList) +import JsonEncoders exposing (encodeSetting, encodeUsernames) -getUrl: DataTypes.Model -> String -> String + +getUrl : DataTypes.Model -> String -> String getUrl m url = - m.contextPath ++ "/secure/api/" ++ url + m.contextPath ++ "/secure/api/" ++ url + getUsers : DataTypes.Model -> Cmd Msg getUsers model = - let - req = - request - { method = "GET" - , headers = [header "X-Requested-With" "XMLHttpRequest"] - , url = getUrl model "users" - , body = emptyBody - , expect = expectJson GetUsers decodeUserList - , timeout = Nothing - , tracker = Nothing - } - in + let + req = + request + { method = "GET" + , headers = [ header "X-Requested-With" "XMLHttpRequest" ] + , url = getUrl model "users" + , body = emptyBody + , expect = expectJson (WorkflowUsersMsg << GetUsers) decodeUserList + , timeout = Nothing + , tracker = Nothing + } + in req -removeValidatedUser: Username -> Model -> Cmd Msg -removeValidatedUser username model = - let - req = - request - { method = "DELETE" - , headers = [header "X-Requested-With" "XMLHttpRequest"] - , url = getUrl model ("validatedUsers/" ++ username) - , body = emptyBody - , expect = expectJson RemoveUser decodeApiDeleteUsername - , timeout = Nothing - , tracker = Nothing - } - in - req saveWorkflow : List Username -> Model -> Cmd Msg saveWorkflow usernames model = - let - req = - request - { method = "POST" - , headers = [header "X-Requested-With" "XMLHttpRequest"] - , url = getUrl model "validatedUsers" - , body = jsonBody (encodeUsernames usernames) - , expect = expectJson SaveWorkflow decodeUserList - , timeout = Nothing - , tracker = Nothing - } - in - req \ No newline at end of file + let + req = + request + { method = "POST" + , headers = [ header "X-Requested-With" "XMLHttpRequest" ] + , url = getUrl model "validatedUsers" + , body = jsonBody (encodeUsernames usernames) + , expect = expectJson (WorkflowUsersMsg << SaveWorkflow) decodeUserList + , timeout = Nothing + , tracker = Nothing + } + in + req + + +getSetting : Model -> String -> (Result Http.Error Bool -> WorkflowUsersMsg) -> Cmd Msg +getSetting model settingId msg = + let + req = + request + { method = "GET" + , headers = [ header "X-Requested-With" "XMLHttpRequest" ] + , url = getUrl model ("settings/" ++ settingId) + , body = emptyBody + , expect = expectJson (WorkflowUsersMsg << msg) (decodeSetting settingId) + , timeout = Nothing + , tracker = Nothing + } + in + req + + +getValidateAllSetting : Model -> Cmd Msg +getValidateAllSetting model = + getSetting model "enable_validate_all" GetValidateAllSetting + + +setSetting : Model -> String -> (Result Http.Error Bool -> WorkflowUsersMsg) -> Bool -> Cmd Msg +setSetting model settingId msg newValue = + let + req = + request + { method = "POST" + , headers = [ header "X-Requested-With" "XMLHttpRequest" ] + , url = getUrl model ("settings/" ++ settingId) + , body = jsonBody (encodeSetting newValue) + , expect = expectJson (WorkflowUsersMsg << msg) (decodeSetting settingId) + , timeout = Nothing + , tracker = Nothing + } + in + req + + +saveValidateAllSetting : Bool -> Model -> Cmd Msg +saveValidateAllSetting newValue model = + setSetting model "enable_validate_all" SaveValidateAllSetting newValue diff --git a/change-validation/src/main/elm/sources/ChangeRequestEditForm.elm b/change-validation/src/main/elm/sources/ChangeRequestEditForm.elm new file mode 100644 index 000000000..715fc4b1d --- /dev/null +++ b/change-validation/src/main/elm/sources/ChangeRequestEditForm.elm @@ -0,0 +1,489 @@ +module ChangeRequestEditForm exposing (..) + +------------------------------ +-- Init and main -- +------------------------------ + +import Browser +import ErrorMessages exposing (getErrorMessage) +import Html exposing (Html, a, div, form, h2, h3, input, label, p, span, text, textarea) +import Html.Attributes exposing (class, disabled, for, href, id, maxlength, name, readonly, required, style, type_, value) +import Html.Events exposing (onClick, onInput, onSubmit) +import Http exposing (Error, emptyBody, expectJson, header, jsonBody, request) +import Json.Decode exposing (Decoder, andThen, at, fail, field, index, int, map3, map4, map5, string, succeed) +import Json.Encode as Encode +import Ports exposing (errorNotification, readUrl, successNotification) + + +getApiUrl : Model -> String -> String +getApiUrl m url = + m.contextPath ++ "/secure/api/" ++ url + + +changeRequestsPageUrl : Model -> String +changeRequestsPageUrl m = + m.contextPath ++ "/secure/configurationManager/changes/changeRequests" + + +main = + Browser.element + { init = init + , view = view + , update = update + , subscriptions = subscriptions + } + + +init : { contextPath : String, hasWriteRights : Bool } -> ( Model, Cmd Msg ) +init flags = + let + initModel = + Model + flags.contextPath + ChangeRequestIdNotSet + flags.hasWriteRights + NoView + in + ( initModel, Cmd.none ) + + + +------------------------------ +-- MODEL -- +------------------------------ + + +type alias Model = + { contextPath : String + , changeRequest : ChangeRequestDetailsOpt + , hasWriteRights : Bool + , viewState : ViewState + } + + +type Msg + = GetChangeRequestDetails (Result Error ChangeRequestDetails) + | SetChangeRequestDetails (Result Error ChangeRequestDetails) + | GetChangeRequestIdFromUrl String + -- Form input + | FormInputName String + | FormInputDescription String + | FormSubmit + + +type alias ChangeRequestDetails = + { title : String + , state : String + , id : Int + , description : String + } + + +type ChangeRequestDetailsOpt + = Success ChangeRequestDetails + | ChangeRequestIdNotSet + + +type ViewState + = NoView + | ViewError String + | Form { initValues : ChangeRequestDetails, formValues : ChangeRequestDetails } + + + +------------------------------ +-- API -- +------------------------------ + + +getChangeRequestDetails : Model -> Int -> Cmd Msg +getChangeRequestDetails model crId = + let + req = + request + { method = "GET" + , headers = [ header "X-Requested-With" "XMLHttpRequest" ] + , url = getApiUrl model ("changeRequests/" ++ String.fromInt crId) + , body = emptyBody + , expect = expectJson GetChangeRequestDetails decodeChangeRequestDetails + , timeout = Nothing + , tracker = Nothing + } + in + req + + +updateChangeRequestDetails : Model -> ChangeRequestDetails -> Cmd Msg +updateChangeRequestDetails model changeRequestDetails = + let + req = + request + { method = "POST" + , headers = [ header "X-Requested-With" "XMLHttpRequest" ] + , url = getApiUrl model ("changeRequests/" ++ String.fromInt changeRequestDetails.id) + , body = encodeChangeRequestDetails changeRequestDetails |> jsonBody + , expect = expectJson SetChangeRequestDetails decodeChangeRequestDetails + , timeout = Nothing + , tracker = Nothing + } + in + req + + + +------------------------------ +-- ENCODE / DECODE JSON -- +------------------------------ + + +decodeChangeRequestStatus : Decoder String +decodeChangeRequestStatus = + string + |> andThen + (\str -> + case str of + "Open" -> + succeed str + + "Closed" -> + succeed str + + "Pending validation" -> + succeed str + + "Pending deployment" -> + succeed str + + "Cancelled" -> + succeed str + + "Deployed" -> + succeed str + + _ -> + fail "Invalid change request status" + ) + + +decodeChangeRequestDetails : Decoder ChangeRequestDetails +decodeChangeRequestDetails = + at [ "data" ] + (field "changeRequests" + (index 0 + (map4 + ChangeRequestDetails + (field "displayName" string) + (field "status" decodeChangeRequestStatus) + (field "id" int) + (field "description" string) + ) + ) + ) + + +encodeChangeRequestDetails : ChangeRequestDetails -> Encode.Value +encodeChangeRequestDetails changeRequestDetails = + Encode.object + [ ( "description", Encode.string changeRequestDetails.description ) + , ( "name", Encode.string changeRequestDetails.title ) + ] + + + +------------------------------ +-- UPDATE -- +------------------------------ + + +update : Msg -> Model -> ( Model, Cmd Msg ) +update msg model = + case msg of + GetChangeRequestDetails result -> + case result of + Ok changeRequestDetails -> + ( { model | changeRequest = Success changeRequestDetails, viewState = initForm changeRequestDetails }, Cmd.none ) + + Err err -> + let + errMsg = + getErrorMessage err + in + ( { model | viewState = ViewError errMsg } + , errorNotification ("Error while trying to fetch change request details: " ++ errMsg) + ) + + GetChangeRequestIdFromUrl crIdStr -> + case String.toInt crIdStr of + Just crId -> + ( model, getChangeRequestDetails model crId ) + + Nothing -> + let + errMsg = + crIdStr ++ " is not a valid change request id" + in + ( { model | viewState = ViewError errMsg }, errorNotification errMsg ) + + SetChangeRequestDetails result -> + case result of + Ok changeRequestDetails -> + ( { model | changeRequest = Success changeRequestDetails, viewState = initForm changeRequestDetails } + , successNotification "Successfully updated change request details" + ) + + Err err -> + let + errMsg = + getErrorMessage err + in + ( model, errorNotification ("Error while trying to update change request details: " ++ errMsg) ) + + FormInputName newName -> + ( model |> updateForm (setName newName), Cmd.none ) + + FormInputDescription newDescription -> + ( model |> updateForm (setDescription newDescription), Cmd.none ) + + FormSubmit -> + case model.viewState of + Form { initValues, formValues } -> + if canSaveChanges initValues formValues then + ( model, updateChangeRequestDetails model formValues ) + + else + ( model, Cmd.none ) + + _ -> + ( model, Cmd.none ) + + +initForm : ChangeRequestDetails -> ViewState +initForm cr = + Form { initValues = cr, formValues = cr } + + +updateForm : (ViewState -> ViewState) -> Model -> Model +updateForm f model = + { model | viewState = f model.viewState } + + +setDescription : String -> ViewState -> ViewState +setDescription newDescription viewState = + case viewState of + Form ({ formValues } as formState) -> + Form { formState | formValues = { formValues | description = newDescription } } + + _ -> + viewState + + +setName : String -> ViewState -> ViewState +setName newName viewState = + case viewState of + Form formState -> + let + formVal = + formState.formValues + in + Form { formState | formValues = { formVal | title = newName } } + + _ -> + viewState + + +formModified : ChangeRequestDetails -> ChangeRequestDetails -> Bool +formModified initCR formCR = + not (initCR.title == formCR.title) || not (initCR.description == formCR.description) + + +{-| In order to save the changes in the change request form, the form must have been modified +_and_ the change request title mustn't be empty | +-} +canSaveChanges : ChangeRequestDetails -> ChangeRequestDetails -> Bool +canSaveChanges initCR formCR = + formModified initCR formCR && not (String.isEmpty formCR.title) + + + +------------------------------ +-- VIEW -- +------------------------------ + + +view : Model -> Html Msg +view model = + case model.viewState of + Form formState -> + let + canEdit = + model.hasWriteRights + in + div + [ id "change-request-edit-form" ] + [ div + [ class "col-lg-6 col-xs-12 pe-3 ps-3", id "detailsForm" ] + [ form + [ class "needs-validation", id "changeRequestEditForm" ] + [ editCRName formState.formValues.title canEdit + , div + [] + [ label [] [ text "State" ] + , roInputField formState.initValues.state + ] + , div [] + [ label [] [ text "ID" ] + , roInputField (String.fromInt formState.formValues.id) + ] + , editCRDescription formState.formValues.description canEdit + , div + [] + [ saveButton formState.initValues formState.formValues canEdit ] + ] + ] + ] + + NoView -> + errorView model "Change Request Id was not set." + + ViewError errMsg -> + errorView model errMsg + + +roInputField : String -> Html Msg +roInputField content = + input + [ class "form-control col-xs-12" + , disabled True + , readonly True + , value content + ] + [] + + +editCRName : String -> Bool -> Html Msg +editCRName crName writeRights = + let + inputClass = + if crName == "" then + "form-control col-xs-12 is-invalid" + + else + "form-control col-xs-12" + + attributes = + [ name "CRName" + , type_ "text" + , value crName + , id "CRNameLabel" + ] + ++ (if writeRights then + [ class inputClass + , required True + , onInput FormInputName + ] + + else + [ class "form-control col-xs-12" + , disabled True + , readonly True + ] + ) + in + div [ id "CRName" ] + [ div + [ class "row" ] + [ label + [ for "CRNameLabel", class "col-xs-12 form-label" ] + [ span [] [ text "Change request title" ] ] + , div + [ class "needs-validation" ] + [ input attributes [] + , div + [ class "invalid-feedback" ] + [ text "The change request title cannot be empty." ] + ] + ] + ] + + +editCRDescription : String -> Bool -> Html Msg +editCRDescription crDescription writeRights = + let + inputClass = + "form-control col-xs-12" + + attributes = + [ name "CRDescription" + , id "CRDescriptionLabel" + ] + ++ (if writeRights then + [ maxlength 255 + , onInput FormInputDescription + , class inputClass + ] + + else + [ class inputClass + , disabled True + , readonly True + ] + ) + in + div + [ id "CRDescription" ] + [ div + [ class "row" ] + [ label + [ for "CRDescriptionLabel", class "col-xs-12" ] + [ span [ class "fw-normal" ] [ text "Description" ] ] + , div + [ class "col-xs-12" ] + [ textarea attributes [ text crDescription ] + ] + ] + ] + + +saveButton : ChangeRequestDetails -> ChangeRequestDetails -> Bool -> Html Msg +saveButton modelCR formCR writeRights = + if writeRights then + let + attrList = + [ value "Update" + , class "btn btn-default" + , type_ "button" + , onClick FormSubmit + , disabled <| not (canSaveChanges modelCR formCR) + ] + in + div [ id "CRSave" ] + [ input + attrList + [ text "Update" ] + ] + + else + div [ id "CRSave" ] [] + + +errorView : Model -> String -> Html Msg +errorView model errMsg = + div + [ style "padding" "40px" + , style "text-align" "center" + ] + [ h2 [] [ text "Change request id was not found" ] + , h3 [] [ text errMsg ] + , a [ href (changeRequestsPageUrl model) ] [ text "Back to change requests page" ] + ] + + + +------------------------------ +-- SUBSCRIPTIONS +------------------------------ + + +subscriptions : Model -> Sub Msg +subscriptions _ = + readUrl (\id -> GetChangeRequestIdFromUrl id) diff --git a/change-validation/src/main/elm/sources/ChangeValidationSettings.elm b/change-validation/src/main/elm/sources/ChangeValidationSettings.elm new file mode 100644 index 000000000..a294bd4b4 --- /dev/null +++ b/change-validation/src/main/elm/sources/ChangeValidationSettings.elm @@ -0,0 +1,101 @@ +module ChangeValidationSettings exposing (..) + +import ApiCalls exposing (getUsers, getValidateAllSetting) +import Browser +import DataTypes exposing (Msg(..), WorkflowUsersMsg) +import Html exposing (Html, div) +import SupervisedTargets exposing (getTargets) +import View +import WorkflowUsers + + + +------------------------------ +-- Init and main -- +------------------------------ + + +init : { contextPath : String, hasWriteRights : Bool } -> ( Model, Cmd Msg ) +init flags = + let + m = + initModel flags.contextPath flags.hasWriteRights + in + ( m, Cmd.batch [ getUsers m.workflowUsersModel, getValidateAllSetting m.workflowUsersModel, getTargets m.supervisedTargetsModel ] ) + + +main = + Browser.element + { init = init + , view = view + , update = update + , subscriptions = subscriptions + } + + +initModel : String -> Bool -> Model +initModel contextPath hasWriteRights = + { workflowUsersModel = WorkflowUsers.initModel contextPath hasWriteRights + , supervisedTargetsModel = SupervisedTargets.initModel contextPath + } + + + +------------------------------ +-- MODEL -- +------------------------------ + + +type alias Model = + { workflowUsersModel : DataTypes.Model + , supervisedTargetsModel : SupervisedTargets.Model + } + + + +------------------------------ +-- VIEW +------------------------------ + + +view : Model -> Html Msg +view model = + div [] + [ View.view model.workflowUsersModel + , SupervisedTargets.view model.supervisedTargetsModel + ] + + + +------------------------------ +-- UPDATE +------------------------------ + + +update : Msg -> Model -> ( Model, Cmd Msg ) +update msg model = + case msg of + WorkflowUsersMsg wuMsg -> + let + ( wuModel, wuCmd ) = + WorkflowUsers.update wuMsg model.workflowUsersModel + in + ( { model | workflowUsersModel = wuModel }, wuCmd ) + + SupervisedTargetsMsg stMsg -> + let + ( stModel, stCmd ) = + SupervisedTargets.update stMsg model.supervisedTargetsModel + in + ( { model | supervisedTargetsModel = stModel }, stCmd ) + + + +------------------------------ +-- SUBSCRIPTIONS +------------------------------ + + +subscriptions : Model -> Sub Msg +subscriptions _ = + Sub.none diff --git a/change-validation/src/main/elm/sources/DataTypes.elm b/change-validation/src/main/elm/sources/DataTypes.elm index 916002150..29784cc40 100644 --- a/change-validation/src/main/elm/sources/DataTypes.elm +++ b/change-validation/src/main/elm/sources/DataTypes.elm @@ -4,56 +4,141 @@ import Http exposing (Error) import List exposing (map) import Result exposing (Result) -type alias UserList = List User -type alias Username = String -type alias ApiMsg = String + +type alias UserList = + List User + + +type alias Username = + String + + +type alias ApiMsg = + String + type EditMod - = On - | Off + = On + | Off + + {-- Left : validated users Right : unvalidated users --} + + type ColPos - = Left - | Right + = Left + | Right + type alias User = - { username : Username - , isValidated : Bool - , isInFile : Bool - } + { username : Username + , isValidated : Bool + , isInFile : Bool + } + type alias Model = - { contextPath : String - , users : UserList - , validatedUsers : UserList - , unvalidatedUsers : UserList - , rightChecked : List User - , leftChecked : List User - , hasMoved : List User -- Too track updates - , editMod : EditMod - } + { contextPath : String + , editMod : EditMod + , workflowUsersView : WorkflowUsersView + , hasWriteRights : Bool + , validateAllView : ValidateAllView + } + + +type alias WorkflowUsersForm = + { users : UserList + , validatedUsers : UserList + , unvalidatedUsers : UserList + , rightChecked : UserList + , leftChecked : UserList + , hasMoved : UserList -- To track updates + } + + +type WorkflowUsersView + = WorkflowUsersInitView + | WorkflowUsers WorkflowUsersForm + + +type UserListField + = Users + | ValidatedUsers + | UnvalidatedUsers + | RightChecked + | LeftChecked + | HasMoved + + +type ValidateAllView + = ValidateAllInitView + | ValidateAll { initValues : FormState, formValues : FormState } + + +type alias FormState = + { validateAll : Bool -- "enable_validate_all" setting + } + getUsernames : UserList -> List Username -getUsernames users = map .username users +getUsernames users = + map .username users + + +type WorkflowUsersMsg + = {--Messages for the "Workflow Users" table --} + {--API CALLS --} + GetUsers (Result Error UserList) + | RemoveUser (Result Error Username) + | SaveWorkflow (Result Error UserList) + | CallApi (Model -> Cmd Msg) + {--TABLE MANAGE CONTENT --} + | LeftToRight + | RightToLeft + | AddLeftChecked User Bool + | AddRightChecked User Bool + | CheckAll ColPos Bool + {--MOD MANAGEMENT --} + | SwitchMode + | ExitEditMod + {--Messages for the "Validate all changes" checkbox and button --} + {--API CALLS--} + | GetValidateAllSetting (Result Error Bool) + | SaveValidateAllSetting (Result Error Bool) + {--VIEW UPDATE--} + | ChangeValidateAllSetting Bool + type Msg - = - {-- API CALLS --} - GetUsers (Result Error UserList) - | RemoveUser (Result Error Username) - | SaveWorkflow (Result Error UserList) - | CallApi (Model -> Cmd Msg) - {-- TABLE MANAGE CONTENT --} - | LeftToRight - | RightToLeft - | AddLeftChecked User Bool - | AddRightChecked User Bool - | CheckAll ColPos Bool - {-- MOD MANAGEMENT --} - | SwitchMode - | ExitEditMod + = WorkflowUsersMsg WorkflowUsersMsg + | SupervisedTargetsMsg SupervisedTargetsMsg + + +type SupervisedTargetsMsg + = GetTargets (Result Error Category) + | SaveTargets (Result Error String) -- here the string is just the status message + | SendSave + | UpdateTarget Target + + +type alias Target = + { id : String -- id + , name : String -- display name of the rule target + , description : String -- description + , supervised : Bool -- do you want to validate CR targeting that rule target + } + + +type alias Category = + { name : String -- name of the category + , categories : Subcategories -- sub-categories + , targets : List Target -- targets in category + } + +type Subcategories + = Subcategories (List Category) -- needed because no recursive type alias support diff --git a/change-validation/src/main/elm/sources/ErrorMessages.elm b/change-validation/src/main/elm/sources/ErrorMessages.elm new file mode 100644 index 000000000..81fe1a4a3 --- /dev/null +++ b/change-validation/src/main/elm/sources/ErrorMessages.elm @@ -0,0 +1,26 @@ +module ErrorMessages exposing (..) + +import Http + + +getErrorMessage : Http.Error -> String +getErrorMessage e = + let + errMessage = + case e of + Http.BadStatus status -> + "Code " ++ String.fromInt status + + Http.BadUrl str -> + "Invalid API url" + + Http.Timeout -> + "It took too long to get a response" + + Http.NetworkError -> + "Network error" + + Http.BadBody c -> + "Wrong content in request body" ++ c + in + errMessage diff --git a/change-validation/src/main/elm/sources/Init.elm b/change-validation/src/main/elm/sources/Init.elm deleted file mode 100644 index 428de21b4..000000000 --- a/change-validation/src/main/elm/sources/Init.elm +++ /dev/null @@ -1,49 +0,0 @@ -port module Init exposing (..) - -import DataTypes exposing (EditMod(..), Model, Msg(..)) -import Http - ------------------------------- --- PORTS ------------------------------- - - -port successNotification : String -> Cmd msg -port errorNotification : String -> Cmd msg - - - ------------------------------- --- SUBSCRIPTIONS ------------------------------- - - -subscriptions : Model -> Sub Msg -subscriptions _ = - Sub.none - -initModel : String -> Model -initModel contextPath = - Model contextPath [] [] [] [] [] [] Off - -getErrorMessage : Http.Error -> String -getErrorMessage e = - let - errMessage = - case e of - Http.BadStatus status -> - "Code " ++ String.fromInt status - - Http.BadUrl str -> - "Invalid API url" - - Http.Timeout -> - "It took too long to get a response" - - Http.NetworkError -> - "Network error" - Http.BadBody c-> - "Wrong content in request body" ++ c - - in - errMessage \ No newline at end of file diff --git a/change-validation/src/main/elm/sources/JsonDecoders.elm b/change-validation/src/main/elm/sources/JsonDecoders.elm index d53380b44..5eed72276 100644 --- a/change-validation/src/main/elm/sources/JsonDecoders.elm +++ b/change-validation/src/main/elm/sources/JsonDecoders.elm @@ -1,20 +1,32 @@ module JsonDecoders exposing (..) import DataTypes exposing (ApiMsg, User, UserList, Username) -import Json.Decode exposing (Decoder, at, bool, list, string, succeed) +import Json.Decode exposing (Decoder, at, bool, field, list, string, succeed) import Json.Decode.Pipeline exposing (required) + decodeUser : Decoder User decodeUser = - succeed User - |> required "username" string - |> required "isValidated" bool - |> required "userExists" bool + succeed User + |> required "username" string + |> required "isValidated" bool + |> required "userExists" bool + decodeUserList : Decoder UserList decodeUserList = - at [ "data" ] (list decodeUser) + at [ "data" ] (list decodeUser) + decodeApiDeleteUsername : Decoder Username decodeApiDeleteUsername = - at [ "data" ] string + at [ "data" ] string + + +decodeSetting : String -> Decoder Bool +decodeSetting fieldName = + let + decSetting = + field "settings" (field fieldName bool) + in + at [ "data" ] decSetting diff --git a/change-validation/src/main/elm/sources/JsonEncoders.elm b/change-validation/src/main/elm/sources/JsonEncoders.elm index d6cafb010..e5d415873 100644 --- a/change-validation/src/main/elm/sources/JsonEncoders.elm +++ b/change-validation/src/main/elm/sources/JsonEncoders.elm @@ -3,29 +3,39 @@ module JsonEncoders exposing (..) import DataTypes exposing (User, Username) import Json.Encode exposing (Value, bool, list, object, string) -username : Username -> (String, Value) + +username : Username -> ( String, Value ) username value = - ("username", string value) + ( "username", string value ) + -isValidated : Bool -> (String, Value) +isValidated : Bool -> ( String, Value ) isValidated value = - ("isValidated", bool value) + ( "isValidated", bool value ) -isInFile : Bool -> (String, Value) + +isInFile : Bool -> ( String, Value ) isInFile value = - ("userExists", bool value) + ( "userExists", bool value ) + encodeUser : User -> Value encodeUser userInfo = - object - [ username userInfo.username - , isValidated userInfo.isValidated - , isInFile userInfo.isInFile - ] + object + [ username userInfo.username + , isValidated userInfo.isValidated + , isInFile userInfo.isInFile + ] + encodeUsernames : List Username -> Value encodeUsernames usernames = - object - [ ("action", string "addValidatedUsersList") - , ("validatedUsers", list (\s -> string s) usernames) - ] + object + [ ( "action", string "addValidatedUsersList" ) + , ( "validatedUsers", list (\s -> string s) usernames ) + ] + + +encodeSetting : Bool -> Value +encodeSetting value = + object [ ( "value", bool value ) ] diff --git a/change-validation/src/main/elm/sources/Ports.elm b/change-validation/src/main/elm/sources/Ports.elm new file mode 100644 index 000000000..4a0cc7a1d --- /dev/null +++ b/change-validation/src/main/elm/sources/Ports.elm @@ -0,0 +1,14 @@ +port module Ports exposing (..) + +------------------------------ +-- PORTS +------------------------------ + + +port successNotification : String -> Cmd msg + + +port errorNotification : String -> Cmd msg + + +port readUrl : (String -> msg) -> Sub msg diff --git a/change-validation/src/main/elm/sources/SupervisedTargets.elm b/change-validation/src/main/elm/sources/SupervisedTargets.elm index f9247984e..8a2dec062 100644 --- a/change-validation/src/main/elm/sources/SupervisedTargets.elm +++ b/change-validation/src/main/elm/sources/SupervisedTargets.elm @@ -1,27 +1,17 @@ -module SupervisedTargets exposing (Category, Model, Msg(..), Subcategories(..), Target, alphanumericRegex, decodeApiCategory, decodeApiSave, decodeCategory, decodeSubcategories, decodeTarget, displayCategory, displaySubcategories, displayTarget, encodeTargets, getSupervisedIds, getTargets, init, isAlphanumeric, main, saveTargets, subscriptions, update, updateTarget, view) +module SupervisedTargets exposing (Model, alphanumericRegex, decodeApiCategory, decodeApiSave, decodeCategory, decodeSubcategories, decodeTarget, displayCategory, displaySubcategories, displayTarget, encodeTargets, getSupervisedIds, getTargets, initModel, isAlphanumeric, saveTargets, update, updateTarget, view) +import DataTypes exposing (Category, Msg, Subcategories(..), SupervisedTargetsMsg(..), Target) +import ErrorMessages exposing (getErrorMessage) import Html exposing (..) -import Browser -import Html.Attributes exposing (checked, class, type_) +import Html.Attributes exposing (checked, class, id, type_) import Html.Events exposing (..) import Http exposing (..) import Json.Decode as D exposing (Decoder) import Json.Decode.Pipeline exposing (..) import Json.Encode as E +import Ports exposing (errorNotification, successNotification) import Regex import String -import Init exposing (errorNotification, successNotification, getErrorMessage) - - - ------------------------------- --- SUBSCRIPTIONS ------------------------------- - - -subscriptions : Model -> Sub Msg -subscriptions model = - Sub.none @@ -30,24 +20,9 @@ subscriptions model = ------------------------------ -init : { contextPath : String } -> ( Model, Cmd Msg ) -init flags = - let - initModel = - Model flags.contextPath (Category "waiting for server data..." (Subcategories []) []) - in - ( initModel - , getTargets initModel - ) - - -main = - Browser.element - { init = init - , view = view - , update = update - , subscriptions = subscriptions - } +initModel : String -> Model +initModel contextPath = + Model contextPath (Category "waiting for server data..." (Subcategories []) []) @@ -56,40 +31,14 @@ main = ------------------------------ -type alias Target = - { id : String -- id - , name : String -- display name of the rule target - , description : String -- description - , supervised : Bool -- do you want to validate CR targeting that rule target - } - - -type alias Category = - { name : String -- name of the category - , categories : Subcategories -- sub-categories - , targets : List Target -- targets in category - } - - -type Subcategories - = Subcategories (List Category) -- needed because no recursive type alias support - - type alias Model = { contextPath : String , allTargets : Category -- from API } -type Msg - = GetTargets (Result Error Category) - | SaveTargets (Result Error String) -- here the string is just the status message - | SendSave - | UpdateTarget Target - -- NOTIFICATIONS - - +-- NOTIFICATIONS ------------------------------ -- API -- ------------------------------ @@ -108,15 +57,15 @@ getTargets model = req = request { method = "GET" - , headers = [Http.header "X-Requested-With" "XMLHttpRequest"] + , headers = [ Http.header "X-Requested-With" "XMLHttpRequest" ] , url = url , body = emptyBody - , expect = expectJson GetTargets decodeApiCategory + , expect = expectJson (DataTypes.SupervisedTargetsMsg << GetTargets) decodeApiCategory , timeout = Nothing , tracker = Nothing } in - req + req @@ -129,10 +78,10 @@ saveTargets model = req = request { method = "POST" - , headers = [Http.header "X-Requested-With" "XMLHttpRequest"] + , headers = [ Http.header "X-Requested-With" "XMLHttpRequest" ] , url = model.contextPath ++ "/secure/api/changevalidation/supervised/targets" , body = jsonBody (encodeTargets (getSupervisedIds model.allTargets)) - , expect = expectJson SaveTargets decodeApiSave + , expect = expectJson (SaveTargets >> DataTypes.SupervisedTargetsMsg) decodeApiSave , timeout = Nothing , tracker = Nothing } @@ -210,7 +159,7 @@ decodeTarget = encodeTargets : List String -> E.Value encodeTargets targets = - E.object [ ( "supervised", E.list (\s -> E.string s) targets ) ] + E.object [ ( "supervised", E.list (\s -> E.string s) targets ) ] @@ -219,7 +168,7 @@ encodeTargets targets = ------------------------------ -update : Msg -> Model -> ( Model, Cmd Msg ) +update : SupervisedTargetsMsg -> Model -> ( Model, Cmd Msg ) update msg model = case msg of {--Api Calls message --} @@ -289,13 +238,52 @@ updateTarget target cat = view : Model -> Html Msg view model = - div [] - [ div [ class "row" ] - [ div [ class "col-xs-12" ] - [ displayCategory model.allTargets - , div [ class "card-footer" ] [ button [ onClick SendSave, class "btn btn-success right" ] [ text "Save" ] ] + div [ id "supervisedTargets" ] + [ h3 [ class "page-subtitle" ] + [ text "Configure groups with change validations" ] + , div [ class "section-with-doc" ] + [ div [ class "section-left" ] + [ div [ id "supervised-targets-app" ] + [ div [ id "list-groups-change-validation" ] + [ div [ class "row" ] + [ div [ class "col-xs-12" ] + [ displayCategory model.allTargets + , div [ class "card-footer" ] [ button [ onClick (SendSave |> DataTypes.SupervisedTargetsMsg), class "btn btn-success right" ] [ text "Save" ] ] + ] + ] + ] + ] ] + , supervisedTargetsInfoSection + ] + ] + + +supervisedTargetsInfoSection : Html Msg +supervisedTargetsInfoSection = + createRightInfoSection + [ p [] + [ text " Change validation are enable for " + , b [] [ text "any" ] + , text " change that would impact a node belonging to one of the chosen groups below. Be careful: a change on one another group " + ] + , p [] [ text " The supervised changes are: " ] + , ul [] + [ li [] [ text "any change in a global parameter, as these changes can have side effects spreading technique code," ] + , li [] [ text "any modification in one of the supervised groups," ] + , li [] [ text "any change in a rule which targets a node which belong to a group marked as supervised, " ] + , li [] [ text "any change in a directive used in one of the previous rules." ] ] + , p [] [] + , p [] [ text " Changes in techniques are not subjected to change validation, nor are changes resulting from an archive import. " ] + ] + + +createRightInfoSection : List (Html Msg) -> Html Msg +createRightInfoSection contents = + div [ class "section-right" ] + [ div [ class "doc doc-info" ] + (div [ class "marker" ] [ span [ class "fa fa-info-circle" ] [] ] :: contents) ] @@ -359,15 +347,16 @@ displayTarget target = [ input [ type_ "checkbox" , checked target.supervised - , onClick (UpdateTarget { target | supervised = not target.supervised }) + , onClick (DataTypes.SupervisedTargetsMsg (UpdateTarget { target | supervised = not target.supervised })) ] [] - , span [ class "ion ion-checkmark-round" ] [] + , span [ class "fa fa-check" ] [] ] ] ] + ------------------------------ -- HELPERS ------------------------------ diff --git a/change-validation/src/main/elm/sources/View.elm b/change-validation/src/main/elm/sources/View.elm index 257081fa0..41c2ef55f 100644 --- a/change-validation/src/main/elm/sources/View.elm +++ b/change-validation/src/main/elm/sources/View.elm @@ -1,99 +1,127 @@ module View exposing (..) -import ApiCalls exposing (getUsers, saveWorkflow) -import DataTypes exposing (ColPos(..), EditMod(..), Model, Msg(..), User, UserList, Username, getUsernames) +import ApiCalls exposing (getUsers, saveValidateAllSetting, saveWorkflow) +import DataTypes exposing (ColPos(..), EditMod(..), Model, Msg(..), User, UserList, Username, ValidateAllView(..), WorkflowUsersForm, WorkflowUsersMsg(..), WorkflowUsersView(..), getUsernames) import Html exposing (..) -import Html.Attributes exposing (attribute, checked, class, disabled, id, style, type_, value) +import Html.Attributes exposing (attribute, checked, class, disabled, for, id, style, type_, value) import Html.Events exposing (onCheck, onClick) import List exposing (isEmpty, length, member) import String exposing (fromInt) -createInfoTootlip : String -> String -> Html msg + +type alias WorkflowUsersMsg = + DataTypes.WorkflowUsersMsg + + +createInfoTootlip : String -> String -> Html Msg createInfoTootlip content placement = - span - [ - class "fa fa-exclamation-triangle center-box-element input-icon bstool" - , attribute "data-bs-toggle" "tooltip" - , attribute "data-bs-placement" placement - , attribute "title" content - ][] + span + [ class "fa fa-exclamation-triangle center-box-element input-icon bstool" + , attribute "data-bs-toggle" "tooltip" + , attribute "data-bs-placement" placement + , attribute "title" content + ] + [] + + +createRightInfoSectionParagraphs : List String -> Html Msg +createRightInfoSectionParagraphs paragraphs = + let + paragraphElements = + List.map (\paragraphContent -> p [] [ text paragraphContent ]) paragraphs + in + div [ class "section-right" ] + [ div [ class "doc doc-info" ] + ([ div [ class "marker" ] [ span [ class "fa fa-info-circle" ] [] ] ] ++ paragraphElements) + ] + + -- Displayed nb items and nb selected items when edition mod is On -displayFooter: Model -> ColPos -> Html Msg -displayFooter model pos = - let - isChecked = - case pos of - Left -> - (length model.leftChecked == length model.validatedUsers) && not (isEmpty model.leftChecked) - Right -> - (length model.rightChecked == length model.unvalidatedUsers) && not (isEmpty model.rightChecked) - in - div [class "box-footer"] - [ - label [class "all-items"] - [ - input - [ - class "" - , onCheck (\checkStatus-> CheckAll pos checkStatus) - , type_ "checkbox" - , value - ( - case pos of - Left -> - fromInt (length model.validatedUsers) - Right -> - fromInt (length model.unvalidatedUsers) - ) - , checked (isChecked) - , disabled - ( + + +displayFooter : WorkflowUsersForm -> ColPos -> Html Msg +displayFooter workflowUsersForm pos = + let + isChecked = case pos of - Left -> - isEmpty model.validatedUsers - Right -> - isEmpty model.unvalidatedUsers - ) - ] [] - ], - div [id "nb-items", class "footer-infos nb-items"] - [ - case pos of - Left -> text (( fromInt (length model.validatedUsers)) ++ " Items") - Right -> text (( fromInt (length model.unvalidatedUsers)) ++ " Items") - ], - div [id "nb-selected", class "footer-infos"] - [ - case pos of - Left -> - if (length model.leftChecked == length model.validatedUsers) && (not (isEmpty model.leftChecked)) then - text "All Selected" - else - text ((fromInt (length model.leftChecked)) ++ " Selected") - Right -> - if (length model.rightChecked == length model.unvalidatedUsers) && (not (isEmpty model.rightChecked)) then - text "All Selected" - else - text ((fromInt (length model.rightChecked)) ++ " Selected") - ] - ] + Left -> + (length workflowUsersForm.leftChecked == length workflowUsersForm.validatedUsers) && not (isEmpty workflowUsersForm.leftChecked) + + Right -> + (length workflowUsersForm.rightChecked == length workflowUsersForm.unvalidatedUsers) && not (isEmpty workflowUsersForm.rightChecked) + in + div [ class "box-footer" ] + [ label [ class "all-items" ] + [ input + [ class "" + , onCheck (\checkStatus -> WorkflowUsersMsg (CheckAll pos checkStatus)) + , type_ "checkbox" + , value + (case pos of + Left -> + fromInt (length workflowUsersForm.validatedUsers) + + Right -> + fromInt (length workflowUsersForm.unvalidatedUsers) + ) + , checked isChecked + , disabled + (case pos of + Left -> + isEmpty workflowUsersForm.validatedUsers + + Right -> + isEmpty workflowUsersForm.unvalidatedUsers + ) + ] + [] + ] + , div [ id "nb-items", class "footer-infos nb-items" ] + [ case pos of + Left -> + text (fromInt (length workflowUsersForm.validatedUsers) ++ " Items") + + Right -> + text (fromInt (length workflowUsersForm.unvalidatedUsers) ++ " Items") + ] + , div [ id "nb-selected", class "footer-infos" ] + [ case pos of + Left -> + if (length workflowUsersForm.leftChecked == length workflowUsersForm.validatedUsers) && not (isEmpty workflowUsersForm.leftChecked) then + text "All Selected" + + else + text (fromInt (length workflowUsersForm.leftChecked) ++ " Selected") + + Right -> + if (length workflowUsersForm.rightChecked == length workflowUsersForm.unvalidatedUsers) && not (isEmpty workflowUsersForm.rightChecked) then + text "All Selected" + + else + text (fromInt (length workflowUsersForm.rightChecked) ++ " Selected") + ] + ] + + {-- The box containing all users in the system who are not validated Displayed when edition mod is On --} -displayRightCol : Model -> Html Msg -displayRightCol model = - div [ class "box-users-container"] - [ - h5 [ class "box-header" ] [ b [][ text "Users" ]], - div [ class "box-users-content" ] - [ - renderUsers model.unvalidatedUsers Right model - ], - displayFooter model Right - ] + + +displayRightCol : WorkflowUsersForm -> EditMod -> Html Msg +displayRightCol workflowUsersForm editMod = + div [ class "box-users-container" ] + [ h5 [ class "box-header" ] [ b [] [ text "Users" ] ] + , div [ class "box-users-content" ] + [ renderUsers workflowUsersForm.unvalidatedUsers Right editMod workflowUsersForm + ] + , displayFooter workflowUsersForm Right + ] + + {-- Helper function to display individual user's row @@ -105,105 +133,131 @@ displayRightCol model = On edition mod "On" interactions are activated --} -renderUserHelper : User -> ColPos -> Model -> Html Msg -renderUserHelper user pos model = - let - isEditActivate = if model.editMod == On then True else False - sideChecked = - case pos of - Left -> AddLeftChecked user ( not (member user model.leftChecked)) - Right -> AddRightChecked user ( not (member user model.rightChecked)) - isChecked = - case pos of - Left -> member user model.leftChecked - Right -> member user model.rightChecked - content = - li[ class "li-box-content-user" ] - [ - label [ style "vertical-align" "middle", style "display" "inline-block" ] - [ - if isEditActivate then - input - [ - class "box-input-element center-box-element" - , type_ "checkbox" - , value user.username - , checked isChecked - ] [] - else - div [class "box-input-element center-box-element"] [] - ] - , div[ class "center-box-element" ][ text <| user.username ], - if not user.isInFile then - createInfoTootlip - """ + + +renderUserHelper : User -> ColPos -> EditMod -> WorkflowUsersForm -> Html Msg +renderUserHelper user pos editMod workflowUsersForm = + let + isEditActivate = + if editMod == On then + True + + else + False + + sideChecked = + case pos of + Left -> + WorkflowUsersMsg (AddLeftChecked user (not (member user workflowUsersForm.leftChecked))) + + Right -> + WorkflowUsersMsg (AddRightChecked user (not (member user workflowUsersForm.rightChecked))) + + isChecked = + case pos of + Left -> + member user workflowUsersForm.leftChecked + + Right -> + member user workflowUsersForm.rightChecked + + content = + li [ class "li-box-content-user" ] + [ label [ style "vertical-align" "middle", style "display" "inline-block" ] + [ if isEditActivate then + input + [ class "box-input-element center-box-element" + , type_ "checkbox" + , value user.username + , checked isChecked + ] + [] + + else + div [ class "box-input-element center-box-element" ] [] + ] + , div [ class "center-box-element" ] [ text <| user.username ] + , if not user.isInFile then + createInfoTootlip + """ The user doesn't exist anymore but they are still a validated user, delete them by removing them from the validated users. """ - "auto" - else - div [][] - ] - in + "auto" + + else + div [] [] + ] + in if isEditActivate then - if member user model.hasMoved then - if isChecked then - div [class "users moved-checked", onClick sideChecked ] [ content ] - else - div [ id "moved-user", class "users-clickable", onClick sideChecked ] [ content ] - else if user.isInFile then - if isChecked then - div [class "users normal-checked", onClick sideChecked ] [ content ] - else - div [ id "normal-user", class "users-clickable", onClick sideChecked ] [ content ] - else - if isChecked then - div [class "users not-in-file-checked", onClick sideChecked ] [ content ] + if member user workflowUsersForm.hasMoved then + if isChecked then + div [ class "users moved-checked", onClick sideChecked ] [ content ] + + else + div [ id "moved-user", class "users-clickable", onClick sideChecked ] [ content ] + + else if user.isInFile then + if isChecked then + div [ class "users normal-checked", onClick sideChecked ] [ content ] + + else + div [ id "normal-user", class "users-clickable", onClick sideChecked ] [ content ] + + else if isChecked then + div [ class "users not-in-file-checked", onClick sideChecked ] [ content ] + else - div [ id "not-in-file-user", class "users-clickable", onClick sideChecked ] [ content ] - -- deactivate the ability to select users in edition mod "Off" + div [ id "not-in-file-user", class "users-clickable", onClick sideChecked ] [ content ] + -- deactivate the ability to select users in edition mod "Off" + + else if user.isInFile then + div [ id "normal-user", class "users" ] [ content ] + else - if user.isInFile then - div [ id "normal-user", class "users"] [ content ] - else - div [ id "not-in-file-user", class "users"] [ content ] + div [ id "not-in-file-user", class "users" ] [ content ] + + {-- Display all users according to the box they belong Left -> validated Right -> unvalidated --} -renderUsers : UserList -> ColPos -> Model -> Html Msg -renderUsers users pos model = - ul [ ] (List.map (\u -> renderUserHelper u pos model) users) - - -displayArrows : Model -> Html Msg -displayArrows model = - let - leftArrowBtnType = - if (isEmpty model.rightChecked) then - "btn btn-sm move-left btn-default" - else - "btn btn-sm move-left btn-primary" - rightArrowBtnType = - if (isEmpty model.leftChecked) then - "btn btn-sm move-right btn-default" - else - "btn btn-sm move-right btn-primary" - in - div [ class "list-arrows arrows-validation"] - [ - button [ onClick LeftToRight, class rightArrowBtnType, disabled (isEmpty model.leftChecked) ] - [ - span [ class "fa fa-chevron-right" ][] - ], - br [] [], - br [] [], - button [ onClick RightToLeft,class leftArrowBtnType, disabled (isEmpty model.rightChecked)] - [ - span [ class "fa fa-chevron-left" ] [] + + +renderUsers : UserList -> ColPos -> EditMod -> WorkflowUsersForm -> Html Msg +renderUsers users pos editMod workflowUsersForm = + ul [] (List.map (\u -> renderUserHelper u pos editMod workflowUsersForm) users) + + +displayArrows : WorkflowUsersForm -> Html Msg +displayArrows workflowUsersForm = + let + leftArrowBtnType = + if isEmpty workflowUsersForm.rightChecked then + "btn btn-sm move-left btn-default" + + else + "btn btn-sm move-left btn-primary" + + rightArrowBtnType = + if isEmpty workflowUsersForm.leftChecked then + "btn btn-sm move-right btn-default" + + else + "btn btn-sm move-right btn-primary" + in + div [ class "list-arrows arrows-validation" ] + [ button [ onClick (LeftToRight |> WorkflowUsersMsg), class rightArrowBtnType, disabled (isEmpty workflowUsersForm.leftChecked) ] + [ span [ class "fa fa-chevron-right" ] [] + ] + , br [] [] + , br [] [] + , button [ onClick (RightToLeft |> WorkflowUsersMsg), class leftArrowBtnType, disabled (isEmpty workflowUsersForm.rightChecked) ] + [ span [ class "fa fa-chevron-left" ] [] + ] ] - ] + {-- @@ -214,96 +268,187 @@ displayArrows model = The footer is displayed only in edition mod --} -displayLeftCol : Model -> Html Msg -displayLeftCol model = - let - cancelType = - if model.editMod == On && (isEmpty model.hasMoved) then - ExitEditMod - else - CallApi getUsers - actnBtnIfModif = - if model.editMod == Off then - div [] [] - else - div [] - [ - button - [ - id "cancel-workflow" - , class "btn btn-default btn-action-workflow" - , onClick cancelType - , type_ "button" - ] [text <| if cancelType == ExitEditMod then "Exit" else "Cancel"] - , - if not (isEmpty model.hasMoved) then - button - [ - id "save-workflow " - , class "btn btn-success btn-action-workflow" - , onClick (CallApi (saveWorkflow (getUsernames model.validatedUsers))) - , type_ "button" - ] [text <| "Save"] - else - div [] [] - ] - in + + +displayLeftCol : WorkflowUsersForm -> EditMod -> Html Msg +displayLeftCol workflowUsersForm editMod = + let + cancelType = + if editMod == On && isEmpty workflowUsersForm.hasMoved then + ExitEditMod |> WorkflowUsersMsg + + else + CallApi getUsers |> WorkflowUsersMsg + + actnBtnIfModif = + if editMod == Off then + div [] [] + + else + div [] + [ button + [ id "cancel-workflow" + , class "btn btn-default btn-action-workflow" + , onClick cancelType + , type_ "button" + ] + [ text <| + if cancelType == (ExitEditMod |> WorkflowUsersMsg) then + "Exit" + + else + "Cancel" + ] + , if not (isEmpty workflowUsersForm.hasMoved) then + button + [ id "save-workflow " + , class "btn btn-success btn-action-workflow" + , onClick (CallApi (saveWorkflow (getUsernames workflowUsersForm.validatedUsers)) |> WorkflowUsersMsg) + , type_ "button" + ] + [ text <| "Save" ] + + else + div [] [] + ] + in div [] - [ - div [ class "box-users-container "] - [ - h5 [class "box-header"] [ b [][text "Validated users"]], - div [class "box-users-content"] - [ - if isEmpty model.validatedUsers then - div [style "text-align" "center"] - [ - if model.editMod == Off then - i [class "fa fa-user-times empty-validated-user", style "margin-bottom" "10px"] - [ - br [][], - p [class "empty-box-msg"] [text "No validated users found"] + [ div [ class "box-users-container " ] + [ h5 [ class "box-header" ] [ b [] [ text "Validated users" ] ] + , div [ class "box-users-content" ] + [ if isEmpty workflowUsersForm.validatedUsers then + div [ style "text-align" "center" ] + [ if editMod == Off then + i [ class "fa fa-user-times empty-validated-user", style "margin-bottom" "10px" ] + [ br [] [] + , p [ class "empty-box-msg" ] [ text "No validated users found" ] + ] + + else + div [] [] + ] + + else + renderUsers workflowUsersForm.validatedUsers Left editMod workflowUsersForm ] - else - div [] [] + , case editMod of + On -> + div [] [] + + Off -> + div [ class "circle-edit", onClick (SwitchMode |> WorkflowUsersMsg) ] + [ i [ class "edit-icon-validated-user fa fa-pencil", style "margin" "0" ] [] + ] ] - else - renderUsers model.validatedUsers Left model - ], - case model.editMod of - On -> - div [] [] - Off -> - div [class "circle-edit", onClick SwitchMode] - [ - i [class "edit-icon-validated-user fa fa-pencil", style "margin" "0"] [] + , case editMod of + On -> + displayFooter workflowUsersForm Left + + Off -> + div [] [] + , div [ class "action-button-container" ] + [ actnBtnIfModif ] - ], - case model.editMod of - On -> displayFooter model Left - Off -> div [][] - , - div [class "action-button-container"] - [ - actnBtnIfModif - ] - ] + ] + view : Model -> Html Msg view model = - div [] - [ - case model.editMod of - On -> - div [class "inner-portlet" ,style "display" "flex", style "justify-content" "center"] - [ - displayLeftCol model - , displayArrows model - , displayRightCol model - ] - Off -> - div [class "inner-portlet" ,style "display" "flex", style "justify-content" "center"] - [ - displayLeftCol model - ] - ] \ No newline at end of file + let + validateAllForm = + if model.hasWriteRights then + [ Html.br [] [], displayValidateAllForm model ] + + else + [] + in + let + workflowUsers = + case model.workflowUsersView of + WorkflowUsersInitView -> + text "" + + WorkflowUsers workflowUsersForm -> + div [ class "section-with-doc" ] + [ div [ class "section-left" ] + [ div + [] + [ case model.editMod of + On -> + div [ class "inner-portlet", style "display" "flex", style "justify-content" "center", id "workflowUsers" ] + [ displayLeftCol workflowUsersForm On + , displayArrows workflowUsersForm + , displayRightCol workflowUsersForm On + ] + + Off -> + div [ class "inner-portlet", style "display" "flex", style "justify-content" "center" ] + [ displayLeftCol workflowUsersForm Off + ] + ] + ] + , createRightInfoSectionParagraphs + [ " Any modification made by a validated user will be automatically deployed, " + ++ "without needing to be validated by another user first. " + ] + ] + in + div + [ id "workflowUsers" ] + (workflowUsers :: validateAllForm) + + +displayValidateAllForm : Model -> Html Msg +displayValidateAllForm model = + case model.validateAllView of + ValidateAll formState -> + div + [ class "section-with-doc" ] + [ div [ class "section-left" ] + [ form [] + [ ul [] + [ li + [ class "rudder-form" ] + [ div [ class "input-group" ] + [ label + [ class "input-group-addon" + , for "validationAutoValidatedUser" + ] + [ input + [ type_ "checkbox" + , id "validationAutoValidatedUser" + , checked formState.formValues.validateAll + , onClick (WorkflowUsersMsg (ChangeValidateAllSetting (not formState.formValues.validateAll))) + ] + [] + , label + [ for "validationAutoValidatedUser", class "label-radio" ] + [ span [ class "fa fa-check" ] [] ] + , span [ class "fa fa-check check-icon" ] [] + ] + , label + [ class "form-control", for "validationAutoValidatedUser" ] + [ text " Validate all changes " ] + ] + ] + ] + , input + [ type_ "button" + , value "Save change" + , id "validationAutoSubmit" + , class "btn btn-default" + , disabled (formState.formValues.validateAll == formState.initValues.validateAll) + , onClick (WorkflowUsersMsg (CallApi (saveValidateAllSetting formState.formValues.validateAll))) + ] + [] + ] + ] + , createRightInfoSectionParagraphs + [ " Any modification made by a validated user will be automatically approved no matter the nature of the change. " + , " Hence, configuring the groups below will have no effect on validated users (in the list above), but will apply" + ++ " to non-validated users, who will still need to create a change request in order to modify a node from a supervised group. " + ] + ] + + _ -> + text "" diff --git a/change-validation/src/main/elm/sources/WorkflowInformation.elm b/change-validation/src/main/elm/sources/WorkflowInformation.elm new file mode 100644 index 000000000..12f8aa632 --- /dev/null +++ b/change-validation/src/main/elm/sources/WorkflowInformation.elm @@ -0,0 +1,314 @@ +module WorkflowInformation exposing (..) + +import Browser +import ErrorMessages exposing (getErrorMessage) +import Html exposing (Html, a, i, li, span, text, ul) +import Html.Attributes as Attr +import Http exposing (Error, emptyBody, expectJson, header, request) +import Json.Decode exposing (Decoder, at, bool, field, index, int, list, map2, maybe) +import Ports exposing (errorNotification) + + + +------------------------------ +-- Init and main -- +------------------------------ + + +getApiUrl : Model -> String -> String +getApiUrl m url = + m.contextPath ++ "/secure/api/" ++ url + + +getPendingValidationUrl : Model -> String +getPendingValidationUrl m = + m.contextPath ++ "/secure/configurationManager/changes/changeRequests/Pending_validation" + + +getPendingDeploymentUrl : Model -> String +getPendingDeploymentUrl m = + m.contextPath ++ "/secure/configurationManager/changes/changeRequests/Pending_deployment" + + +init : { contextPath : String } -> ( Model, Cmd Msg ) +init flags = + let + initModel = + Model flags.contextPath NotSet + in + ( initModel, getWorkflowEnabledSetting initModel ) + + +main = + Browser.element + { init = init + , view = view + , update = update + , subscriptions = subscriptions + } + + + +------------------------------ +-- MODEL -- +------------------------------ + + +type alias PendingCount = + { pendingValidation : Maybe Int + , pendingDeployment : Maybe Int + , totalCount : Int + } + + +type PendingCountOpt + = NotSet + | PendingCountWithTotal PendingCount + + +type WorkflowInfoStatus + = Enabled + | Disabled + + +type alias Model = + { contextPath : String + , pendingCount : PendingCountOpt + } + + +type Msg + = GetPendingCount (Result Error PendingCountOpt) + | GetWorkflowEnabledSetting (Result Error WorkflowInfoStatus) + + + +------------------------------ +-- API -- +------------------------------ +-- API call to get the number of pending change request for each respective "pending" status + + +getPendingCount : Model -> Cmd Msg +getPendingCount model = + let + req = + request + { method = "GET" + , headers = [ header "X-Requested-With" "XMLHttpRequest" ] + , url = getApiUrl model "changevalidation/workflow/pendingCountByStatus" + , body = emptyBody + , expect = expectJson GetPendingCount decodePendingCountList + , timeout = Nothing + , tracker = Nothing + } + in + req + + +getWorkflowEnabledSetting : Model -> Cmd Msg +getWorkflowEnabledSetting model = + let + req = + request + { method = "GET" + , headers = [ header "X-Requested-With" "XMLHttpRequest" ] + , url = getApiUrl model "settings/enable_change_request" + , body = emptyBody + , expect = expectJson GetWorkflowEnabledSetting decodeEnabledSetting + , timeout = Nothing + , tracker = Nothing + } + in + req + + + +------------------------------ +-- ENCODE / DECODE JSON -- +------------------------------ + + +pendingCountOpt : Maybe Int -> Maybe Int -> PendingCountOpt +pendingCountOpt pendingValidation pendingDeployment = + case ( pendingValidation, pendingDeployment ) of + ( Nothing, Nothing ) -> + NotSet + + -- At least one field (pendingValidation and/or pendingDeployment) is not equal to Nothing + ( _, _ ) -> + PendingCountWithTotal + { pendingDeployment = pendingDeployment + , pendingValidation = pendingValidation + , totalCount = Maybe.withDefault 0 pendingValidation + Maybe.withDefault 0 pendingDeployment + } + + +decodePendingCountOpt : Decoder PendingCountOpt +decodePendingCountOpt = + map2 + pendingCountOpt + (maybe (field "pendingValidation" int)) + (maybe (field "pendingDeployment" int)) + + +decodePendingCountList : Decoder PendingCountOpt +decodePendingCountList = + at [ "data" ] (field "workflow" (index 0 decodePendingCountOpt)) + + +decodeEnabledSetting : Decoder WorkflowInfoStatus +decodeEnabledSetting = + let + boolToStatus b = + case b of + True -> + Enabled + + False -> + Disabled + in + let + decSetting = + field "settings" (field "enable_change_request" (Json.Decode.map boolToStatus bool)) + in + at [ "data" ] decSetting + + + +------------------------------ +-- UPDATE -- +------------------------------ + + +update : Msg -> Model -> ( Model, Cmd Msg ) +update msg model = + case msg of + GetPendingCount result -> + case result of + Ok pendingCount -> + ( { model | pendingCount = pendingCount }, Cmd.none ) + + -- Unauthorized access + Err (Http.BadStatus 403) -> + ( { model | pendingCount = NotSet }, Cmd.none ) + + Err err -> + ( model, errorNotification ("Error while trying to fetch pending change requests: " ++ getErrorMessage err) ) + + GetWorkflowEnabledSetting result -> + case result of + Ok setting -> + case setting of + Enabled -> + ( model, getPendingCount model ) + + Disabled -> + ( { model | pendingCount = NotSet }, Cmd.none ) + + -- Unauthorized access + Err (Http.BadStatus 403) -> + ( { model | pendingCount = NotSet }, Cmd.none ) + + Err err -> + ( model, errorNotification ("Error while trying to fetch change_requests_enabled setting: " ++ getErrorMessage err) ) + + + +------------------------------ +-- VIEW -- +------------------------------ + + +view : Model -> Html Msg +view model = + let + viewDropdown = + case model.pendingCount of + NotSet -> + viewDropdownToggle True "-" + + PendingCountWithTotal pc -> + viewDropdownToggle False ( String.fromInt pc.totalCount ) + in + li + [ Attr.class "nav-item dropdown notifications-menu" + , Attr.id "workflow-app" + ] + [ viewDropdown + , ul + [ Attr.class "dropdown-menu" + , Attr.attribute "role" "menu" + ] + [ li [] [ viewDropDownMenu model ] ] + ] + + + +viewDropDownMenu : Model -> Html Msg +viewDropDownMenu model = + case model.pendingCount of + NotSet -> + ul [ Attr.class "menu" ] [] + + PendingCountWithTotal pc -> + ul [ Attr.class "menu" ] + [ displayPendingCount pc.pendingValidation "Pending Validation" (getPendingValidationUrl model) "pe-2 fa fa-flag-o" + , displayPendingCount pc.pendingDeployment "Pending Deployment" (getPendingDeploymentUrl model) "pe-2 fa fa-flag-checkered" + ] + + +viewDropdownToggle : Bool -> String -> Html Msg +viewDropdownToggle isLoading displayedCount = + a + [ Attr.href "#" + , Attr.class ( "dropdown-toggle " ++ if isLoading then "placeholder-glow" else "" ) + , Attr.attribute "data-bs-toggle" "dropdown" + , Attr.attribute "role" "button" + , Attr.attribute "aria-expanded" "false" + ] + [ span [] [ text "CR" ] + , span + [ Attr.id "number" + , Attr.class ( "badge rudder-badge " ++ if isLoading then "placeholder" else "" ) + ] + [ Html.text displayedCount ] + ] + + +displayPendingCount : Maybe Int -> String -> String -> String -> Html Msg +displayPendingCount countOpt countName link flag = + case countOpt of + Nothing -> + Html.text "" + + Just count -> + li [] + [ a + [ Attr.href link + , Attr.class "pe-auto" + ] + [ span [] + [ i + [ Attr.class flag + ] + [] + , Html.text countName + ] + , span + [ Attr.class "float-end badge bg-light text-dark px-2" + ] + [ Html.text (String.fromInt count) ] + ] + ] + + + +------------------------------ +-- SUBSCRIPTIONS +------------------------------ + + +subscriptions : Model -> Sub Msg +subscriptions _ = + Sub.none diff --git a/change-validation/src/main/elm/sources/WorkflowInformationTest.elm b/change-validation/src/main/elm/sources/WorkflowInformationTest.elm new file mode 100644 index 000000000..586a3cf41 --- /dev/null +++ b/change-validation/src/main/elm/sources/WorkflowInformationTest.elm @@ -0,0 +1,88 @@ +module WorkflowInformationTest exposing (..) + +import Expect +import Fuzz exposing (int, maybe) +import Test exposing (..) +import WorkflowInformation exposing (PendingCountOpt(..), pendingCountOpt) + + +expectedTotalCount nonOptCount optCount = + case optCount of + Just res -> + res + nonOptCount + + Nothing -> + nonOptCount + + +suite = + describe "pending count" + [ -- A PendingCount Json that has two empty fields will always be decoded as a NotSet object + test "NotSet" <| + \_ -> + pendingCountOpt Nothing Nothing + |> Expect.equal NotSet + , -- A PendingCount Json whose pendingValidation field is present will always be decoded as a PendingCountWithTotal object + fuzz2 + int + (maybe int) + "Non-empty pendingValidation field" + <| + \pendingValidation pendingDeploymentOpt -> + pendingCountOpt (Just pendingValidation) pendingDeploymentOpt + |> Expect.equal + (PendingCountWithTotal + { pendingValidation = Just pendingValidation + , pendingDeployment = pendingDeploymentOpt + , totalCount = expectedTotalCount pendingValidation pendingDeploymentOpt + } + ) + , -- A PendingCount Json whose pendingDeployment field is present will always be decoded as a PendingCountWithTotal object + fuzz2 + (maybe int) + int + "Non-empty pendingDeployment field" + <| + \pendingValidationOpt pendingDeployment -> + pendingCountOpt pendingValidationOpt (Just pendingDeployment) + |> Expect.equal + (PendingCountWithTotal + { pendingValidation = pendingValidationOpt + , pendingDeployment = Just pendingDeployment + , totalCount = expectedTotalCount pendingDeployment pendingValidationOpt + } + ) + , -- A PendingCount Json whose pendingValidation and pendingDeployment fields are both present will always be decoded as a PendingCountWithTotal object + fuzz2 + int + int + "Non-empty pendingDeployment and pendingValidation fields" + <| + \pendingValidation pendingDeployment -> + pendingCountOpt (Just pendingValidation) (Just pendingDeployment) + |> Expect.equal + (PendingCountWithTotal + { pendingValidation = Just pendingValidation + , pendingDeployment = Just pendingDeployment + , totalCount = pendingValidation + pendingDeployment + } + ) + , -- After decoding, the obtained PendingCountOpt object must be a 'NotSet' if the two given fields are equal to 'Nothing', and be a 'PendingCountWithTotal' object whose fields are exactly equal to the given values. + fuzz2 + (maybe int) + (maybe int) + "PendingCountWithTotal cannot have two empty fields" + <| + \pendingValidation pendingDeployment -> + case pendingCountOpt pendingValidation pendingDeployment of + NotSet -> + ( pendingValidation, pendingDeployment ) |> Expect.equal ( Nothing, Nothing ) + + PendingCountWithTotal pendingCount -> + case ( pendingCount.pendingValidation, pendingCount.pendingDeployment ) of + ( Nothing, Nothing ) -> + Expect.fail "PendingCountWithTotal cannot have both of its pendingValidation and pendingDeployment fields be equal to 'Nothing'" + + ( _, _ ) -> + ( pendingCount.pendingValidation, pendingCount.pendingDeployment ) |> Expect.equal ( pendingValidation, pendingDeployment ) + ] diff --git a/change-validation/src/main/elm/sources/WorkflowUsers.elm b/change-validation/src/main/elm/sources/WorkflowUsers.elm index ba8346b0c..ab8209b93 100644 --- a/change-validation/src/main/elm/sources/WorkflowUsers.elm +++ b/change-validation/src/main/elm/sources/WorkflowUsers.elm @@ -1,150 +1,305 @@ module WorkflowUsers exposing (..) -import ApiCalls exposing (getUsers) -import Browser -import DataTypes exposing (ColPos(..), EditMod(..), Model, Msg(..), User, UserList) -import Init exposing (initModel, subscriptions, errorNotification, successNotification, getErrorMessage) +import DataTypes exposing (ColPos(..), EditMod(..), Model, Msg, User, UserList, UserListField(..), ValidateAllView(..), WorkflowUsersForm, WorkflowUsersMsg(..), WorkflowUsersView(..)) +import ErrorMessages exposing (getErrorMessage) import List exposing (filter, member) +import Ports exposing (errorNotification, successNotification) import String -import View exposing (view) + filterValidatedUsers : UserList -> UserList filterValidatedUsers users = filter (\u -> u.isValidated) users + filterUnvalidatedUsers : UserList -> UserList filterUnvalidatedUsers users = filter (\u -> not u.isValidated) users -mainInit : {contextPath : String} -> ( Model, Cmd Msg ) -mainInit initValues = - let - m = - initModel initValues.contextPath - in - ( m, getUsers m ) - -main = - Browser.element - { init = mainInit - , view = view - , update = update - , subscriptions = subscriptions - } - -update : Msg -> Model -> ( Model, Cmd Msg ) + +initModel : String -> Bool -> Model +initModel contextPath hasWriteRights = + Model contextPath Off WorkflowUsersInitView hasWriteRights ValidateAllInitView + + +update : WorkflowUsersMsg -> Model -> ( Model, Cmd Msg ) update msg model = - case msg of - GetUsers result -> - case result of - Ok users -> - ( - { model - | users = users - , unvalidatedUsers = filterUnvalidatedUsers users - , validatedUsers = filterValidatedUsers users - , leftChecked = [] - , rightChecked = [] - , hasMoved = [] - } + case msg of + GetUsers result -> + case result of + Ok users -> + ( { model + | workflowUsersView = + WorkflowUsers + { users = users + , unvalidatedUsers = filterUnvalidatedUsers users + , validatedUsers = filterValidatedUsers users + , leftChecked = [] + , rightChecked = [] + , hasMoved = [] + } + } + , Cmd.none + ) + + Err error -> + ( model, errorNotification ("An error occurred while trying to get users:" ++ getErrorMessage error) ) + + RemoveUser result -> + case result of + Ok removeUser -> + ( { model | workflowUsersView = mapUserList Users (filter (\m -> m.username /= removeUser)) model.workflowUsersView }, Cmd.none ) + + Err error -> + ( model, errorNotification ("An error occurred while trying to delete a validated users:" ++ getErrorMessage error) ) + + SaveWorkflow result -> + case result of + Ok updatedUsers -> + ( { model + | workflowUsersView = + model.workflowUsersView + |> setUserListOn Users updatedUsers + |> setUserListOn UnvalidatedUsers (filterUnvalidatedUsers updatedUsers) + |> setUserListOn ValidatedUsers (filterValidatedUsers updatedUsers) + |> setUserListOn HasMoved [] + |> setChecked [] [] + } + , successNotification "" + ) + + Err error -> + ( model, errorNotification ("An error occurred while trying to save validated users:" ++ getErrorMessage error) ) + + CallApi call -> + ( model, call model ) + + RightToLeft -> + case model.workflowUsersView of + WorkflowUsers form -> + ( { model + | workflowUsersView = + model.workflowUsersView + |> mapUserList UnvalidatedUsers (filter (\u -> not (member u form.rightChecked))) + |> setUserListOn HasMoved (form.hasMoved ++ form.rightChecked) + |> setUserListOn ValidatedUsers (form.rightChecked ++ form.validatedUsers) + |> setChecked [] [] + } + , Cmd.none + ) + + _ -> + ( model, Cmd.none ) + + LeftToRight -> + case model.workflowUsersView of + WorkflowUsers form -> + ( { model + | workflowUsersView = + model.workflowUsersView + |> setUserListOn UnvalidatedUsers (form.leftChecked ++ form.unvalidatedUsers) + |> setUserListOn HasMoved (form.hasMoved ++ form.leftChecked) + |> mapUserList ValidatedUsers (filter (\u -> not (member u form.leftChecked))) + |> setChecked [] [] + } + , Cmd.none + ) + + _ -> + ( model, Cmd.none ) + + AddLeftChecked user isChecked -> + case model.workflowUsersView of + WorkflowUsers form -> + if not (member user form.leftChecked) && isChecked then + ( { model + | workflowUsersView = + model.workflowUsersView + |> setChecked (user :: form.leftChecked) [] + } + , Cmd.none + ) + + else + ( { model + | workflowUsersView = model.workflowUsersView |> mapUserList LeftChecked (filter (\u -> user /= u)) + } + , Cmd.none + ) + + _ -> + ( model, Cmd.none ) + + AddRightChecked user isChecked -> + case model.workflowUsersView of + WorkflowUsers form -> + if not (member user form.rightChecked) && isChecked then + ( { model + | workflowUsersView = + model.workflowUsersView + |> setChecked [] (user :: form.rightChecked) + } + , Cmd.none + ) + + else + ( { model + | workflowUsersView = model.workflowUsersView |> mapUserList RightChecked (filter (\u -> user /= u)) + } + , Cmd.none + ) + + _ -> + ( model, Cmd.none ) + + CheckAll colPos isChecked -> + case colPos of + Left -> + if isChecked then + ( { model | workflowUsersView = model.workflowUsersView |> checkAllView Left }, Cmd.none ) + + else + ( { model | workflowUsersView = model.workflowUsersView |> setUserListOn LeftChecked [] }, Cmd.none ) + + Right -> + if isChecked then + ( { model | workflowUsersView = model.workflowUsersView |> checkAllView Right }, Cmd.none ) + + else + ( { model | workflowUsersView = model.workflowUsersView |> setUserListOn RightChecked [] }, Cmd.none ) + + SwitchMode -> + case model.editMod of + On -> + ( { model | editMod = Off }, Cmd.none ) + + Off -> + ( { model | editMod = On }, Cmd.none ) + + ExitEditMod -> + ( { model + | editMod = Off + , workflowUsersView = + model.workflowUsersView + |> setChecked [] [] + } , Cmd.none - ) - Err error -> - ( model, errorNotification ("An error occurred while trying to get users:" ++ getErrorMessage error)) - - RemoveUser result -> - case result of - Ok removeUser -> - ( { model | users = filter (\m -> m.username /= removeUser) model.users }, Cmd.none ) - Err error -> - ( model, errorNotification ("An error occurred while trying to delete a validated users:" ++ getErrorMessage error)) - - SaveWorkflow result -> - case result of - Ok updatedUsers -> - ( - { model - | users = updatedUsers - , unvalidatedUsers = filterUnvalidatedUsers updatedUsers - , validatedUsers = filterValidatedUsers updatedUsers - , leftChecked = [] - , rightChecked = [] - , hasMoved = [] - } - , successNotification "" - ) - Err error -> - ( model, errorNotification ("An error occurred while trying to save validated users:" ++ getErrorMessage error)) - - CallApi call -> - (model, call model) - - RightToLeft -> - let - newUnvalidatedUsers = - filter (\u -> not (member u model.rightChecked)) model.unvalidatedUsers - newValidatedUsers = - model.rightChecked ++ model.validatedUsers - in - ( - { model - | unvalidatedUsers = newUnvalidatedUsers - , hasMoved = model.hasMoved ++ model.rightChecked - , validatedUsers = newValidatedUsers - , leftChecked = [] - , rightChecked = [] - } - , Cmd.none - ) - - LeftToRight -> - let - newValidatedUsers = - filter (\u -> not (member u model.leftChecked)) model.validatedUsers - newUnvalidatedUsers = - model.leftChecked ++ model.unvalidatedUsers - in - ( - { model - | unvalidatedUsers = newUnvalidatedUsers - , hasMoved = model.hasMoved ++ model.leftChecked - , validatedUsers = newValidatedUsers - , leftChecked = [] - , rightChecked = [] - } - , Cmd.none - ) - - AddLeftChecked user isChecked -> - if (not (member user model.leftChecked)) && isChecked then - ({model | leftChecked = user :: model.leftChecked, rightChecked = []}, Cmd.none) - else - ({model | leftChecked = filter (\u -> user /= u) model.leftChecked}, Cmd.none) - - - AddRightChecked user isChecked -> - if (not (member user model.rightChecked) ) && isChecked then - ({model | rightChecked = user :: model.rightChecked, leftChecked = []}, Cmd.none) - else - ({model | rightChecked = filter (\u -> user /= u) model.rightChecked}, Cmd.none) - - CheckAll colPos isChecked-> - case colPos of - Left -> - if isChecked then - ({model | leftChecked = model.validatedUsers, rightChecked = []}, Cmd.none) - else - ({model | leftChecked = []}, Cmd.none) - Right -> - if isChecked then - ({model | rightChecked = model.unvalidatedUsers, leftChecked = []}, Cmd.none) - else - ({model | rightChecked = []}, Cmd.none) - - SwitchMode -> - case model.editMod of - On -> ({model | editMod = Off}, Cmd.none) - Off -> ({model | editMod = On}, Cmd.none) - - ExitEditMod -> - ({model | editMod = Off, leftChecked = [], rightChecked = []}, Cmd.none) \ No newline at end of file + ) + + SaveValidateAllSetting result -> + case result of + Ok newSetting -> + ( { model | validateAllView = initValidateAllForm newSetting } + , successNotification "Successfully saved setting" + ) + + Err error -> + ( model, errorNotification ("An error occurred while trying to save validate_all_enabled setting :" ++ getErrorMessage error) ) + + GetValidateAllSetting result -> + case result of + Ok setting -> + ( { model | validateAllView = initValidateAllForm setting }, Cmd.none ) + + Err error -> + ( model, errorNotification ("An error occurred while trying to get validate_all_enabled setting :" ++ getErrorMessage error) ) + + ChangeValidateAllSetting enable_validate_all -> + ( { model | validateAllView = setValidateAll enable_validate_all model.validateAllView }, Cmd.none ) + + +setUserListOn : UserListField -> UserList -> WorkflowUsersView -> WorkflowUsersView +setUserListOn field newList viewState = + case viewState of + WorkflowUsers formState -> + case field of + Users -> + WorkflowUsers { formState | users = newList } + + ValidatedUsers -> + WorkflowUsers { formState | validatedUsers = newList } + + UnvalidatedUsers -> + WorkflowUsers { formState | unvalidatedUsers = newList } + + RightChecked -> + WorkflowUsers { formState | rightChecked = newList } + + LeftChecked -> + WorkflowUsers { formState | leftChecked = newList } + + HasMoved -> + WorkflowUsers { formState | hasMoved = newList } + + _ -> + viewState + + +setChecked : UserList -> UserList -> WorkflowUsersView -> WorkflowUsersView +setChecked left right = + setUserListOn LeftChecked left >> setUserListOn RightChecked right + + +checkAllView : ColPos -> WorkflowUsersView -> WorkflowUsersView +checkAllView colPos viewState = + case viewState of + WorkflowUsers formState -> + case colPos of + Left -> + viewState |> setChecked formState.validatedUsers [] + + Right -> + viewState |> setChecked [] formState.unvalidatedUsers + + _ -> + viewState + + +mapUserList : UserListField -> (UserList -> UserList) -> WorkflowUsersView -> WorkflowUsersView +mapUserList field f viewState = + case viewState of + WorkflowUsers formState -> + case field of + Users -> + WorkflowUsers { formState | users = f formState.users } + + ValidatedUsers -> + WorkflowUsers { formState | validatedUsers = f formState.validatedUsers } + + UnvalidatedUsers -> + WorkflowUsers { formState | unvalidatedUsers = f formState.unvalidatedUsers } + + RightChecked -> + WorkflowUsers { formState | rightChecked = f formState.rightChecked } + + LeftChecked -> + WorkflowUsers { formState | leftChecked = f formState.leftChecked } + + HasMoved -> + WorkflowUsers { formState | hasMoved = f formState.hasMoved } + + _ -> + viewState + + +initValidateAllForm : Bool -> ValidateAllView +initValidateAllForm value = + let + formState = + { validateAll = value } + in + ValidateAll { initValues = formState, formValues = formState } + + +setValidateAll : Bool -> ValidateAllView -> ValidateAllView +setValidateAll newValue viewState = + case viewState of + ValidateAll formState -> + let + newFormState = + { validateAll = newValue } + in + ValidateAll { formState | formValues = newFormState } + + _ -> + viewState diff --git a/change-validation/src/main/resources/change-validation.conf b/change-validation/src/main/resources/change-validation.conf index 81b1795b1..82d821be6 100644 --- a/change-validation/src/main/resources/change-validation.conf +++ b/change-validation/src/main/resources/change-validation.conf @@ -7,20 +7,20 @@ smtp.password="" # The rudder base URL (domain) as seen from people who will receive emails. This parameter is used # to display link to change requests in notification emails. -# If the CR URL is: https://my.rudder.server/rudder/secure/plugins/changes/changeRequest/1 +# If the CR URL is: https://my.rudder.server/rudder/secure/configurationManager/changes/changeRequest/1 # You should use: https://my.rudder.server/rudder [without end slash] rudder.base.url="https://my.rudder.server/rudder" # The rudder base URL (domain) as seen from people who will receive emails. This parameter is used # to display link to change requests in notification emails. -# If the CR URL is: https://my.rudder.server/rudder/secure/plugins/changes/changeRequest/1 +# If the CR URL is: https://my.rudder.server/rudder/secure/configurationManager/changes/changeRequest/1 # You should use: https://my.rudder.server/rudder [without end slash] rudder.base.url="https://my.rudder.server/rudder" # `subject` parameter support templating. -# Please refer to the documentation : https://docs.rudder.io/reference/6.1/plugins/change-validation.html +# Please refer to the documentation : https://docs.rudder.io/reference/current/plugins/change-validation.html # to know which parameters can be used for templating. # Pending validation diff --git a/change-validation/src/main/resources/template/ChangeValidationManagement.html b/change-validation/src/main/resources/template/ChangeValidationManagement.html index b556b6eef..05062fa79 100644 --- a/change-validation/src/main/resources/template/ChangeValidationManagement.html +++ b/change-validation/src/main/resources/template/ChangeValidationManagement.html @@ -1,332 +1,217 @@ - - + + - - - - + + + + + - -
- -Rudder - Change Validation - - - - - +
-
+ -
-
-
-

- Change validation -

-
-
-
+
+
+
+
+

Change validation status

+
+
+
+
    +
  • +
    + + +
    +
  • +
  • +
    + + +
    +
  • +
  • +
    + + +
    +
  • +
+ + + [messages] + +
+
+
+
+
+ +
+

+ If enabled, all change to configuration (directives, rules, groups and parameters) will be + submitted for validation via a change request based on node targeting (configured + below).
+ A new change request will enter the Pending validation status, then can be moved to + Pending deployment (approved but not yet deployed) or Deployed (approved and + deployed) statuses. +

+

+ If you have the user management plugin, only users with the validator or + deployer roles are authorized to perform + these steps (see /opt/rudder/etc/rudder-users.xml). +

+

+ If disabled or if the change is not submitted to validation, the configuration will be + immediately deployed. +

+
+
+
+
+
+

Configure email notification

+
+
+

You can modify the email's template of each steps here:

+
    +
  • + /var/rudder/plugins/change-validation/validation-mail.template + + + +
  • +
  • + /var/rudder/plugins/change-validation/deployment-mail.template + + + +
  • +
  • + /var/rudder/plugins/change-validation/cancelled-mail.template + + + +
  • +
  • + /var/rudder/plugins/change-validation/deployed-mail.template + + + +
  • +
+
+
+
+
+ +
+

+ By default, email notifications are disabled. To enable them, make sure that the + smtp.hostServer parameter is + not left empty in the configuration file: + /opt/rudder/etc/plugins/change-validation.conf +

+
+
+
+
+
+

Configure change request triggers

+
+

+ By default, change request are created for all users. You can change when a change request + is created with below options: +

+
    +
  • exempt some users from validation;
  • +
  • trigger change request only for changes impacting nodes belonging to some supervised groups; +
  • +
+

Be careful: a change request is created when at least one predicate matches, so an + exempted user + still need a change request to modify a node from a supervised group. +

+
+

Configure users with change validation

+
- + + + + -
-
-
-
-

Change validation status

-
-
-
-
    -
  • -
    - - -
    -
  • -
  • -
    - - -
    -
  • -
  • -
    - - -
    -
  • -
- - - [messages] - -
-
-
-
-
- -
-

- If enabled, all change to configuration (directives, rules, groups and parameters) will be - submitted for validation via a change request based on node targeting (configured - below).
- A new change request will enter the Pending validation status, then can be moved to - Pending deployment (approved but not yet deployed) or Deployed (approved and - deployed) statuses. -

-

- If you have the user management plugin, only users with the validator or - deployer roles are authorized to perform - these steps (see /opt/rudder/etc/rudder-users.xml). -

-

- If disabled or if the change is not submitted to validation, the configuration will be - immediately deployed. -

-
-
-
-
-
-

Configure email notification

-
-
-

You can modify the email's template of each steps here:

-
    -
  • - /var/rudder/plugins/change-validation/validation-mail.template - - - -
  • -
  • - /var/rudder/plugins/change-validation/deployment-mail.template - - - -
  • -
  • - /var/rudder/plugins/change-validation/cancelled-mail.template - - - -
  • -
  • - /var/rudder/plugins/change-validation/deployed-mail.template - - - -
  • -
-
-
-
-
- + + +
+
-

- By default, email notifications are disabled. To enable them, make sure that the - smtp.hostServer parameter is - not left empty in the configuration file: - /opt/rudder/etc/plugins/change-validation.conf -

-
-
-
-

Configure change request triggers

-
-

- By default, change request are created for all users. You can change when a change request - is created with below options: -

-
    -
  • exempt some users from validation;
  • -
  • trigger change request only for changes impacting nodes belonging to some supervised groups; -
  • -
-

Be careful: a change request is created when at least one predicate matches, so an - exempted user - still need a change request to modify a node from a supervised group. -

-
-

Configure users with change validation

-
-
-
-
-
-
-
-
-
- -
-

- Any change done by a validated user will be automatically deployed without validation needed - by another user. -

-
-
-
- -
-
-
-
-
-
    -
  • -
    - - -
    -
  • -
- - [messages] -
-
-
-
-
- -
-

- Any change done by a Validated User will be automatically approved no matter the nature of the change. -

-

- Configuring groups below will hence have no effect on validated users (in the list above), but will apply to non-validated users, who will still need a change request to modify a node from a supervised group. -

-
-
-
- -
-
-
-

Configure groups with change validations

-
-
-
-
-
- -
-
-
-
- -
-

- Change validation are enable for any change that would impact a node belonging to one - of the chosen groups below. - Be careful: a change on one another group -

-

- The supervised changes are: -

    -
  • any change in a global parameter, as these changes can have side effects spreading - technique code,
  • -
  • any modification in one of the supervised groups,
  • -
  • any change in a rule which targets a node which belong to a group marked as supervised, -
  • -
  • any change in a directive used in one of the previous rules.
  • -
-

-

- Changes in techniques are not subjected to change validation, nor are changes resulting from - an archive import. -

-
-
-
-
-
-
-
-
-
-
-
-
- - - + + diff --git a/change-validation/src/main/resources/template/changeRequest.html b/change-validation/src/main/resources/template/changeRequest.html index 353cbf050..ec2235c4d 100644 --- a/change-validation/src/main/resources/template/changeRequest.html +++ b/change-validation/src/main/resources/template/changeRequest.html @@ -4,6 +4,7 @@ Rudder - Change Validation +
@@ -16,9 +17,59 @@
+
+ + + + + + + + + + + + + + + + + +
diff --git a/change-validation/src/main/resources/toserve/changevalidation/change-validation.js b/change-validation/src/main/resources/toserve/changevalidation/change-validation.js index 158ee3b2a..56e3517b0 100644 --- a/change-validation/src/main/resources/toserve/changevalidation/change-validation.js +++ b/change-validation/src/main/resources/toserve/changevalidation/change-validation.js @@ -57,7 +57,7 @@ function createChangeRequestTable(gridId, data, contextPath, refresh) { $(nTd).empty(); $(nTd).addClass("link"); var editLink = $(""); - editLink.attr("href",contextPath +'/secure/plugins/changes/changeRequest/'+sData); + editLink.attr("href",contextPath +'/secure/configurationManager/changes/changeRequest/'+sData); editLink.text(sData); $(nTd).append(editLink); } @@ -73,7 +73,7 @@ function createChangeRequestTable(gridId, data, contextPath, refresh) { $(nTd).empty(); $(nTd).addClass("link"); var editLink = $(""); - editLink.attr("href",contextPath +'/secure/plugins/changes/changeRequest/'+oData.id); + editLink.attr("href",contextPath +'/secure/configurationManager/changes/changeRequest/'+oData.id); editLink.text(sData); $(nTd).append(editLink); } diff --git a/change-validation/src/main/scala-templates/default/com/normation/plugins/changevalidation/EnablePluginImpl.scala b/change-validation/src/main/scala-templates/default/com/normation/plugins/changevalidation/EnablePluginImpl.scala index 047ed8707..a97af0bab 100644 --- a/change-validation/src/main/scala-templates/default/com/normation/plugins/changevalidation/EnablePluginImpl.scala +++ b/change-validation/src/main/scala-templates/default/com/normation/plugins/changevalidation/EnablePluginImpl.scala @@ -38,10 +38,10 @@ package com.normation.plugins.changevalidation import com.normation.plugins.PluginEnableImpl -import com.normation.rudder.services.nodes.NodeInfoService +import com.normation.rudder.facts.nodes.NodeFactRepository /* * The class will be loaded by ServiceLoader, it needs an empty constructor. */ -final class CheckRudderPluginEnableImpl(nodeInfoService: NodeInfoService) extends PluginEnableImpl +final class CheckRudderPluginEnableImpl(nodeFactRepo: NodeFactRepository) extends PluginEnableImpl diff --git a/change-validation/src/main/scala-templates/limited/com/normation/plugins/changevalidation/EnablePluginImpl.scala b/change-validation/src/main/scala-templates/limited/com/normation/plugins/changevalidation/EnablePluginImpl.scala index a250f3692..be78c4fd5 100644 --- a/change-validation/src/main/scala-templates/limited/com/normation/plugins/changevalidation/EnablePluginImpl.scala +++ b/change-validation/src/main/scala-templates/limited/com/normation/plugins/changevalidation/EnablePluginImpl.scala @@ -38,7 +38,7 @@ package com.normation.plugins.changevalidation import com.normation.plugins.LicensedPluginCheck -import com.normation.rudder.services.nodes.NodeInfoService +import com.normation.rudder.facts.nodes.NodeFactRepository import com.normation.zio.* /* @@ -48,12 +48,12 @@ import com.normation.zio.* * * The class will be loaded by ServiceLoader, it needs an empty constructor. */ -final class CheckRudderPluginEnableImpl(nodeInfoService: NodeInfoService) extends LicensedPluginCheck { +final class CheckRudderPluginEnableImpl(nodeFactRepo: NodeFactRepository) extends LicensedPluginCheck { // here are processed variables def pluginResourcePublickey = "${plugin-resource-publickey}" def pluginResourceLicense = "${plugin-resource-license}" def pluginDeclaredVersion = "${plugin-declared-version}" def pluginId = "${plugin-fullname}" - override def getNumberOfNodes: Int = nodeInfoService.getNumberOfManagedNodes.runNow + override def getNumberOfNodes: Int = nodeFactRepo.getNumberOfManagedNodes().runNow } diff --git a/change-validation/src/main/scala/bootstrap/rudder/plugin/ChangeValidationConf.scala b/change-validation/src/main/scala/bootstrap/rudder/plugin/ChangeValidationConf.scala index ffe4b00f9..f3c0e4d93 100644 --- a/change-validation/src/main/scala/bootstrap/rudder/plugin/ChangeValidationConf.scala +++ b/change-validation/src/main/scala/bootstrap/rudder/plugin/ChangeValidationConf.scala @@ -41,7 +41,7 @@ import bootstrap.liftweb.RudderConfig import bootstrap.liftweb.RudderConfig.commitAndDeployChangeRequest import bootstrap.liftweb.RudderConfig.doobie import bootstrap.liftweb.RudderConfig.workflowLevelService -import com.normation.box.* +import com.normation.errors.IOResult import com.normation.eventlog.EventActor import com.normation.plugins.PluginStatus import com.normation.plugins.RudderPluginModule @@ -71,6 +71,9 @@ import com.normation.plugins.changevalidation.api.SupervisedTargetsApi import com.normation.plugins.changevalidation.api.SupervisedTargetsApiImpl import com.normation.plugins.changevalidation.api.ValidatedUserApi import com.normation.plugins.changevalidation.api.ValidatedUserApiImpl +import com.normation.plugins.changevalidation.api.WorkflowInternalApi +import com.normation.plugins.changevalidation.api.WorkflowInternalApiImpl +import com.normation.plugins.changevalidation.extension.ChangeValidationTab import com.normation.rudder.domain.nodes.NodeGroupId import com.normation.rudder.domain.policies.DirectiveUid import com.normation.rudder.domain.policies.RuleUid @@ -87,8 +90,7 @@ import com.normation.rudder.services.workflows.WorkflowLevelService import com.normation.rudder.services.workflows.WorkflowService import com.normation.zio.UnsafeRun import java.nio.file.Paths -import net.liftweb.common.Box -import net.liftweb.common.Full +import zio.syntax.ToZio /* * The validation workflow level @@ -98,15 +100,15 @@ class ChangeValidationWorkflowLevelService( defaultWorkflowService: WorkflowService, validationWorkflowService: TwoValidationStepsWorkflowServiceImpl, validationNeeded: Seq[ValidationNeeded], - workflowEnabledByUser: () => Box[Boolean], - alwaysNeedValidation: () => Box[Boolean], + workflowEnabledByUser: () => IOResult[Boolean], + alwaysNeedValidation: () => IOResult[Boolean], validatedUserRepo: RoValidatedUserRepository ) extends WorkflowLevelService { override def workflowLevelAllowsEnable: Boolean = status.isEnabled() override def workflowEnabled: Boolean = { - workflowLevelAllowsEnable && workflowEnabledByUser().getOrElse(false) + workflowLevelAllowsEnable && workflowEnabledByUser().orElseSucceed(false).runNow } override def name: String = "Change Request Validation Workflows" @@ -116,7 +118,7 @@ class ChangeValidationWorkflowLevelService( * return the correct workflow given the "needed" check. Also check * for the actual status of workflow to decide what workflow to use. */ - private[this] def getWorkflow(shouldBeNeeded: Box[Boolean]): Box[WorkflowService] = { + private[this] def getWorkflow(shouldBeNeeded: IOResult[Boolean]): IOResult[WorkflowService] = { for { need <- shouldBeNeeded } yield { @@ -132,19 +134,19 @@ class ChangeValidationWorkflowLevelService( * Method to use to combine several validationNeeded check. * Note that a validated user will prevent workflow to be performed, no other validationNeeded check will be executed */ - def combine[T]( - checkFn: (ValidationNeeded, EventActor, T) => Box[Boolean], + private def combine[T]( + checkFn: (ValidationNeeded, EventActor, T) => IOResult[Boolean], checks: Seq[ValidationNeeded], actor: EventActor, change: T - ): Box[WorkflowService] = { + ): IOResult[WorkflowService] = { def getWorkflowAux = { // When we "always need validation", we ignore all validationNeeded checks, otherwise we validate using these checks getWorkflow(validationNeeded.foldLeft(alwaysNeedValidation()) { case (shouldValidate, nextCheck) => shouldValidate.flatMap { // logic is "or": if previous should validate is true, don't check following - case true => Full(true) + case true => true.succeed case false => checkFn(nextCheck, actor, change) } }) @@ -161,47 +163,46 @@ class ChangeValidationWorkflowLevelService( validatedUserRepo .get(actor) .chainError("Could get user from validated user list when checking validation workflow") - .toBox .flatMap { - case Some(e) => getWorkflow(Full(false)) + case Some(e) => getWorkflow(false.succeed) case None => getWorkflowAux } } - override def getForRule(actor: EventActor, change: RuleChangeRequest): Box[WorkflowService] = { + override def getForRule(actor: EventActor, change: RuleChangeRequest): IOResult[WorkflowService] = { combine[RuleChangeRequest]((v, a, c) => v.forRule(a, c), validationNeeded, actor, change) } - override def getForDirective(actor: EventActor, change: DirectiveChangeRequest): Box[WorkflowService] = { + override def getForDirective(actor: EventActor, change: DirectiveChangeRequest): IOResult[WorkflowService] = { combine[DirectiveChangeRequest]((v, a, c) => v.forDirective(a, c), validationNeeded, actor, change) } - override def getForNodeGroup(actor: EventActor, change: NodeGroupChangeRequest): Box[WorkflowService] = { + override def getForNodeGroup(actor: EventActor, change: NodeGroupChangeRequest): IOResult[WorkflowService] = { combine[NodeGroupChangeRequest]((v, a, c) => v.forNodeGroup(a, c), validationNeeded, actor, change) } - override def getForGlobalParam(actor: EventActor, change: GlobalParamChangeRequest): Box[WorkflowService] = { + override def getForGlobalParam(actor: EventActor, change: GlobalParamChangeRequest): IOResult[WorkflowService] = { combine[GlobalParamChangeRequest]((v, a, c) => v.forGlobalParam(a, c), validationNeeded, actor, change) } - override def getByDirective(uid: DirectiveUid, onlyPending: Boolean): Box[Vector[ChangeRequest]] = { + override def getByDirective(uid: DirectiveUid, onlyPending: Boolean): IOResult[Vector[ChangeRequest]] = { if (workflowEnabled) { validationWorkflowService.roChangeRequestRepository.getByDirective(uid, onlyPending) } else { - Full(Vector()) + Vector().succeed } } - override def getByNodeGroup(id: NodeGroupId, onlyPending: Boolean): Box[Vector[ChangeRequest]] = { + override def getByNodeGroup(id: NodeGroupId, onlyPending: Boolean): IOResult[Vector[ChangeRequest]] = { if (workflowEnabled) { validationWorkflowService.roChangeRequestRepository.getByNodeGroup(id, onlyPending) } else { - Full(Vector()) + Vector().succeed } } - override def getByRule(id: RuleUid, onlyPending: Boolean): Box[Vector[ChangeRequest]] = { + override def getByRule(id: RuleUid, onlyPending: Boolean): IOResult[Vector[ChangeRequest]] = { if (workflowEnabled) { validationWorkflowService.roChangeRequestRepository.getByRule(id, onlyPending) } else { - Full(Vector()) + Vector().succeed } } } @@ -226,7 +227,7 @@ object ChangeValidationConf extends RudderPluginModule { "/opt/rudder/etc/plugins/change-validation.conf" ) // by build convention, we have only one of that on the classpath - lazy val pluginStatusService = new CheckRudderPluginEnableImpl(RudderConfig.nodeInfoService) + lazy val pluginStatusService = new CheckRudderPluginEnableImpl(RudderConfig.nodeFactRepository) lazy val roWorkflowRepository = new RoWorkflowJdbcRepository(RudderConfig.doobie) lazy val woWorkflowRepository = new WoWorkflowJdbcRepository(RudderConfig.doobie) @@ -243,9 +244,9 @@ object ChangeValidationConf extends RudderPluginModule { woChangeRequestRepository, notificationService, RudderConfig.userService, - () => Full(RudderConfig.workflowLevelService.workflowEnabled), - () => RudderConfig.configService.rudder_workflow_self_validation().toBox, - () => RudderConfig.configService.rudder_workflow_self_deployment().toBox + () => RudderConfig.workflowLevelService.workflowEnabled.succeed, + () => RudderConfig.configService.rudder_workflow_self_validation(), + () => RudderConfig.configService.rudder_workflow_self_deployment() ) lazy val unsupervisedTargetRepo = new UnsupervisedTargetsRepository( @@ -289,8 +290,8 @@ object ChangeValidationConf extends RudderPluginModule { RudderConfig.nodeFactRepository ) ), - () => RudderConfig.configService.rudder_workflow_enabled().toBox, - () => RudderConfig.configService.rudder_workflow_validate_all().toBox, + () => RudderConfig.configService.rudder_workflow_enabled(), + () => RudderConfig.configService.rudder_workflow_validate_all(), roValidatedUserRepository ) ) @@ -320,16 +321,21 @@ object ChangeValidationConf extends RudderPluginModule { roValidatedUserRepository, woValidatedUserRepository ) + val api4 = new WorkflowInternalApiImpl( + roWorkflowRepository, + RudderConfig.userService + ) new LiftApiModuleProvider[EndpointSchema] { override def schemas: ApiModuleProvider[EndpointSchema] = new ApiModuleProvider[EndpointSchema] { override def endpoints: List[EndpointSchema] = - ValidatedUserApi.endpoints ::: SupervisedTargetsApi.endpoints ::: ChangeRequestApi.endpoints + ValidatedUserApi.endpoints ::: SupervisedTargetsApi.endpoints ::: ChangeRequestApi.endpoints ::: WorkflowInternalApi.endpoints } override def getLiftEndpoints(): List[LiftApiModule] = - api1.getLiftEndpoints() ::: api2.getLiftEndpoints() ::: api3.getLiftEndpoints() + api1.getLiftEndpoints() ::: api2.getLiftEndpoints() ::: api3.getLiftEndpoints() ::: api4.getLiftEndpoints() } } RudderConfig.snippetExtensionRegister.register(new TopBarExtension(pluginStatusService)) + RudderConfig.snippetExtensionRegister.register(new ChangeValidationTab(pluginDef.status)) } diff --git a/change-validation/src/main/scala/com/normation/plugins/changevalidation/ChangeRequestJdbcRepository.scala b/change-validation/src/main/scala/com/normation/plugins/changevalidation/ChangeRequestJdbcRepository.scala index ad53ab95b..a5b8ae6eb 100644 --- a/change-validation/src/main/scala/com/normation/plugins/changevalidation/ChangeRequestJdbcRepository.scala +++ b/change-validation/src/main/scala/com/normation/plugins/changevalidation/ChangeRequestJdbcRepository.scala @@ -37,7 +37,12 @@ package com.normation.plugins.changevalidation +import cats.Show import cats.data.NonEmptyList +import cats.implicits.toBifunctorOps +import cats.syntax.applicative.* +import cats.syntax.applicativeError.* +import cats.syntax.functor.* import cats.syntax.reducible.* import com.normation.errors.* import com.normation.eventlog.EventActor @@ -45,12 +50,18 @@ import com.normation.eventlog.ModificationId import com.normation.rudder.db.Doobie import com.normation.rudder.db.Doobie.* import com.normation.rudder.domain.nodes.NodeGroupId +import com.normation.rudder.domain.policies.DirectiveId import com.normation.rudder.domain.policies.DirectiveUid +import com.normation.rudder.domain.policies.RuleId import com.normation.rudder.domain.policies.RuleUid import com.normation.rudder.domain.workflows.ChangeRequest import com.normation.rudder.domain.workflows.ChangeRequestId import com.normation.rudder.domain.workflows.ChangeRequestInfo import com.normation.rudder.domain.workflows.ConfigurationChangeRequest +import com.normation.rudder.domain.workflows.DirectiveChanges +import com.normation.rudder.domain.workflows.GlobalParameterChanges +import com.normation.rudder.domain.workflows.NodeGroupChanges +import com.normation.rudder.domain.workflows.RuleChanges import com.normation.rudder.domain.workflows.WorkflowNodeId import com.normation.rudder.services.marshalling.ChangeRequestChangesSerialisation import com.normation.rudder.services.marshalling.ChangeRequestChangesUnserialisation @@ -58,11 +69,24 @@ import doobie.* import doobie.implicits.* import doobie.postgres.implicits.* import doobie.util.fragments -import net.liftweb.common.* +import net.liftweb.common.Box +import net.liftweb.common.Full import net.liftweb.common.Loggable import org.joda.time.DateTime import scala.xml.Elem import zio.interop.catz.* +import zio.syntax.* + +/** + * Change request from the database does not have a trivial mapping to the `ChangeRequest` structure : + * there is the XML content that needs to be validated, and in some cases (some APIs) we decide to + * return the valid ones and ignore invalid ones. + * + * So, the type here is used to create a Read that does not fail but contains the failure. + */ +sealed trait DbChangeRequest +case class ChangeRequestWithSuccessXml(cr: ChangeRequest) extends DbChangeRequest +case class ChangeRequestWithInvalidXml(error: RudderError) extends DbChangeRequest trait RoChangeRequestJdbcRepositorySQL { @@ -77,43 +101,72 @@ trait RoChangeRequestJdbcRepositorySQL { import changeRequestMapper.* - implicit val readCRWithState: Read[Box[(ChangeRequest, WorkflowNodeId)]] = { - Read[(Box[ChangeRequest], WorkflowNodeId)].map { case (cr, state) => cr.map((_, state)) } + implicit val ChangeRequestReadOpt: Read[DbChangeRequest] = { + Read[CR].map { + case (id, name, description, content, modId) => + crcUnserialiser + .unserialise(content) + .chainError(s"Error when trying to get the content of the change request ${id}") match { + case Right((directivesMaps, nodesMaps, ruleMaps, paramMaps)) => + ChangeRequestWithSuccessXml( + ConfigurationChangeRequest( + ChangeRequestId(id), + modId.map(ModificationId.apply), + ChangeRequestInfo( + name.getOrElse(""), + description.getOrElse("") + ), + directivesMaps, + nodesMaps, + ruleMaps, + paramMaps + ) + ) + + case Left(err) => + ChangeRequestWithInvalidXml(err) + } + } } - def getAllSQL: Query0[Box[ChangeRequest]] = - sql"SELECT id, name, description, content, modificationId FROM ChangeRequest".query[Box[ChangeRequest]] + def getAllSQL: Query0[DbChangeRequest] = + sql"SELECT id, name, description, content, modificationId FROM ChangeRequest".query[DbChangeRequest] - def getSQL(changeRequestId: ChangeRequestId): Query0[Box[ChangeRequest]] = { + def getSQL(changeRequestId: ChangeRequestId): Query0[ChangeRequest] = { sql"SELECT id, name, description, content, modificationId FROM ChangeRequest where id = ${changeRequestId}" - .query[Box[ChangeRequest]] + .query[ChangeRequest] } - def getByContributorSQL(actor: EventActor): Query0[Box[ChangeRequest]] = { + def getRawCRSQL(changeRequestId: ChangeRequestId): Query0[DbChangeRequest] = { + sql"SELECT id, name, description, content, modificationId FROM ChangeRequest where id = ${changeRequestId}" + .query[DbChangeRequest] + } + + def getByContributorSQL(actor: EventActor): Query0[ChangeRequest] = { val actorName = Array(actor.name) sql"SELECT id, name, description, content, modificationId FROM ChangeRequest where cast( xpath('//firstChange/change/actor/text()',content) as character varying[]) = ${actorName}" - .query[Box[ChangeRequest]] + .query[ChangeRequest] } def getChangeRequestsByXpathContentSQL( xpath: Fragment, shouldEquals: String, onlyPending: Boolean - ): Query0[Box[ChangeRequest]] = { + ): Query0[ChangeRequest] = { val param = Array(shouldEquals) if (onlyPending) { sql"""SELECT CR.id, name, description, content, modificationId FROM changeRequest CR LEFT JOIN workflow W on CR.id = W.id where cast( xpath(${xpath}, content) as character varying[]) = ${param} and state like 'Pending%'""" - .query[Box[ChangeRequest]] + .query[ChangeRequest] } else { sql"""SELECT id, name, description, content, modificationId FROM ChangeRequest where cast( xpath(${xpath}, content) as character varying[]) = ${param}""" - .query[Box[ChangeRequest]] + .query[ChangeRequest] } } def getByFiltersSQL( statuses: Option[NonEmptyList[WorkflowNodeId]], xpathWithValue: Option[(Fragment, String)] // (xpath, value) - ): Query0[Box[(ChangeRequest, WorkflowNodeId)]] = { + ): Query0[(ChangeRequest, WorkflowNodeId)] = { (fr"SELECT CR.id, CR.name, CR.description, CR.content, CR.modificationId, W.state FROM ChangeRequest CR LEFT JOIN workflow W on CR.id = W.id" ++ fragments.whereAndOpt( statuses.map(fragments.in(fr"state", _)), @@ -122,7 +175,7 @@ trait RoChangeRequestJdbcRepositorySQL { val param = Array(value) fr"cast( xpath(${xpath}, content) as character varying[]) = ${param}" } - )).query[Box[(ChangeRequest, WorkflowNodeId)]] + )).query[(ChangeRequest, WorkflowNodeId)] } } @@ -154,59 +207,78 @@ class RoChangeRequestJdbcRepository( import doobie.* // utility method which correctly transform Doobie types towards Box[Vector[ChangeRequest]] - private[this] def execQuery(q: Query0[Box[ChangeRequest]]): Box[Vector[ChangeRequest]] = { - transactRunBox(xa => { - q.to[Vector] - .map( - // we are just ignoring change request with unserialisation - // error. Does not seem the best. - _.flatten.toVector - ) - .transact(xa) - }) + private[this] def execQuery(errMsg: String, q: Query0[ChangeRequest]): IOResult[Vector[ChangeRequest]] = { + transactIOResult(errMsg)(xa => { q.to[Vector].transact(xa) }) } - override def getAll(): Box[Vector[ChangeRequest]] = { - execQuery(getAllSQL) + override def getAll(): IOResult[Vector[ChangeRequest]] = { + transactIOResult("errMsg")(xa => { + getAllSQL + .to[List] + .transact(xa) + .flatMap(vector => { + val (errors, success) = vector.partitionMap { + case ChangeRequestWithInvalidXml(err) => Left(err) + case ChangeRequestWithSuccessXml(suc) => Right(suc) + } + + NonEmptyList + .fromList(errors) + .succeed + .tapSome { + case Some(errs) => + ChangeValidationLoggerPure + .warn( + Chained("There are some errors getting all change requests", Accumulated(errs)).fullMsg + ) + } + .as(success.toVector) + }) + }) } - override def get(changeRequestId: ChangeRequestId): Box[Option[ChangeRequest]] = { - transactRunBox(xa => getSQL(changeRequestId).option.map(_.flatMap(_.toOption)).transact(xa)) + override def get(changeRequestId: ChangeRequestId): IOResult[Option[ChangeRequest]] = { + transactIOResult(s"Could not get change request with id ${changeRequestId} in database")(xa => + getSQL(changeRequestId).option.transact(xa) + ) } // Get every change request where a user add a change - override def getByContributor(actor: EventActor): Box[Vector[ChangeRequest]] = { - execQuery(getByContributorSQL(actor)) + override def getByContributor(actor: EventActor): IOResult[Vector[ChangeRequest]] = { + execQuery(s"Could not get change requests that were modified by ${actor.name} in database", getByContributorSQL(actor)) } - override def getByDirective(id: DirectiveUid, onlyPending: Boolean): Box[Vector[ChangeRequest]] = { + override def getByDirective(id: DirectiveUid, onlyPending: Boolean): IOResult[Vector[ChangeRequest]] = { execQuery( + s"Could not get change requests for directive with id ${id.value} in database", getChangeRequestsByXpathContentSQL( directiveIdXPathFr, id.value, onlyPending ) - ) ?~! s"could not fetch change request for directive with id ${id.value}" + ) } - override def getByNodeGroup(id: NodeGroupId, onlyPending: Boolean): Box[Vector[ChangeRequest]] = { + override def getByNodeGroup(id: NodeGroupId, onlyPending: Boolean): IOResult[Vector[ChangeRequest]] = { execQuery( + s"Could not get change requests for group with id ${id.serialize} in database", getChangeRequestsByXpathContentSQL( groupIdXPathFr, id.serialize, onlyPending ) - ) ?~! s"could not fetch change request for group with id ${id.serialize}" + ) } - override def getByRule(id: RuleUid, onlyPending: Boolean): Box[Vector[ChangeRequest]] = { + override def getByRule(id: RuleUid, onlyPending: Boolean): IOResult[Vector[ChangeRequest]] = { execQuery( + s"Could not get change requests for rule with id ${id.value} in database", getChangeRequestsByXpathContentSQL( ruleIdXPathFr, id.value, onlyPending ) - ) ?~! s"could not fetch change request for rule with id ${id.value}" + ) } override def getByFilter(filter: ChangeRequestFilter): IOResult[Vector[(ChangeRequest, WorkflowNodeId)]] = { @@ -225,22 +297,22 @@ class RoChangeRequestJdbcRepository( getByFiltersSQL(statuses.map(_.toNonEmptyList), by.map(getXPathWithValue)) } - transactIOResult(errorMsg)(filteredQuery.to[Vector].map(_.flatten).transact(_)) + transactIOResult(errorMsg)(filteredQuery.to[Vector].transact(_)) } } class WoChangeRequestJdbcRepository( - doobie: Doobie, - mapper: ChangeRequestMapper, - roRepo: RoChangeRequestRepository -) extends WoChangeRequestRepository with Loggable with WoChangeRequestJdbcRepositorySQL { + doobie: Doobie, + override val changeRequestMapper: ChangeRequestMapper, + roRepo: RoChangeRequestRepository +) extends WoChangeRequestRepository with Loggable with WoChangeRequestJdbcRepositorySQL with RoChangeRequestJdbcRepositorySQL { import doobie.* // get the different part from a change request: name, description, content, modId private[this] def getAtom(cr: ChangeRequest): (Option[String], Option[String], Elem, Option[String]) = { - val xml = mapper.crcSerialiser.serialise(cr) + val xml = changeRequestMapper.crcSerialiser.serialise(cr) val name = cr.info.name match { case "" => None case x => Some(x) @@ -259,29 +331,31 @@ class WoChangeRequestJdbcRepository( * The id is ignored, and a new one will be attributed * to the change request. */ - def createChangeRequest(changeRequest: ChangeRequest, actor: EventActor, reason: Option[String]): Box[ChangeRequest] = { + def createChangeRequest(changeRequest: ChangeRequest, actor: EventActor, reason: Option[String]): IOResult[ChangeRequest] = { val (name, desc, xml, modId) = getAtom(changeRequest) - for { - id <- transactRunBox(xa => createChangeRequestSQL(name, desc, xml, modId).withUniqueGeneratedKeys[Int]("id").transact(xa)) - cr <- roRepo.get(ChangeRequestId(id)).flatMap { - case None => - val msg = s"The newly saved change request with ID ${id} was not found back in data base" - ChangeValidationLogger.error(msg) - Failure(msg) - case Some(x) => Full(x) - } - } yield { - cr - } + transactIOResult[Option[ChangeRequest]](s"Could not create change request with id ${changeRequest.id} in database")(xa => { + (for { + id <- createChangeRequestSQL(name, desc, xml, modId).withUniqueGeneratedKeys[Int]("id") + cr <- getSQL(ChangeRequestId(id)).option + } yield { + cr + }).transact(xa) + }) + .notOptional(s"The new change request cannot be saved in database") + .tapError(err => ChangeValidationLoggerPure.error(err.fullMsg)) } /** * Delete a change request. * (whatever the read/write mode is). */ - def deleteChangeRequest(changeRequestId: ChangeRequestId, actor: EventActor, reason: Option[String]): Box[ChangeRequest] = { + def deleteChangeRequest( + changeRequestId: ChangeRequestId, + actor: EventActor, + reason: Option[String] + ): IOResult[ChangeRequest] = { // we should update it rather, shouldn't we ? throw new IllegalArgumentException( "This a developer error. Please contact rudder developer, saying that they call unemplemented deleteChangeRequest" @@ -289,34 +363,31 @@ class WoChangeRequestJdbcRepository( } /** - * Update a change request. The change request must exists. + * Update a change request. The change request must exist. */ - def updateChangeRequest(changeRequest: ChangeRequest, actor: EventActor, reason: Option[String]): Box[ChangeRequest] = { + def updateChangeRequest(changeRequest: ChangeRequest, actor: EventActor, reason: Option[String]): IOResult[ChangeRequest] = { // no transaction between steps, because we don't actually use anything in the existing change request - - for { - cr <- roRepo.get(changeRequest.id) - ok <- cr match { - case None => - val msg = s"Cannot update non-existent Change Request with id ${changeRequest.id.value}" - ChangeValidationLogger.warn(msg) - Failure(msg) - case Some(x) => Full("ok") - } - update <- { - val (name, desc, xml, modId) = getAtom(changeRequest) - transactRunBox(xa => updateChangeRequestSQL(name, desc, xml, modId, changeRequest.id).run.transact(xa)) + val process = { + for { + exists <- getRawCRSQL(changeRequest.id).option + _ <- exists match { + case None => + val msg = + s"Change Request with id ${changeRequest.id.value} was not found in database" + new IllegalArgumentException(msg).raiseError[ConnectionIO, Unit] + case Some(_) => + val (name, desc, xml, modId) = getAtom(changeRequest) + updateChangeRequestSQL(name, desc, xml, modId, changeRequest.id).run.void + } + } yield { + changeRequest } - updated <- roRepo.get(changeRequest.id).flatMap { - case None => - val msg = s"Couldn't find the updated entry when updating Change Request ${changeRequest.id.value}" - ChangeValidationLogger.error(msg) - Failure(msg) - case Some(x) => Full(x) - } - } yield { - updated } + + transactIOResult( + s"Could not update change request with id ${changeRequest.id.value} in database" + )(xa => process.transact(xa)) + .tapError(err => ChangeValidationLoggerPure.error(err.fullMsg)) } } @@ -329,48 +400,6 @@ class ChangeRequestMapper( // id, name, description, content, modificationId type CR = (Int, Option[String], Option[String], Elem, Option[String]) - // unserialize the XML. - // If it fails, produce a failure - // directives map is boxed because some Exception could be launched - def unserialize( - id: Int, - name: Option[String], - description: Option[String], - content: Elem, - modId: Option[String] - ): Box[ChangeRequest] = { - crcUnserialiser.unserialise(content) match { - case Full((directivesMaps, nodesMaps, ruleMaps, paramMaps)) => - directivesMaps match { - case Full(map) => - Full( - ConfigurationChangeRequest( - ChangeRequestId(id), - modId.map(ModificationId.apply), - ChangeRequestInfo( - name.getOrElse(""), - description.getOrElse("") - ), - map, - nodesMaps, - ruleMaps, - paramMaps - ) - ) - - case eb: EmptyBox => - val fail = eb ?~! s"could not deserialize directive change of change request #${id} cause is: ${eb}" - ChangeValidationLogger.error(fail) - fail - } - - case eb: EmptyBox => - val fail = eb ?~! s"Error when trying to get the content of the change request ${id} : ${eb}" - ChangeValidationLogger.error(fail.msg) - fail - } - } - def serialize(optCR: Box[ChangeRequest]): CR = { optCR match { case Full(cr) => @@ -391,7 +420,44 @@ class ChangeRequestMapper( } } - implicit val ChangeRequestRead: Read[Box[ChangeRequest]] = { - Read[CR].map((t: CR) => unserialize(t._1, t._2, t._3, t._4, t._5)) + type ElemXml = ( + Map[DirectiveId, DirectiveChanges], + Map[NodeGroupId, NodeGroupChanges], + Map[RuleId, RuleChanges], + Map[String, GlobalParameterChanges] + ) + + implicit val ElemShow: Show[Elem] = { + Show.show(e => e.toString()) + } + + implicit val ElemXmlGet: Get[ElemXml] = XmlMeta.get.temap(content => { + crcUnserialiser + .unserialise(content) + .leftMap(err => Chained("Could not get content column from change request ", err).fullMsg) + }) + + implicit val ChangeRequestShow: Show[CR] = { + Show.show(cr => s"( id : ${cr._1}, name : ${cr._2}, description : ${cr._3}, content : ${cr._4}, modificationId : ${cr._5} ") + } + + implicit val ChangeRequestRead: Read[ChangeRequest] = { + + Read[(Int, Option[String], Option[String], ElemXml, Option[String])].map { + + case (id, name, description, (directivesMaps, nodesMaps, ruleMaps, paramMaps), modId) => + ConfigurationChangeRequest( + ChangeRequestId(id), + modId.map(ModificationId.apply), + ChangeRequestInfo( + name.getOrElse(""), + description.getOrElse("") + ), + directivesMaps, + nodesMaps, + ruleMaps, + paramMaps + ) + } } } diff --git a/change-validation/src/main/scala/com/normation/plugins/changevalidation/ChangeRequestJson.scala b/change-validation/src/main/scala/com/normation/plugins/changevalidation/ChangeRequestJson.scala index 35fbfa572..5a6d20e29 100644 --- a/change-validation/src/main/scala/com/normation/plugins/changevalidation/ChangeRequestJson.scala +++ b/change-validation/src/main/scala/com/normation/plugins/changevalidation/ChangeRequestJson.scala @@ -85,6 +85,7 @@ import com.normation.rudder.domain.properties.PropertyProvider import com.normation.rudder.domain.queries.Query import com.normation.rudder.domain.workflows.ChangeRequest import com.normation.rudder.domain.workflows.ChangeRequestId +import com.normation.rudder.domain.workflows.ChangeRequestInfo import com.normation.rudder.domain.workflows.ConfigurationChangeRequest import com.normation.rudder.domain.workflows.DirectiveChange import com.normation.rudder.domain.workflows.DirectiveChanges @@ -101,9 +102,6 @@ import io.scalaland.chimney.PartialTransformer import io.scalaland.chimney.Transformer import io.scalaland.chimney.partial.Result import io.scalaland.chimney.syntax.* -import net.liftweb.common.Empty -import net.liftweb.common.Failure -import net.liftweb.common.Full import scala.util.Try import zio.Chunk import zio.NonEmptyChunk @@ -198,6 +196,48 @@ object ChangeRequestJson { } } +final case class ChangeRequestInfoJson( + name: Option[String], + description: Option[String] +) { + + def updateCrInfo(crInfo: ChangeRequestInfo): ChangeRequestInfo = { + crInfo.copy( + name = name.getOrElse(crInfo.name), + description = description.getOrElse(crInfo.description) + ) + } +} + +object ChangeRequestInfoJson { + implicit val decoder: JsonDecoder[ChangeRequestInfoJson] = DeriveJsonDecoder.gen[ChangeRequestInfoJson] +} + +/** + * Class that represents the number of change requests that are currently in a "pending" status, i.e. + * "Pending validation" and "Pending deployment" respectively. + * Both fields are optional : either field will be present if and only if the user who made the request has + * the required authorization type, i.e. Validator.Read, and Deployer.Read authorizations respectively. + * + * @param pendingValidation the current number of change requests that have the "Pending validation" status + * @param pendingDeployment the current number of change requests that have the "Pending deployment" status + */ +final case class PendingCountJson( + pendingValidation: Option[Long], + pendingDeployment: Option[Long] +) + +object PendingCountJson { + implicit val encoder: JsonEncoder[PendingCountJson] = DeriveJsonEncoder.gen[PendingCountJson] + + def from(map: Map[WorkflowNodeId, Long]): PendingCountJson = { + PendingCountJson( + map.get(TwoValidationStepsWorkflowServiceImpl.Validation.id), + map.get(TwoValidationStepsWorkflowServiceImpl.Deployment.id) + ) + } +} + @jsonDiscriminator("action") sealed trait ActionChangeJson { def name: String = this match { case ActionChangeJson.create => "create" @@ -355,7 +395,7 @@ object DirectiveChangeJson { technique: Technique, diffService: DiffService ): PartialTransformer[ModifyToDirectiveDiff, DirectiveModifyChangeJson] = { - case (ModifyToDirectiveDiff(techniqueName, directive, rootSection), _) => + case (ModifyToDirectiveDiff(_, directive, rootSection), _) => val result = change.initialState match { case Some((techniqueName, initialState, section @ Some(initialRootSection))) => val diff = diffService.diffDirective(initialState, section, directive, rootSection, techniqueName) @@ -414,10 +454,11 @@ object DirectiveChangeActionJson { case d: DirectiveDeleteChangeJson => DirectiveChangeActionJson(ActionChangeJson.delete, d) case d: DirectiveModifyChangeJson => DirectiveChangeActionJson(ActionChangeJson.modify, d) }) match { - case Empty => - Result.fromErrorString(s"Error while serializing directives from CR ${change.firstChange.diff.directive.id.serialize}") - case Failure(msg, exception, chain) => Result.fromErrorString(msg) - case Full(value) => value + case Left(err) => + Result.fromErrorString( + s"Error while serializing directives from CR ${change.firstChange.diff.directive.id.serialize}: ${err.msg}" + ) + case Right(value) => value } } @@ -545,10 +586,11 @@ object RuleChangeActionJson { case d: RuleDeleteChangeJson => RuleChangeActionJson(ActionChangeJson.delete, d) case d: RuleModifyChangeJson => RuleChangeActionJson(ActionChangeJson.modify, d) }) match { - case Empty => - Result.fromErrorString(s"Error while serializing rules from CR ${change.firstChange.diff.rule.id.serialize}") - case Failure(msg, exception, chain) => Result.fromErrorString(msg) - case Full(value) => value + case Left(err) => + Result.fromErrorString( + s"Error while serializing rules from CR ${change.firstChange.diff.rule.id.serialize}: ${err.msg}" + ) + case Right(value) => value } } @@ -749,10 +791,11 @@ object GroupChangeActionJson { case d: GroupDeleteChangeJson => GroupChangeActionJson(ActionChangeJson.delete, d) case d: GroupModifyChangeJson => GroupChangeActionJson(ActionChangeJson.modify, d) }) match { - case Empty => - Result.fromErrorString(s"Error while serializing group from CR ${change.firstChange.diff.group.id.serialize}") - case Failure(msg, exception, chain) => Result.fromErrorString(msg) - case Full(value) => value + case Left(err) => + Result.fromErrorString( + s"Error while serializing group from CR ${change.firstChange.diff.group.id.serialize}: ${err.msg}" + ) + case Right(value) => value } } @@ -863,10 +906,11 @@ object GlobalParameterChangeActionJson { case d: GlobalParameterDeleteChangeJson => GlobalParameterChangeActionJson(ActionChangeJson.delete, d) case d: GlobalParameterModifyChangeJson => GlobalParameterChangeActionJson(ActionChangeJson.modify, d) }) match { - case Empty => - Result.fromErrorString(s"Error while serializing global parameter from CR ${change.firstChange.diff.parameter.name}") - case Failure(msg, exception, chain) => Result.fromErrorString(msg) - case Full(value) => value + case Left(err) => + Result.fromErrorString( + s"Error while serializing global parameter from CR ${change.firstChange.diff.parameter.name}: ${err.msg}" + ) + case Right(value) => value } } diff --git a/change-validation/src/main/scala/com/normation/plugins/changevalidation/ChangeRequestRepository.scala b/change-validation/src/main/scala/com/normation/plugins/changevalidation/ChangeRequestRepository.scala index 90aa125ae..aa321fd56 100644 --- a/change-validation/src/main/scala/com/normation/plugins/changevalidation/ChangeRequestRepository.scala +++ b/change-validation/src/main/scala/com/normation/plugins/changevalidation/ChangeRequestRepository.scala @@ -45,24 +45,23 @@ import com.normation.rudder.domain.policies.RuleUid import com.normation.rudder.domain.workflows.ChangeRequest import com.normation.rudder.domain.workflows.ChangeRequestId import com.normation.rudder.domain.workflows.WorkflowNodeId -import net.liftweb.common.Box /** * Read access to change request */ trait RoChangeRequestRepository { - def getAll(): Box[Vector[ChangeRequest]] + def getAll(): IOResult[Vector[ChangeRequest]] - def get(changeRequestId: ChangeRequestId): Box[Option[ChangeRequest]] + def get(changeRequestId: ChangeRequestId): IOResult[Option[ChangeRequest]] - def getByDirective(id: DirectiveUid, onlyPending: Boolean): Box[Vector[ChangeRequest]] + def getByDirective(id: DirectiveUid, onlyPending: Boolean): IOResult[Vector[ChangeRequest]] - def getByNodeGroup(id: NodeGroupId, onlyPending: Boolean): Box[Vector[ChangeRequest]] + def getByNodeGroup(id: NodeGroupId, onlyPending: Boolean): IOResult[Vector[ChangeRequest]] - def getByRule(id: RuleUid, onlyPending: Boolean): Box[Vector[ChangeRequest]] + def getByRule(id: RuleUid, onlyPending: Boolean): IOResult[Vector[ChangeRequest]] - def getByContributor(actor: EventActor): Box[Vector[ChangeRequest]] + def getByContributor(actor: EventActor): IOResult[Vector[ChangeRequest]] def getByFilter(filter: ChangeRequestFilter): IOResult[Vector[(ChangeRequest, WorkflowNodeId)]] @@ -72,35 +71,30 @@ trait RoChangeRequestRepository { * A proxy implementation simply delegating to either A or B implementation */ class EitherRoChangeRequestRepository( - cond: () => Box[Boolean], + cond: () => IOResult[Boolean], whenTrue: RoChangeRequestRepository, whenFalse: RoChangeRequestRepository ) extends RoChangeRequestRepository { - // remove some boilerplate to make following proxy implementation more readable, just exposing the actual method to call - private[this] def condApply[T](method: RoChangeRequestRepository => Box[T]): Box[T] = { - cond().flatMap(if (_) method(whenTrue) else method(whenFalse)) - } - private[this] def condApply[T](method: RoChangeRequestRepository => IOResult[T]): IOResult[T] = { - cond().toIO.flatMap(if (_) method(whenTrue) else method(whenFalse)) + cond().flatMap(if (_) method(whenTrue) else method(whenFalse)) } - def getAll(): Box[Vector[ChangeRequest]] = condApply(_.getAll()) + def getAll(): IOResult[Vector[ChangeRequest]] = condApply(_.getAll()) - def get(changeRequestId: ChangeRequestId): Box[Option[ChangeRequest]] = condApply(_.get(changeRequestId)) + def get(changeRequestId: ChangeRequestId): IOResult[Option[ChangeRequest]] = condApply(_.get(changeRequestId)) - def getByDirective(id: DirectiveUid, onlyPending: Boolean): Box[Vector[ChangeRequest]] = condApply( + def getByDirective(id: DirectiveUid, onlyPending: Boolean): IOResult[Vector[ChangeRequest]] = condApply( _.getByDirective(id, onlyPending) ) - def getByNodeGroup(id: NodeGroupId, onlyPending: Boolean): Box[Vector[ChangeRequest]] = condApply( + def getByNodeGroup(id: NodeGroupId, onlyPending: Boolean): IOResult[Vector[ChangeRequest]] = condApply( _.getByNodeGroup(id, onlyPending) ) - def getByRule(id: RuleUid, onlyPending: Boolean): Box[Vector[ChangeRequest]] = condApply(_.getByRule(id, onlyPending)) + def getByRule(id: RuleUid, onlyPending: Boolean): IOResult[Vector[ChangeRequest]] = condApply(_.getByRule(id, onlyPending)) - def getByContributor(actor: EventActor): Box[Vector[ChangeRequest]] = condApply(_.getByContributor(actor)) + def getByContributor(actor: EventActor): IOResult[Vector[ChangeRequest]] = condApply(_.getByContributor(actor)) def getByFilter(filter: ChangeRequestFilter): IOResult[Vector[(ChangeRequest, WorkflowNodeId)]] = condApply( _.getByFilter(filter) @@ -117,7 +111,7 @@ trait WoChangeRequestRepository { * The id is ignored, and a new one will be attributed * to the change request. */ - def createChangeRequest(changeRequest: ChangeRequest, actor: EventActor, reason: Option[String]): Box[ChangeRequest] + def createChangeRequest(changeRequest: ChangeRequest, actor: EventActor, reason: Option[String]): IOResult[ChangeRequest] /** * Update a change request. The change request must not @@ -126,13 +120,13 @@ trait WoChangeRequestRepository { * will be ignore), an explicit call to setWriteOnly must be * done for that. */ - def updateChangeRequest(changeRequest: ChangeRequest, actor: EventActor, reason: Option[String]): Box[ChangeRequest] + def updateChangeRequest(changeRequest: ChangeRequest, actor: EventActor, reason: Option[String]): IOResult[ChangeRequest] /** * Delete a change request. * (whatever the read/write mode is). */ - def deleteChangeRequest(changeRequestId: ChangeRequestId, actor: EventActor, reason: Option[String]): Box[ChangeRequest] + def deleteChangeRequest(changeRequestId: ChangeRequestId, actor: EventActor, reason: Option[String]): IOResult[ChangeRequest] } @@ -140,23 +134,27 @@ trait WoChangeRequestRepository { * Again, a proxy forwarding to an implementation based on a runtime property */ class EitherWoChangeRequestRepository( - cond: () => Box[Boolean], + cond: () => IOResult[Boolean], whenTrue: WoChangeRequestRepository, whenFalse: WoChangeRequestRepository ) extends WoChangeRequestRepository { - def createChangeRequest(changeRequest: ChangeRequest, actor: EventActor, reason: Option[String]): Box[ChangeRequest] = { + def createChangeRequest(changeRequest: ChangeRequest, actor: EventActor, reason: Option[String]): IOResult[ChangeRequest] = { cond().flatMap( if (_) whenTrue.createChangeRequest(changeRequest, actor, reason) else whenFalse.createChangeRequest(changeRequest, actor, reason) ) } - def updateChangeRequest(changeRequest: ChangeRequest, actor: EventActor, reason: Option[String]): Box[ChangeRequest] = { + def updateChangeRequest(changeRequest: ChangeRequest, actor: EventActor, reason: Option[String]): IOResult[ChangeRequest] = { cond().flatMap( if (_) whenTrue.updateChangeRequest(changeRequest, actor, reason) else whenFalse.updateChangeRequest(changeRequest, actor, reason) ) } - def deleteChangeRequest(changeRequestId: ChangeRequestId, actor: EventActor, reason: Option[String]): Box[ChangeRequest] = { + def deleteChangeRequest( + changeRequestId: ChangeRequestId, + actor: EventActor, + reason: Option[String] + ): IOResult[ChangeRequest] = { cond().flatMap( if (_) whenTrue.deleteChangeRequest(changeRequestId, actor, reason) else whenFalse.deleteChangeRequest(changeRequestId, actor, reason) diff --git a/change-validation/src/main/scala/com/normation/plugins/changevalidation/ChangeValidationPluginDef.scala b/change-validation/src/main/scala/com/normation/plugins/changevalidation/ChangeValidationPluginDef.scala index 5b541455a..cd58eedb4 100644 --- a/change-validation/src/main/scala/com/normation/plugins/changevalidation/ChangeValidationPluginDef.scala +++ b/change-validation/src/main/scala/com/normation/plugins/changevalidation/ChangeValidationPluginDef.scala @@ -37,10 +37,8 @@ package com.normation.plugins.changevalidation -import bootstrap.liftweb.Boot import bootstrap.liftweb.Boot.redirection import bootstrap.liftweb.ConfigResource -import bootstrap.liftweb.MenuUtils import bootstrap.rudder.plugin.ChangeValidationConf import com.normation.plugins.* import com.normation.rudder.AuthorizationType @@ -58,7 +56,6 @@ import net.liftweb.http.RedirectWithState import net.liftweb.http.RewriteRequest import net.liftweb.http.RewriteResponse import net.liftweb.sitemap.Loc.Hidden -import net.liftweb.sitemap.Loc.LocGroup import net.liftweb.sitemap.Loc.Template import net.liftweb.sitemap.Loc.TestAccess import net.liftweb.sitemap.LocPath.stringToLocPath @@ -72,18 +69,18 @@ class ChangeValidationPluginDef(override val status: PluginStatus) extends Defau // URL rewrites LiftRules.statefulRewrite.append { case RewriteRequest( - ParsePath("secure" :: "plugins" :: "changes" :: "changeRequests" :: filter :: Nil, _, _, _), + ParsePath("secure" :: "configurationManager" :: "changes" :: "changeRequests" :: filter :: Nil, _, _, _), GetRequest, _ ) => { - RewriteResponse("secure" :: "plugins" :: "changes" :: "changeRequests" :: Nil, Map("filter" -> filter)) + RewriteResponse("secure" :: "configurationManager" :: "changes" :: "changeRequests" :: Nil, Map("filter" -> filter)) } case RewriteRequest( - ParsePath("secure" :: "plugins" :: "changes" :: "changeRequest" :: crId :: Nil, _, _, _), + ParsePath("secure" :: "configurationManager" :: "changes" :: "changeRequest" :: crId :: Nil, _, _, _), GetRequest, _ ) => - RewriteResponse("secure" :: "plugins" :: "changes" :: "changeRequest" :: Nil, Map("crId" -> crId)) + RewriteResponse("secure" :: "configurationManager" :: "changes" :: "changeRequest" :: Nil, Map("crId" -> crId)) } // init directory to save JSON @@ -107,41 +104,25 @@ class ChangeValidationPluginDef(override val status: PluginStatus) extends Defau CurrentUser.checkRights(AuthorizationType.Deployer.Read) (Menu("changeRequests", Change Requests) / - "secure" / "plugins" / "changes" - >> LocGroup("changeValidation") + "secure" / "configurationManager" / "changes" >> Hidden >> TestAccess(() => { if (status.isEnabled() && canViewPage) Empty else - Full(RedirectWithState("/secure/utilities/eventLogs", redirection)) + Full(RedirectWithState("/secure/index", redirection)) })).submenus( Menu("changeRequestsList", Change requests) / - "secure" / "plugins" / "changes" / "changeRequests" + "secure" / "configurationManager" / "changes" / "changeRequests" >> Hidden >> Template(() => ClasspathTemplates("template" :: "changeRequests" :: Nil) openOr
Template not found
), Menu("changeRequestDetails", Change request) / - "secure" / "plugins" / "changes" / "changeRequest" + "secure" / "configurationManager" / "changes" / "changeRequest" >> Hidden >> Template(() => ClasspathTemplates("template" :: "changeRequest" :: Nil) openOr
Template not found
) ) } - override def pluginMenuEntry: List[(Menu, Option[String])] = { - ( - ( - Menu("770-changeValidationManagement", Change validation) / - "secure" / "plugins" / "changeValidationManagement" - >> LocGroup("pluginsGroup") - >> TestAccess(() => Boot.userIsAllowed("/secure/index", AuthorizationType.Administration.Read)) - >> Template(() => - ClasspathTemplates("template" :: "ChangeValidationManagement" :: Nil) openOr
Template not found
- ) - ).toMenu, - Some(MenuUtils.administrationMenu) - ) :: Nil - } - override def updateSiteMap(menus: List[Menu]): List[Menu] = { super.updateSiteMap(menus) :+ changeRequestValidationMenu } diff --git a/change-validation/src/main/scala/com/normation/plugins/changevalidation/DataTypes.scala b/change-validation/src/main/scala/com/normation/plugins/changevalidation/DataTypes.scala index f335e5200..0708ab128 100644 --- a/change-validation/src/main/scala/com/normation/plugins/changevalidation/DataTypes.scala +++ b/change-validation/src/main/scala/com/normation/plugins/changevalidation/DataTypes.scala @@ -50,7 +50,6 @@ import com.normation.rudder.repository.FullNodeGroupCategory import io.scalaland.chimney.Transformer import net.liftweb.common.Logger import org.slf4j.LoggerFactory -import scala.annotation.nowarn import scala.collection.immutable.SortedSet import zio.NonEmptyChunk import zio.json.* @@ -167,7 +166,7 @@ trait TargetJsonCodec { implicit val unsupervisedTargetIdsEncoder: JsonEncoder[UnsupervisedTargetIds] = DeriveJsonEncoder.gen[UnsupervisedTargetIds] implicit val unsupervisedTargetIdsDecoder: JsonDecoder[UnsupervisedTargetIds] = { - @nowarn implicit val simpleTargetDecoder: JsonDecoder[SimpleTarget] = JsonDecoder[String].mapOrFail(s => { + implicit val simpleTargetDecoder: JsonDecoder[SimpleTarget] = JsonDecoder[String].mapOrFail(s => { RuleTarget .unser(s) .collect { case t: SimpleTarget => t } @@ -177,7 +176,7 @@ trait TargetJsonCodec { } implicit val supervisedSimpleTargetsDecoder: JsonDecoder[SupervisedSimpleTargets] = { - @nowarn implicit val loggedSimpleTargetDecoder: JsonDecoder[SimpleTarget] = { + implicit val loggedSimpleTargetDecoder: JsonDecoder[SimpleTarget] = { JsonDecoder[String].mapOrFail(s => { RuleTarget .unser(s) diff --git a/change-validation/src/main/scala/com/normation/plugins/changevalidation/TopBarExtension.scala b/change-validation/src/main/scala/com/normation/plugins/changevalidation/TopBarExtension.scala index b7f467e5c..52536f96d 100644 --- a/change-validation/src/main/scala/com/normation/plugins/changevalidation/TopBarExtension.scala +++ b/change-validation/src/main/scala/com/normation/plugins/changevalidation/TopBarExtension.scala @@ -2,12 +2,17 @@ package com.normation.plugins.changevalidation import com.normation.plugins.PluginExtensionPoint import com.normation.plugins.PluginStatus +import com.normation.rudder.AuthorizationType +import com.normation.rudder.users.CurrentUser import com.normation.rudder.web.snippet.CommonLayout import net.liftweb.common.Loggable import net.liftweb.util.Helpers.* import scala.reflect.ClassTag import scala.xml.NodeSeq +/** + * Load the change-validation scripts, css and async comet actor : to display it in the top bar + */ class TopBarExtension(val status: PluginStatus)(implicit val ttag: ClassTag[CommonLayout]) extends PluginExtensionPoint[CommonLayout] with Loggable { @@ -15,10 +20,24 @@ class TopBarExtension(val status: PluginStatus)(implicit val ttag: ClassTag[Comm "display" -> render _ ) + /** + * User must have either or both of the Validator.Read and Deployer.Read authorizations + * in order to display this. + */ def render(xml: NodeSeq) = { - ( - "#rudder-navbar -*" #>
  • - ).apply(xml) + val isValidator = CurrentUser.checkRights(AuthorizationType.Validator.Read) + val isDeployer = CurrentUser.checkRights(AuthorizationType.Deployer.Read) + if (isValidator || isDeployer) { + ( + "#rudder-navbar -*" #> + + + + + & "#rudder-navbar -*" #> +
  • + ).apply(xml) + } else xml } } diff --git a/change-validation/src/main/scala/com/normation/plugins/changevalidation/ValidationNeeded.scala b/change-validation/src/main/scala/com/normation/plugins/changevalidation/ValidationNeeded.scala index 3e32e9f00..8b96c8cd9 100644 --- a/change-validation/src/main/scala/com/normation/plugins/changevalidation/ValidationNeeded.scala +++ b/change-validation/src/main/scala/com/normation/plugins/changevalidation/ValidationNeeded.scala @@ -37,7 +37,6 @@ package com.normation.plugins.changevalidation -import com.normation.box.* import com.normation.errors.IOResult import com.normation.eventlog.EventActor import com.normation.inventory.domain.NodeId @@ -52,9 +51,9 @@ import com.normation.rudder.services.workflows.DirectiveChangeRequest import com.normation.rudder.services.workflows.GlobalParamChangeRequest import com.normation.rudder.services.workflows.NodeGroupChangeRequest import com.normation.rudder.services.workflows.RuleChangeRequest -import net.liftweb.common.Box -import net.liftweb.common.Full import scala.collection.MapView +import zio.ZIO +import zio.syntax.ToZio object bddMock { val USER_AUTH_NEEDED = Map( @@ -71,10 +70,10 @@ object bddMock { * (see https://issues.rudder.io/issues/22188#note-5) */ trait ValidationNeeded { - def forRule(actor: EventActor, change: RuleChangeRequest): Box[Boolean] - def forDirective(actor: EventActor, change: DirectiveChangeRequest): Box[Boolean] - def forNodeGroup(actor: EventActor, change: NodeGroupChangeRequest): Box[Boolean] - def forGlobalParam(actor: EventActor, change: GlobalParamChangeRequest): Box[Boolean] + def forRule(actor: EventActor, change: RuleChangeRequest): IOResult[Boolean] + def forDirective(actor: EventActor, change: DirectiveChangeRequest): IOResult[Boolean] + def forNodeGroup(actor: EventActor, change: NodeGroupChangeRequest): IOResult[Boolean] + def forGlobalParam(actor: EventActor, change: GlobalParamChangeRequest): IOResult[Boolean] } /* @@ -109,22 +108,25 @@ class NodeGroupValidationNeeded( * - rule R is changed to add Group2 in its target (or opposite change: Group2 removed) * - the change must be validated. */ - override def forRule(actor: EventActor, change: RuleChangeRequest): Box[Boolean] = { - val start = System.currentTimeMillis() - val res = (for { + override def forRule(actor: EventActor, change: RuleChangeRequest): IOResult[Boolean] = { + for { + start <- com.normation.zio.currentTimeMillis groups <- groupLib.getFullGroupLibrary() // I think it's ok to have that, it will need a deeper change when we will want to have per-tenant change validation arePolicyServer <- nodeFactRepo.getAll()(QueryContext.systemQC) supervised <- supervisedTargets() + targets = Set(change.newRule) ++ change.previousRule.toSet + res = checkNodeTargetByRule(groups, arePolicyServer.mapValues(_.rudderSettings.isPolicyServer), supervised, targets) + end <- com.normation.zio.currentTimeMillis + _ <- { + ChangeValidationLoggerPure.Metrics.debug( + s"Check rule '${change.newRule.name}' [${change.newRule.id.serialize}]" + + s"change requestion need for validation in ${end - start}ms" + ) + } } yield { - val targets = Set(change.newRule) ++ change.previousRule.toSet - checkNodeTargetByRule(groups, arePolicyServer.mapValues(_.rudderSettings.isPolicyServer), supervised, targets) - }).toBox - ChangeValidationLogger.Metrics.debug( - s"Check rule '${change.newRule.name}' [${change.newRule.id.serialize}] change requestion need for validation in ${System - .currentTimeMillis() - start}ms" - ) - res + res + } } /** @@ -161,7 +163,7 @@ class NodeGroupValidationNeeded( * belong to an other group) * - now the rule is applied to supervised node, but no validation was done. */ - override def forNodeGroup(actor: EventActor, change: NodeGroupChangeRequest): Box[Boolean] = { + override def forNodeGroup(actor: EventActor, change: NodeGroupChangeRequest): IOResult[Boolean] = { // Here we need to test the future content of the group, and not the current one. // So we need to know: // - the list of supervised node in the group before the change, @@ -169,61 +171,70 @@ class NodeGroupValidationNeeded( // - the list of supervised node in the group after change, // => non empty means validation needed - val start = System.currentTimeMillis() - - val res = (for { + for { + start <- com.normation.zio.currentTimeMillis groups <- groupLib.getFullGroupLibrary() nodeFacts <- nodeFactRepo.getAll()(QueryContext.systemQC) supervised <- supervisedTargets() - } yield { - val targetNodes = change.newGroup.serverList ++ change.previousGroup.map(_.serverList).getOrElse(Set()) - val exists = groups - .getNodeIds(supervised.map(identity), nodeFacts.mapValues(_.rudderSettings.isPolicyServer)) - .find(nodeId => targetNodes.contains(nodeId)) + targetNodes = change.newGroup.serverList ++ change.previousGroup.map(_.serverList).getOrElse(Set()) + exists = groups + .getNodeIds(supervised.map(identity), nodeFacts.mapValues(_.rudderSettings.isPolicyServer)) + .find(nodeId => targetNodes.contains(nodeId)) + res <- + // we want to let the log know why the change request needs validation + ZIO + .foreach(exists) { nodeId => + ChangeValidationLoggerPure + .debug( + s"Node '${nodeId.value}' belongs to both a supervised group and to group '${change.newGroup.name}' [${change.newGroup.id.serialize}]" + ) + } + .map(_.nonEmpty) - // we want to let the log knows why the change request need validation - exists.foreach { nodeId => - ChangeValidationLogger.debug( - s"Node '${nodeId.value}' belongs to both a supervised group and to group '${change.newGroup.name}' [${change.newGroup.id.serialize}]" + end <- com.normation.zio.currentTimeMillis + _ <- { + ChangeValidationLoggerPure.Metrics.debug( + s"Check group '${change.newGroup.name}' [${change.newGroup.id.serialize}] " + + s"change requestion need for validation in ${end - start}ms" ) } - exists.nonEmpty - }).toBox - ChangeValidationLogger.Metrics.debug( - s"Check group '${change.newGroup.name}' [${change.newGroup.id.serialize}] change requestion need for validation in ${System - .currentTimeMillis() - start}ms" - ) - res + } yield { + res + } } /* * A directive need a validation if any rule using it need a validation. */ - override def forDirective(actor: EventActor, change: DirectiveChangeRequest): Box[Boolean] = { - // in a change, the old directive id and the new one is the same. - val directiveId = change.newDirective.id - val start = System.currentTimeMillis() - val res = (for { + override def forDirective(actor: EventActor, change: DirectiveChangeRequest): IOResult[Boolean] = { + for { + start <- com.normation.zio.currentTimeMillis + // in a change, the old directive id and the new one is the same. + directiveId = change.newDirective.id rules <- ruleLib.getAll(includeSytem = true).map(_.filter(r => r.directiveIds.contains(directiveId))) // we need to add potentially new rules applied to that directive that the previous request does not cover newRules = change.updatedRules supervised <- supervisedTargets() groups <- groupLib.getFullGroupLibrary() nodeFacts <- nodeFactRepo.getAll()(QueryContext.systemQC) + res = + checkNodeTargetByRule(groups, nodeFacts.mapValues(_.rudderSettings.isPolicyServer), supervised, (rules ++ newRules).toSet) + end <- com.normation.zio.currentTimeMillis + _ <- { + ChangeValidationLoggerPure.Metrics.debug( + s"Check directive '${change.newDirective.name}' [${change.newDirective.id.uid.serialize}]" + + s"change requestion need for validation in ${end - start}ms" + ) + } } yield { - checkNodeTargetByRule(groups, nodeFacts.mapValues(_.rudderSettings.isPolicyServer), supervised, (rules ++ newRules).toSet) - }).toBox - ChangeValidationLogger.Metrics.debug( - s"Check directive '${change.newDirective.name}' [${change.newDirective.id.uid.serialize}] change requestion need for validation in ${System - .currentTimeMillis() - start}ms" - ) - res + res + } } /* * For a global parameter, we just answer "yes" */ - override def forGlobalParam(actor: EventActor, change: GlobalParamChangeRequest): Box[Boolean] = { - Full(true) + override def forGlobalParam(actor: EventActor, change: GlobalParamChangeRequest): IOResult[Boolean] = { + true.succeed } } diff --git a/change-validation/src/main/scala/com/normation/plugins/changevalidation/WorkflowJdbcRepository.scala b/change-validation/src/main/scala/com/normation/plugins/changevalidation/WorkflowJdbcRepository.scala index 14ceedecc..c1085d596 100644 --- a/change-validation/src/main/scala/com/normation/plugins/changevalidation/WorkflowJdbcRepository.scala +++ b/change-validation/src/main/scala/com/normation/plugins/changevalidation/WorkflowJdbcRepository.scala @@ -36,31 +36,36 @@ */ package com.normation.plugins.changevalidation +import cats.data.NonEmptyList import cats.implicits.* +import com.normation.errors.IOResult import com.normation.rudder.db.Doobie import com.normation.rudder.domain.workflows.ChangeRequestId import com.normation.rudder.domain.workflows.WorkflowNodeId import doobie.* import doobie.implicits.* -import net.liftweb.common.Box +import doobie.util.fragments import net.liftweb.common.Loggable +import zio.ZIO import zio.interop.catz.* /** * Repository to manage the Workflow part */ trait RoWorkflowRepository { - def getAllByState(state: WorkflowNodeId): Box[Seq[ChangeRequestId]] + def getAllByState(state: WorkflowNodeId): IOResult[Seq[ChangeRequestId]] - def getStateOfChangeRequest(crId: ChangeRequestId): Box[WorkflowNodeId] + def getCountByState(filter: NonEmptyList[WorkflowNodeId]): IOResult[Map[WorkflowNodeId, Long]] - def getAllChangeRequestsState(): Box[Map[ChangeRequestId, WorkflowNodeId]] + def getStateOfChangeRequest(crId: ChangeRequestId): IOResult[WorkflowNodeId] + + def getAllChangeRequestsState(): IOResult[Map[ChangeRequestId, WorkflowNodeId]] } trait WoWorkflowRepository { - def createWorkflow(crId: ChangeRequestId, state: WorkflowNodeId): Box[WorkflowNodeId] + def createWorkflow(crId: ChangeRequestId, state: WorkflowNodeId): IOResult[WorkflowNodeId] - def updateState(crId: ChangeRequestId, from: WorkflowNodeId, state: WorkflowNodeId): Box[WorkflowNodeId] + def updateState(crId: ChangeRequestId, from: WorkflowNodeId, state: WorkflowNodeId): IOResult[WorkflowNodeId] } trait RoWorkflowJdbcRepositorySQL { @@ -68,6 +73,12 @@ trait RoWorkflowJdbcRepositorySQL { sql"select id from workflow where state = $state".query[ChangeRequestId] } + def getCountByStateSQL(filter: NonEmptyList[WorkflowNodeId]): Query0[(WorkflowNodeId, Long)] = { + val f = fragments.in(fr"state", filter) + sql"select state,count(distinct(id)) from workflow where ${f} group by state" + .query[(WorkflowNodeId, Long)] + } + def getStateOfChangeRequestSQL(crId: ChangeRequestId): Query0[WorkflowNodeId] = { sql"select state from workflow where id = $crId".query[WorkflowNodeId] } @@ -92,16 +103,38 @@ class RoWorkflowJdbcRepository(doobie: Doobie) extends RoWorkflowRepository with import WorkflowJdbcRepositorySQL.* import doobie.* - def getAllByState(state: WorkflowNodeId): Box[Seq[ChangeRequestId]] = { - transactRunBox(xa => getAllByStateSQL(state).to[Vector].transact(xa)) + def getAllByState(state: WorkflowNodeId): IOResult[Seq[ChangeRequestId]] = { + transactIOResult(s"Could not get change request with state ${state.value}")(xa => + getAllByStateSQL(state).to[Vector].transact(xa) + ) + } + + /** + * Returns the number of change requests for each state in the given filter. + * If there are no existing change requests for a given state in the filter, the count for this state will be 0. + * @param filter + * @return + */ + override def getCountByState(filter: NonEmptyList[WorkflowNodeId]): IOResult[Map[WorkflowNodeId, Long]] = { + transactIOResult("Could not get total count of change requests in each state")(xa => { + for { + req <- getCountByStateSQL(filter).to[Vector].transact(xa).map(_.toMap) + initMap = filter.map((_, 0L)).toList.toMap + res = req.combine(initMap) + } yield { + res + } + }) } - def getStateOfChangeRequest(crId: ChangeRequestId): Box[WorkflowNodeId] = { - transactRunBox(xa => getStateOfChangeRequestSQL(crId).unique.transact(xa)) + def getStateOfChangeRequest(crId: ChangeRequestId): IOResult[WorkflowNodeId] = { + transactIOResult(s"Could not get state of change request with id ${crId.value}")(xa => + getStateOfChangeRequestSQL(crId).unique.transact(xa) + ) } - def getAllChangeRequestsState(): Box[Map[ChangeRequestId, WorkflowNodeId]] = { - transactRunBox(xa => getAllChangeRequestsStateSQL.to[Vector].transact(xa)) + def getAllChangeRequestsState(): IOResult[Map[ChangeRequestId, WorkflowNodeId]] = { + transactIOResult("Could not get states of all change requests")(xa => getAllChangeRequestsStateSQL.to[Vector].transact(xa)) .map(_.toMap) } } @@ -110,53 +143,64 @@ class WoWorkflowJdbcRepository(doobie: Doobie) extends WoWorkflowRepository with import WorkflowJdbcRepositorySQL.* import doobie.* - def createWorkflow(crId: ChangeRequestId, state: WorkflowNodeId): Box[WorkflowNodeId] = { + def createWorkflow(crId: ChangeRequestId, state: WorkflowNodeId): IOResult[WorkflowNodeId] = { val process = { for { - exists <- getStateOfChangeRequestSQL(crId).option - created <- exists match { - case None => createWorkflowSQL(crId, state).run.attempt - case Some(s) => - val msg = - s"Cannot start a workflow for Change Request id ${crId.value}, as it is already part of a workflow in state '${s}'" - ChangeValidationLogger.error(msg) - (Left(msg)).pure[ConnectionIO] - } + exists <- getStateOfChangeRequestSQL(crId).option + _ <- exists match { + case None => createWorkflowSQL(crId, state).run.attempt + case Some(s) => + val msg = + s"Cannot start a workflow for Change Request id ${crId.value}, as it is already part of a workflow in state '${s}'" + ChangeValidationLogger.error(msg) + (Left(msg)).pure[ConnectionIO] + } } yield { state } } - transactRunBox(xa => process.transact(xa)) + transactIOResult(s"Could not create workflow with id ${crId.value} and state ${state.value}")(xa => process.transact(xa)) } - def updateState(crId: ChangeRequestId, from: WorkflowNodeId, state: WorkflowNodeId): Box[WorkflowNodeId] = { - val process = { + def updateState(crId: ChangeRequestId, from: WorkflowNodeId, state: WorkflowNodeId): IOResult[WorkflowNodeId] = { + val process: ConnectionIO[Either[String, Unit]] = { for { - exists <- getStateOfChangeRequestSQL(crId).option - created <- exists match { - case Some(s) => - if (s == from) { - updateStateSQL( - crId, - from, - state - ).run.attempt // swallows any "constraint violation" error if CrId is not in the ChangeRequest table - } else { - val msg = s"Cannot change status of ChangeRequest '${crId.value}': it has the status '${s.value}' " + - s"but we were expecting '${from.value}'. Perhaps someone else changed it concurrently?" - ChangeValidationLogger.error(msg) - (Left(msg).pure[ConnectionIO]) - } - case None => - val msg = - s"Cannot change a workflow for Change Request id ${crId.value}, as it is not part of any workflow yet" - ChangeValidationLogger.error(msg) - (Left(msg).pure[ConnectionIO]) - } + exists <- getStateOfChangeRequestSQL(crId).option + update <- exists match { + case Some(s) => + if (s == from) { + updateStateSQL( + crId, + from, + state + ).run.attempt.map( + _.bimap( + err => err.getMessage, + _ => () + ) + ) + } else { + val msg = s"Cannot change status of ChangeRequest '${crId.value}': it has status '${s.value}' " + + s"but the expected status was '${from.value}'. Perhaps someone else changed it concurrently?" + Left(msg).pure[ConnectionIO] + } + case None => + val msg = + s"Cannot change a workflow for Change Request id ${crId.value}, as it is not part of any workflow yet" + Left(msg).pure[ConnectionIO] + } } yield { - state + update } } - transactRunBox(xa => process.transact(xa)) + + for { + res <- transactIOResult( + s"Could not update state of change request with id ${crId.value} from ${from.value} to ${state.value}" + )(xa => process.transact(xa)) + _ <- ZIO.whenCase(res) { case Left(err) => ChangeValidationLoggerPure.error(err) } + } yield { + state + } } } diff --git a/change-validation/src/main/scala/com/normation/plugins/changevalidation/WorkflowService.scala b/change-validation/src/main/scala/com/normation/plugins/changevalidation/WorkflowService.scala index 952384212..a83bec735 100644 --- a/change-validation/src/main/scala/com/normation/plugins/changevalidation/WorkflowService.scala +++ b/change-validation/src/main/scala/com/normation/plugins/changevalidation/WorkflowService.scala @@ -37,7 +37,6 @@ package com.normation.plugins.changevalidation -import com.normation.box.* import com.normation.errors.* import com.normation.eventlog.EventActor import com.normation.eventlog.ModificationId @@ -60,29 +59,30 @@ import com.normation.rudder.services.workflows.WorkflowService import com.normation.rudder.services.workflows.WorkflowUpdate import com.normation.rudder.users.UserService import com.normation.utils.StringUuidGenerator -import net.liftweb.common.* +import com.normation.zio.UnsafeRun import org.joda.time.DateTime import zio.* +import zio.syntax.ToZio /** * A proxy workflow service based on a runtime choice */ -class EitherWorkflowService(cond: () => Box[Boolean], whenTrue: WorkflowService, whenFalse: WorkflowService) +class EitherWorkflowService(cond: () => IOResult[Boolean], whenTrue: WorkflowService, whenFalse: WorkflowService) extends WorkflowService { // TODO: handle ERRORS for config! val name = "choose-active-validation-workflow" - def current: WorkflowService = if (cond().getOrElse(false)) whenTrue else whenFalse + def current: WorkflowService = if (cond().orElseSucceed(false).runNow) whenTrue else whenFalse - override def startWorkflow(changeRequest: ChangeRequest)(implicit cc: ChangeContext): Box[ChangeRequestId] = + override def startWorkflow(changeRequest: ChangeRequest)(implicit cc: ChangeContext): IOResult[ChangeRequestId] = current.startWorkflow(changeRequest) - override def openSteps: List[WorkflowNodeId] = + override def openSteps: List[WorkflowNodeId] = current.openSteps - override def closedSteps: List[WorkflowNodeId] = + override def closedSteps: List[WorkflowNodeId] = current.closedSteps - override def stepsValue: List[WorkflowNodeId] = + override def stepsValue: List[WorkflowNodeId] = current.stepsValue override def findNextSteps(currentUserRights: Seq[String], currentStep: WorkflowNodeId, isCreator: Boolean)(implicit qc: QueryContext @@ -92,17 +92,17 @@ class EitherWorkflowService(cond: () => Box[Boolean], whenTrue: WorkflowService, currentUserRights: Seq[String], currentStep: WorkflowNodeId, isCreator: Boolean - ): Seq[(WorkflowNodeId, (ChangeRequestId, EventActor, Option[String]) => Box[WorkflowNodeId])] = + ): Seq[(WorkflowNodeId, (ChangeRequestId, EventActor, Option[String]) => IOResult[WorkflowNodeId])] = current.findBackSteps(currentUserRights, currentStep, isCreator) - override def findStep(changeRequestId: ChangeRequestId): Box[WorkflowNodeId] = + override def findStep(changeRequestId: ChangeRequestId): IOResult[WorkflowNodeId] = current.findStep(changeRequestId) - override def getAllChangeRequestsStep(): Box[Map[ChangeRequestId, WorkflowNodeId]] = + override def getAllChangeRequestsStep(): IOResult[Map[ChangeRequestId, WorkflowNodeId]] = current.getAllChangeRequestsStep() - override def isEditable(currentUserRights: Seq[String], currentStep: WorkflowNodeId, isCreator: Boolean): Boolean = + override def isEditable(currentUserRights: Seq[String], currentStep: WorkflowNodeId, isCreator: Boolean): Boolean = current.isEditable(currentUserRights, currentStep, isCreator) - override def isPending(currentStep: WorkflowNodeId): Boolean = + override def isPending(currentStep: WorkflowNodeId): Boolean = current.isPending(currentStep) - override def needExternalValidation(): Boolean = current.needExternalValidation() + override def needExternalValidation(): Boolean = current.needExternalValidation() } object TwoValidationStepsWorkflowServiceImpl { @@ -137,15 +137,15 @@ class TwoValidationStepsWorkflowServiceImpl( woChangeRequestRepository: WoChangeRequestRepository, notificationService: NotificationService, userService: UserService, - workflowEnable: () => Box[Boolean], - selfValidation: () => Box[Boolean], - selfDeployment: () => Box[Boolean] + workflowEnable: () => IOResult[Boolean], + selfValidation: () => IOResult[Boolean], + selfDeployment: () => IOResult[Boolean] ) extends WorkflowService { import TwoValidationStepsWorkflowServiceImpl.* val name = "two-steps-validation-workflow" - def getItemsInStep(stepId: WorkflowNodeId): Box[Seq[ChangeRequestId]] = roWorkflowRepo.getAllByState(stepId) + def getItemsInStep(stepId: WorkflowNodeId): IOResult[Seq[ChangeRequestId]] = roWorkflowRepo.getAllByState(stepId) val closedSteps: List[WorkflowNodeId] = List(Cancelled.id, Deployed.id) val openSteps: List[WorkflowNodeId] = List(Validation.id, Deployment.id) @@ -166,14 +166,15 @@ class TwoValidationStepsWorkflowServiceImpl( } for { - saved <- save ?~! s"could not save change request ${changeRequest.info.name}" + saved <- save.chainError(s"could not save change request ${changeRequest.info.name}") modId = ModificationId(uuidGen.newUuid) workflowEnable <- workflowEnable() _ <- if (workflowEnable) { - changeRequestEventLogService.saveChangeRequestLog(modId, actor, saved, reason) ?~! - s"could not save event log for change request ${saved.changeRequest.id} creation" + changeRequestEventLogService + .saveChangeRequestLog(modId, actor, saved, reason) + .chainError(s"could not save event log for change request ${saved.changeRequest.id} creation") } else { - Full("OK, no workflow") + "OK, no workflow".succeed } } yield { saved.changeRequest } } @@ -183,7 +184,7 @@ class TwoValidationStepsWorkflowServiceImpl( newInfo: ChangeRequestInfo, actor: EventActor, reason: Option[String] - ): Box[ChangeRequest] = { + ): IOResult[ChangeRequest] = { val newCr = ChangeRequest.updateInfo(oldChangeRequest, newInfo) saveAndLogChangeRequest(ModifyToChangeRequestDiff(newCr, oldChangeRequest), actor, reason) } @@ -198,7 +199,7 @@ class TwoValidationStepsWorkflowServiceImpl( isCreator: Boolean )(implicit qc: QueryContext): WorkflowAction = { - def deployAction(action: (ChangeRequestId, EventActor, Option[String]) => Box[WorkflowNodeId]) = { + def deployAction(action: (ChangeRequestId, EventActor, Option[String]) => IOResult[WorkflowNodeId]) = { if (canDeploy(isCreator, selfDeployment)) Seq((Deployed.id, action)) else Seq() @@ -230,7 +231,7 @@ class TwoValidationStepsWorkflowServiceImpl( currentUserRights: Seq[String], currentStep: WorkflowNodeId, isCreator: Boolean - ): Seq[(WorkflowNodeId, (ChangeRequestId, EventActor, Option[String]) => Box[WorkflowNodeId])] = { + ): Seq[(WorkflowNodeId, (ChangeRequestId, EventActor, Option[String]) => IOResult[WorkflowNodeId])] = { currentStep match { case Validation.id => if (canValidate(isCreator, selfValidation)) @@ -266,7 +267,7 @@ class TwoValidationStepsWorkflowServiceImpl( } } - def isPending(currentStep: WorkflowNodeId): Boolean = { + def isPending(currentStep: WorkflowNodeId): Boolean = { currentStep match { case Validation.id => true case Deployment.id => true @@ -279,15 +280,15 @@ class TwoValidationStepsWorkflowServiceImpl( false } } - def findStep(changeRequestId: ChangeRequestId): Box[WorkflowNodeId] = { + def findStep(changeRequestId: ChangeRequestId): IOResult[WorkflowNodeId] = { roWorkflowRepo.getStateOfChangeRequest(changeRequestId) } - def getAllChangeRequestsStep(): Box[Map[ChangeRequestId, WorkflowNodeId]] = { + def getAllChangeRequestsStep(): IOResult[Map[ChangeRequestId, WorkflowNodeId]] = { roWorkflowRepo.getAllChangeRequestsState() } - def startWorkflow(changeRequest: ChangeRequest)(implicit cc: ChangeContext): Box[ChangeRequestId] = { + def startWorkflow(changeRequest: ChangeRequest)(implicit cc: ChangeContext): IOResult[ChangeRequestId] = { ChangeValidationLogger.debug(s"${name}: start workflow for change request '${changeRequest.id.value}'") for { saved <- saveAndLogChangeRequest(AddChangeRequestDiff(changeRequest), cc.actor, cc.message) @@ -305,7 +306,7 @@ class TwoValidationStepsWorkflowServiceImpl( changeRequestId: ChangeRequestId, actor: EventActor, reason: Option[String] - ): Box[WorkflowNodeId] = { + ): IOResult[WorkflowNodeId] = { (for { state <- woWorkflowRepo.updateState(changeRequestId, from.id, to.id) workflowStep = WorkflowStepChange(changeRequestId, from.id, to.id) @@ -314,13 +315,10 @@ class TwoValidationStepsWorkflowServiceImpl( } yield { workflowComet ! WorkflowUpdate state - }) match { - case Full(state) => Full(state) - case eb: EmptyBox => - val e = eb ?~! s"Error when changing step in workflow for Change Request ${changeRequestId.value}" - ChangeValidationLogger.error(e.messageChain) - e - } + }) + .chainError(s"Error when changing step in workflow for Change Request ${changeRequestId.value}") + .tapError(err => ChangeValidationLoggerPure.error(err.fullMsg)) + } /* @@ -329,13 +327,13 @@ class TwoValidationStepsWorkflowServiceImpl( * - check for current user rights. * WARNING: WE ARE SIDE STEPPING authz check until https://issues.rudder.io/issues/22595 is solved */ - private def canValidate(isCreator: Boolean, selfValidation: () => Box[Boolean]): Boolean = { - val correctActor = selfValidation().getOrElse(false) || !isCreator + private def canValidate(isCreator: Boolean, selfValidation: () => IOResult[Boolean]): Boolean = { + val correctActor = selfValidation().orElseSucceed(false).runNow || !isCreator correctActor && userService.getCurrentUser.checkRights(AuthorizationType.Validator.Edit) } - private def canDeploy(isCreator: Boolean, selfDeployment: () => Box[Boolean]): Boolean = { - val correctActor = selfDeployment().getOrElse(false) || !isCreator + private def canDeploy(isCreator: Boolean, selfDeployment: () => IOResult[Boolean]): Boolean = { + val correctActor = selfDeployment().orElseSucceed(false).runNow || !isCreator correctActor && userService.getCurrentUser.checkRights(AuthorizationType.Deployer.Edit) } @@ -347,7 +345,6 @@ class TwoValidationStepsWorkflowServiceImpl( for { cr <- roChangeRequestRepository .get(changeRequestId) - .toIO .notOptional( s"Change request with ID '${changeRequestId.value}' was not found in database" ) @@ -372,13 +369,10 @@ class TwoValidationStepsWorkflowServiceImpl( */ implicit class CatchEmailError(result: IOResult[Unit]) { def catchEmailError(from: String, to: String): Unit = { - result.toBox match { - case eb: EmptyBox => - val msg = - (eb ?~! s"Error when trying to send email for change request status update from '${from}' to '${to}'").messageChain - ChangeValidationLogger.error(msg) - case Full(_) => // nothing - } + result + .chainError(s"Error when trying to send email for change request status update from '${from}' to '${to}'") + .catchAll(err => ChangeValidationLoggerPure.error(err.fullMsg)) + .runNow } } @@ -389,11 +383,12 @@ class TwoValidationStepsWorkflowServiceImpl( reason: Option[String] )(implicit qc: QueryContext - ): Box[WorkflowNodeId] = { + ): IOResult[WorkflowNodeId] = { ChangeValidationLogger.debug(s"${name}: deploy change for change request '${changeRequestId.value}'") for { - optcr <- roChangeRequestRepository.get(changeRequestId) - cr <- Box(optcr) ?~! s"Change request with ID '${changeRequestId.value}' was not found in database" + cr <- roChangeRequestRepository + .get(changeRequestId) + .notOptional(s"Change request with ID '${changeRequestId.value}' was not found in database") saved <- commit.save(cr)(ChangeContext(ModificationId(uuidGen.newUuid), qc.actor, new DateTime(), reason, None, qc.nodePerms)) _ <- woChangeRequestRepository.updateChangeRequest(saved, actor, reason) @@ -408,7 +403,7 @@ class TwoValidationStepsWorkflowServiceImpl( changeRequestId: ChangeRequestId, actor: EventActor, reason: Option[String] - ): Box[WorkflowNodeId] = { + ): IOResult[WorkflowNodeId] = { changeStep(from, Cancelled, changeRequestId, actor, reason) } @@ -418,7 +413,7 @@ class TwoValidationStepsWorkflowServiceImpl( changeRequestId: ChangeRequestId, actor: EventActor, reason: Option[String] - ): Box[WorkflowNodeId] = { + ): IOResult[WorkflowNodeId] = { changeStep(Validation, Deployment, changeRequestId, actor, reason) } @@ -426,7 +421,7 @@ class TwoValidationStepsWorkflowServiceImpl( changeRequestId: ChangeRequestId, actor: EventActor, reason: Option[String] - )(implicit qc: QueryContext): Box[WorkflowNodeId] = { + )(implicit qc: QueryContext): IOResult[WorkflowNodeId] = { onSuccessWorkflow(Validation, changeRequestId, actor, reason) } @@ -434,7 +429,7 @@ class TwoValidationStepsWorkflowServiceImpl( changeRequestId: ChangeRequestId, actor: EventActor, reason: Option[String] - ): Box[WorkflowNodeId] = { + ): IOResult[WorkflowNodeId] = { toFailure(Validation, changeRequestId, actor, reason) } @@ -442,7 +437,7 @@ class TwoValidationStepsWorkflowServiceImpl( changeRequestId: ChangeRequestId, actor: EventActor, reason: Option[String] - ): Box[WorkflowNodeId] = { + ): IOResult[WorkflowNodeId] = { toFailure(Deployment, changeRequestId, actor, reason) } @@ -450,7 +445,7 @@ class TwoValidationStepsWorkflowServiceImpl( changeRequestId: ChangeRequestId, actor: EventActor, reason: Option[String] - )(implicit qc: QueryContext): Box[WorkflowNodeId] = { + )(implicit qc: QueryContext): IOResult[WorkflowNodeId] = { onSuccessWorkflow(Deployment, changeRequestId, actor, reason) } diff --git a/change-validation/src/main/scala/com/normation/plugins/changevalidation/api/ChangeRequestApi.scala b/change-validation/src/main/scala/com/normation/plugins/changevalidation/api/ChangeRequestApi.scala index d515e5455..e0ba16f47 100644 --- a/change-validation/src/main/scala/com/normation/plugins/changevalidation/api/ChangeRequestApi.scala +++ b/change-validation/src/main/scala/com/normation/plugins/changevalidation/api/ChangeRequestApi.scala @@ -37,12 +37,19 @@ package com.normation.plugins.changevalidation.api -import com.normation.box.* import com.normation.cfclerk.domain.Technique import com.normation.cfclerk.domain.TechniqueId import com.normation.cfclerk.services.TechniqueRepository -import com.normation.errors.* +import com.normation.errors.AccumulateErrors +import com.normation.errors.Inconsistency +import com.normation.errors.IOResult +import com.normation.errors.OptionToIoResult +import com.normation.errors.OptionToPureResult +import com.normation.errors.PureResult +import com.normation.errors.PureToIoResult +import com.normation.errors.Unexpected import com.normation.plugins.changevalidation.ChangeRequestFilter +import com.normation.plugins.changevalidation.ChangeRequestInfoJson import com.normation.plugins.changevalidation.ChangeRequestJson import com.normation.plugins.changevalidation.RoChangeRequestRepository import com.normation.plugins.changevalidation.RoWorkflowRepository @@ -53,6 +60,8 @@ import com.normation.rudder.api.ApiVersion import com.normation.rudder.api.HttpAction.DELETE import com.normation.rudder.api.HttpAction.GET import com.normation.rudder.api.HttpAction.POST +import com.normation.rudder.config.ReasonBehavior +import com.normation.rudder.config.UserPropertyService import com.normation.rudder.domain.nodes.NodeGroupUid import com.normation.rudder.domain.policies.DirectiveId import com.normation.rudder.domain.policies.DirectiveUid @@ -69,10 +78,10 @@ import com.normation.rudder.rest.EndpointSchema import com.normation.rudder.rest.EndpointSchema.syntax.* import com.normation.rudder.rest.GeneralApi import com.normation.rudder.rest.OneParam +import com.normation.rudder.rest.RudderJsonRequest.* import com.normation.rudder.rest.SortIndex import com.normation.rudder.rest.StartsAtVersion3 import com.normation.rudder.rest.ZeroParam -import com.normation.rudder.rest.data.APIChangeRequestInfo import com.normation.rudder.rest.implicits.* import com.normation.rudder.rest.lift.DefaultParams import com.normation.rudder.rest.lift.LiftApiModule @@ -82,10 +91,7 @@ import com.normation.rudder.services.modification.DiffService import com.normation.rudder.services.workflows.CommitAndDeployChangeRequestService import com.normation.rudder.services.workflows.WorkflowLevelService import com.normation.rudder.users.UserService -import com.normation.rudder.web.services.ReasonBehavior -import com.normation.rudder.web.services.UserPropertyService import enumeratum.* -import net.liftweb.common.Box import net.liftweb.http.LiftResponse import net.liftweb.http.Req import sourcecode.Line @@ -172,7 +178,9 @@ class ChangeRequestApiImpl( userService: UserService ) extends LiftApiModuleProvider[ChangeRequestApi] { import com.normation.plugins.changevalidation.api.ChangeRequestApi as API - implicit private val diffServiceImpl: DiffService = diffService + + implicit def reasonBehavior: ReasonBehavior = userPropertyService.reasonsFieldBehavior + implicit private val diffServiceImpl: DiffService = diffService override def schemas: ApiModuleProvider[ChangeRequestApi] = API @@ -302,8 +310,8 @@ class ChangeRequestApiImpl( s"Could not decline ChangeRequest ${id} details cause is: could not decline ChangeRequest ${id}, because status '${step.value}' cannot be cancelled." ) (_, func) = stepFunc - reason <- extractReason(req) - result <- func(changeRequest.id, authzToken.qc.actor, reason).toIO + reason <- extractReason(req).toIO + result <- func(changeRequest.id, authzToken.qc.actor, reason) serialized <- serialize(changeRequest, result).toIO } yield { serialized @@ -339,8 +347,8 @@ class ChangeRequestApiImpl( s"Could not accept ChangeRequest ${id} details cause is: you could not send Change Request from '${step.value}' to '${targetStep.value}'." ) (_, func) = stepFunc - reason <- extractReason(req) - result <- func(changeRequest.id, authzToken.qc.actor, reason).toIO + reason <- extractReason(req).toIO + result <- func(changeRequest.id, authzToken.qc.actor, reason) serialized <- serialize(changeRequest, result).toIO } yield { serialized @@ -390,17 +398,21 @@ class ChangeRequestApiImpl( authzToken: AuthzToken ): LiftResponse = { implicit val qc: QueryContext = authzToken.qc - def updateInfo(changeRequest: ChangeRequest, status: WorkflowNodeId, apiInfo: APIChangeRequestInfo)(implicit + def updateInfo(changeRequest: ChangeRequest, status: WorkflowNodeId, apiInfo: ChangeRequestInfoJson)(implicit techniqueByDirective: Map[DirectiveId, Technique] ): IOResult[ChangeRequestJson] = { val newInfo = apiInfo.updateCrInfo(changeRequest.info) - if (changeRequest.info == newInfo) { + + if (newInfo.name == "") { + val message = s"Could not update ChangeRequest ${id} details cause is: Change request name cannot be empty." + Inconsistency(message).fail + } else if (changeRequest.info == newInfo) { val message = s"Could not update ChangeRequest ${id} details cause is: No changes to save." Inconsistency(message).fail } else { val newCR = ChangeRequest.updateInfo(changeRequest, newInfo) for { - updated <- writeChangeRequest.updateChangeRequest(newCR, authzToken.qc.actor, None).toIO + updated <- writeChangeRequest.updateChangeRequest(newCR, authzToken.qc.actor, None) serialized <- serialize(updated, status).toIO } yield { serialized @@ -408,9 +420,14 @@ class ChangeRequestApiImpl( } } - withChangeRequestContext(id, params, schema, "update")((changeRequest, status, techniqueByDirective) => - updateInfo(changeRequest, status, extractChangeRequestInfo(req.params))(techniqueByDirective.toMap) - ).toLiftResponseOne(params, schema, Some(id)) + withChangeRequestContext(id, params, schema, "update")((changeRequest, status, techniqueByDirective) => { + for { + json <- req.fromJson[ChangeRequestInfoJson].toIO + update <- updateInfo(changeRequest, status, json)(techniqueByDirective.toMap) + } yield { + update + } + }).toLiftResponseOne(params, schema, Some(id)) } } @@ -422,27 +439,27 @@ class ChangeRequestApiImpl( )( block: (ChangeRequest, WorkflowNodeId, Map[DirectiveId, Technique]) => IOResult[T] ): IOResult[T] = { - val id = { - // PureResult.attempt(s"'${sid}' is not a valid change request id (need to be an integer)")(ChangeRequestId(sid.toInt)) - Box(sid.toIntOption.map(ChangeRequestId(_))) ?~ (s"'${sid}' is not a valid change request id (need to be an integer)") - } - - checkWorkflow match { - case true => - (for { - crId <- id - optCr <- readChangeRequest.get(crId) ?~! (s"Could not find ChangeRequest ${sid}") - changeRequest <- - Box(optCr) ?~ (s"Could not get ChangeRequest ${sid} details cause is: change request with id ${sid} does not exist.") - status <- readWorkflow.getStateOfChangeRequest(crId) ?~! (s"Could not find ChangeRequest ${sid} status") - result <- getDirectiveTechniques(changeRequest).flatMap(block(changeRequest, status, _)).toBox - } yield { - result - }).toIO - .chainError(s"Could not ${actionDetail} ChangeRequest ${sid}") - case false => - disabledWorkflowAnswer + if (checkWorkflow) { + (for { + crId <- sid.toIntOption + .notOptional(s"'${sid}' is not a valid change request id (need to be an integer)") + .map(ChangeRequestId(_)) + changeRequest <- readChangeRequest + .get(crId) + .chainError(s"Could not find ChangeRequest ${sid}") + .notOptional(s"Change request with id ${sid} does not exist.") + status <- readWorkflow + .getStateOfChangeRequest(crId) + .chainError(s"Could not find ChangeRequest ${sid} status") + result <- getDirectiveTechniques(changeRequest) + .flatMap(block(changeRequest, status, _)) + } yield { + result + }) + .chainError(s"Could not ${actionDetail} ChangeRequest ${sid}") + } else { + disabledWorkflowAnswer } } @@ -506,32 +523,6 @@ class ChangeRequestApiImpl( }) } - private def extractChangeRequestInfo(params: Map[String, List[String]]): APIChangeRequestInfo = { - APIChangeRequestInfo( - params.get("name").flatMap(_.headOption), - params.get("description").flatMap(_.headOption) - ) - } - - private def extractReason(req: Req): IOResult[Option[String]] = { - import ReasonBehavior.* - (userPropertyService.reasonsFieldBehavior match { - case Disabled => ZIO.none - case mode => - val reason = req.params.get("reason").flatMap(_.headOption) - (mode: @unchecked) match { - case Mandatory => - reason - .notOptional("Reason field is mandatory and should be at least 5 characters long") - .reject { - case s if s.lengthIs < 5 => Inconsistency("Reason field should be at least 5 characters long") - } - .map(Some(_)) - case Optionnal => reason.succeed - } - }).chainError("There was an error while extracting reason message") - } - private[this] def extractFilters(params: Map[String, List[String]]): PureResult[ChangeRequestFilter] = { import ChangeRequestFilter.* for { diff --git a/change-validation/src/main/scala/com/normation/plugins/changevalidation/api/SupervisedTargetsApi.scala b/change-validation/src/main/scala/com/normation/plugins/changevalidation/api/SupervisedTargetsApi.scala index 3a0fa16b3..5ef25efe4 100644 --- a/change-validation/src/main/scala/com/normation/plugins/changevalidation/api/SupervisedTargetsApi.scala +++ b/change-validation/src/main/scala/com/normation/plugins/changevalidation/api/SupervisedTargetsApi.scala @@ -88,7 +88,8 @@ object SupervisedTargetsApi extends Enum[SupervisedTargetsApi] with ApiMod val description = "Save the updated list of groups" val (action, path) = POST / "changevalidation" / "supervised" / "targets" - override def dataContainer: Option[String] = None + override def dataContainer: Option[String] = None + val authz: List[AuthorizationType] = AuthorizationType.Administration.Write :: Nil } def endpoints = values.toList.sortBy(_.z) diff --git a/change-validation/src/main/scala/com/normation/plugins/changevalidation/api/ValidatedUserApi.scala b/change-validation/src/main/scala/com/normation/plugins/changevalidation/api/ValidatedUserApi.scala index 3fa096aaa..b42d52eee 100644 --- a/change-validation/src/main/scala/com/normation/plugins/changevalidation/api/ValidatedUserApi.scala +++ b/change-validation/src/main/scala/com/normation/plugins/changevalidation/api/ValidatedUserApi.scala @@ -77,7 +77,8 @@ object ValidatedUserApi extends Enum[ValidatedUserApi] with ApiModuleProvi val (action, path) = DELETE / "validatedUsers" / "{username}" override val name = "removeValidatedUser" - override def dataContainer: Option[String] = None + override def dataContainer: Option[String] = None + val authz: List[AuthorizationType] = AuthorizationType.Administration.Write :: Nil } final case object SaveWorkflowUsers extends ValidatedUserApi with ZeroParam with StartsAtVersion3 with SortIndex { val z = implicitly[Line].value @@ -86,6 +87,7 @@ object ValidatedUserApi extends Enum[ValidatedUserApi] with ApiModuleProvi override def dataContainer: Option[String] = None override val name = "saveWorkflowUser" + val authz: List[AuthorizationType] = AuthorizationType.Administration.Write :: Nil } def endpoints = values.toList.sortBy(_.z) diff --git a/change-validation/src/main/scala/com/normation/plugins/changevalidation/api/WorkflowInternalApi.scala b/change-validation/src/main/scala/com/normation/plugins/changevalidation/api/WorkflowInternalApi.scala new file mode 100644 index 000000000..fb6e320e9 --- /dev/null +++ b/change-validation/src/main/scala/com/normation/plugins/changevalidation/api/WorkflowInternalApi.scala @@ -0,0 +1,142 @@ +/* + ************************************************************************************* + * Copyright 2025 Normation SAS + ************************************************************************************* + * + * This file is part of Rudder. + * + * Rudder is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * In accordance with the terms of section 7 (7. Additional Terms.) of + * the GNU General Public License version 3, the copyright holders add + * the following Additional permissions: + * Notwithstanding to the terms of section 5 (5. Conveying Modified Source + * Versions) and 6 (6. Conveying Non-Source Forms.) of the GNU General + * Public License version 3, when you create a Related Module, this + * Related Module is not considered as a part of the work and may be + * distributed under the license agreement of your choice. + * A "Related Module" means a set of sources files including their + * documentation that, without modification of the Source Code, enables + * supplementary functions or services in addition to those offered by + * the Software. + * + * Rudder is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Rudder. If not, see . + + * + ************************************************************************************* + */ + +package com.normation.plugins.changevalidation.api + +import cats.data.NonEmptyList +import com.normation.plugins.changevalidation.PendingCountJson +import com.normation.plugins.changevalidation.RoWorkflowRepository +import com.normation.plugins.changevalidation.TwoValidationStepsWorkflowServiceImpl +import com.normation.rudder.AuthorizationType +import com.normation.rudder.api.ApiVersion +import com.normation.rudder.api.HttpAction.GET +import com.normation.rudder.rest.ApiModuleProvider +import com.normation.rudder.rest.ApiPath +import com.normation.rudder.rest.AuthzToken +import com.normation.rudder.rest.EndpointSchema +import com.normation.rudder.rest.EndpointSchema.syntax.AddPath +import com.normation.rudder.rest.EndpointSchema.syntax.BuildPath +import com.normation.rudder.rest.EndpointSchema0 +import com.normation.rudder.rest.InternalApi +import com.normation.rudder.rest.SortIndex +import com.normation.rudder.rest.StartsAtVersion21 +import com.normation.rudder.rest.ZeroParam +import com.normation.rudder.rest.implicits.ToLiftResponseOne +import com.normation.rudder.rest.lift.DefaultParams +import com.normation.rudder.rest.lift.LiftApiModule +import com.normation.rudder.rest.lift.LiftApiModule0 +import com.normation.rudder.rest.lift.LiftApiModuleProvider +import com.normation.rudder.users.UserService +import enumeratum.Enum +import enumeratum.EnumEntry +import net.liftweb.http.LiftResponse +import net.liftweb.http.Req +import sourcecode.Line +import zio.syntax.ToZio + +sealed trait WorkflowInternalApi extends EnumEntry with EndpointSchema with InternalApi with SortIndex +object WorkflowInternalApi extends Enum[WorkflowInternalApi] with ApiModuleProvider[WorkflowInternalApi] { + + final case object PendingChangeRequestCount extends WorkflowInternalApi with ZeroParam with StartsAtVersion21 with SortIndex { + val z = implicitly[Line].value + val (action, path) = GET / "changevalidation" / "workflow" / "pendingCountByStatus" + val description = + "Get total count of change requests in each state, i.e. PendingValidation and PendingDeployment" + + override def dataContainer: Option[String] = Some("workflow") + override def authz: List[AuthorizationType] = { + List(AuthorizationType.Deployer.Read, AuthorizationType.Validator.Read) + } + } + + override def endpoints: List[WorkflowInternalApi] = values.toList.sortBy(_.z) + override def values: IndexedSeq[WorkflowInternalApi] = findValues +} + +class WorkflowInternalApiImpl( + readWorkflow: RoWorkflowRepository, + userService: UserService +) extends LiftApiModuleProvider[WorkflowInternalApi] { + import com.normation.plugins.changevalidation.api.WorkflowInternalApi as API + + override def schemas: ApiModuleProvider[WorkflowInternalApi] = API + + override def getLiftEndpoints(): List[LiftApiModule] = { + API.endpoints.map { case API.PendingChangeRequestCount => PendingChangeRequestCount } + } + + object PendingChangeRequestCount extends LiftApiModule0 { + + override val schema: EndpointSchema0 = API.PendingChangeRequestCount + + override def process0( + version: ApiVersion, + path: ApiPath, + req: Req, + params: DefaultParams, + authzToken: AuthzToken + ): LiftResponse = { + + val user = userService.getCurrentUser + + val isValidator = user.checkRights(AuthorizationType.Validator.Read) + val isDeployer = user.checkRights(AuthorizationType.Deployer.Read) + + val filter = { + if (isValidator && isDeployer) { + List(TwoValidationStepsWorkflowServiceImpl.Validation.id, TwoValidationStepsWorkflowServiceImpl.Deployment.id) + } else if (isValidator) List(TwoValidationStepsWorkflowServiceImpl.Validation.id) + else if (isDeployer) List(TwoValidationStepsWorkflowServiceImpl.Deployment.id) + else List() + } + + NonEmptyList.fromList(filter) match { + case None => + // Should never happen : a request from a user that doesn't have either rights will not be processed here + PendingCountJson(None, None).succeed.toLiftResponseOne(params, schema, None) + case Some(nonEmptyFilter) => + readWorkflow + .getCountByState(nonEmptyFilter) + .map(PendingCountJson.from) + .chainError("Could not get pending change request count") + .toLiftResponseOne(params, schema, None) + } + + } + + } +} diff --git a/change-validation/src/main/scala/com/normation/plugins/changevalidation/comet/WorkflowInformation.scala b/change-validation/src/main/scala/com/normation/plugins/changevalidation/comet/WorkflowInformation.scala index 65b508129..cc4a1d4ea 100644 --- a/change-validation/src/main/scala/com/normation/plugins/changevalidation/comet/WorkflowInformation.scala +++ b/change-validation/src/main/scala/com/normation/plugins/changevalidation/comet/WorkflowInformation.scala @@ -38,147 +38,96 @@ package com.normation.plugins.changevalidation.comet import bootstrap.liftweb.RudderConfig -import com.normation.box.* -import com.normation.plugins.changevalidation.EitherWorkflowService -import com.normation.plugins.changevalidation.TwoValidationStepsWorkflowServiceImpl -import com.normation.rudder.AuthorizationType import com.normation.rudder.batch.AsyncWorkflowInfo -import com.normation.rudder.services.workflows.WorkflowService import com.normation.rudder.services.workflows.WorkflowUpdate -import com.normation.rudder.users.CurrentUser +import com.normation.zio.UnsafeRun import net.liftweb.common.* import net.liftweb.http.* +import net.liftweb.http.js.JsCmds.Run import scala.xml.* +/** + * Actor to dynamically update the count of change requests once workflows are updated. + * + * It loads the corresponding WorkflowInformation Elm app, which can also be + * dynamically loaded depending on feature toggle. + */ class WorkflowInformation extends CometActor with CometListener with Loggable { - private[this] def workflowService = { - RudderConfig.workflowLevelService.getWorkflowService() + + private[this] val asyncWorkflow = RudderConfig.asyncWorkflowInfo + + private[this] var workflowEnabledPrev = getWorkflowEnabled() + private[this] var shouldLoadScript = workflowEnabledPrev + + private[this] def getWorkflowEnabled() = { + RudderConfig.configService.rudder_workflow_enabled().orElseSucceed(false).runNow } - private[this] val asyncWorkflow = RudderConfig.asyncWorkflowInfo - private[this] val isValidator = CurrentUser.checkRights(AuthorizationType.Validator.Edit) - private[this] val isDeployer = CurrentUser.checkRights(AuthorizationType.Deployer.Edit) override def registerWith: AsyncWorkflowInfo = asyncWorkflow override val defaultHtml = NodeSeq.Empty - val layout = { -
    - } - - def render = { - val xml = RudderConfig.configService.rudder_workflow_enabled().toBox match { - case eb: EmptyBox => - val e = eb ?~! "Error when trying to read Rudder configuration for workflow activation" - logger.error(e.messageChain) - e.rootExceptionCause.foreach(ex => logger.error("Exception was:", e)) - - (".dropdown-menu *" #> ).apply(layout) - - case Full(workflowEnabled) => - val cssSelect = { - if (workflowEnabled && (isValidator || isDeployer)) { - { - if (isValidator) pendingModifications - else ".dropdown-menu *+" #> NodeSeq.Empty - } & { - if (isDeployer) pendingDeployment - else ".dropdown-menu *+" #> NodeSeq.Empty - } & - "#number *" #> requestCount(workflowService) - } else { - ".dropdown *" #> NodeSeq.Empty - } - } - - cssSelect(layout) + */ + + val xml = { + } - + loadScript() new RenderOut(xml) } - def requestCount(workflowService: WorkflowService): Int = { - - workflowService match { - case ws: TwoValidationStepsWorkflowServiceImpl => - val validation = - if (isValidator) ws.getItemsInStep(TwoValidationStepsWorkflowServiceImpl.Validation.id).map(_.size).getOrElse(0) else 0 - val deployment = - if (isDeployer) ws.getItemsInStep(TwoValidationStepsWorkflowServiceImpl.Deployment.id).map(_.size).getOrElse(0) else 0 - validation + deployment - case either: EitherWorkflowService => requestCount(either.current) - case _ => 0 - } - } - - def pendingModifications = { - val xml = pendingModificationRec(workflowService) + override def lowPriority = { + case WorkflowUpdate => + val workflowEnabled = getWorkflowEnabled() - "#workflow-app ul *+" #> xml - } - - private[this] def pendingModificationRec(workflowService: WorkflowService): NodeSeq = { - workflowService match { - case ws: TwoValidationStepsWorkflowServiceImpl => - ws.getItemsInStep(TwoValidationStepsWorkflowServiceImpl.Validation.id) match { - case Full(seq) => -
  • - - - - Pending review - - {seq.size} - -
  • - case e: EmptyBox => -
  • Error when trying to fetch pending change requests.

  • - } - case either: EitherWorkflowService => pendingModificationRec(either.current) - case _ => // For other kind of workflows, this has no meaning -
  • Error, the configured workflow does not have that step.

  • - } - } + // The script should be loaded if the workflow_enabled setting has been enabled since the last render + if ((!workflowEnabledPrev) && workflowEnabled) shouldLoadScript = true + if (workflowEnabledPrev && (!workflowEnabled)) shouldLoadScript = false - def pendingDeployment = { - val xml = pendingDeploymentRec(workflowService) + workflowEnabledPrev = workflowEnabled - "#workflow-app ul *+" #> xml + loadScript() } - private[this] def pendingDeploymentRec(workflowService: WorkflowService): NodeSeq = { - workflowService match { - case ws: TwoValidationStepsWorkflowServiceImpl => - ws.getItemsInStep(TwoValidationStepsWorkflowServiceImpl.Deployment.id) match { - case Full(seq) => -
  • - - - - Pending deployment - - {seq.size} - -
  • - case e: EmptyBox => -
  • Error when trying to fetch pending change requests.

  • - } - case either: EitherWorkflowService => pendingDeploymentRec(either.current) - case _ => // For other kind of workflows, this has no meaning -
  • Error, the configured workflow does not have that step.

  • + private def loadScript(): Unit = { + if (shouldLoadScript) { + partialUpdate(Run(""" + $(document).ready(function(){ + const node = document.getElementById("workflow-app"); + if (!node) return; + const initValues = { + contextPath : contextPath + }; + const app = Elm.WorkflowInformation.init({node: node, flags: initValues}); + + app.ports.errorNotification.subscribe((errMsg) => { + createErrorNotification(errMsg) + }) + }); + """)) } } - override def lowPriority = { case WorkflowUpdate => reRender() } } diff --git a/change-validation/src/main/scala/com/normation/plugins/changevalidation/extension/ChangeValidationTab.scala b/change-validation/src/main/scala/com/normation/plugins/changevalidation/extension/ChangeValidationTab.scala new file mode 100644 index 000000000..4067a11e5 --- /dev/null +++ b/change-validation/src/main/scala/com/normation/plugins/changevalidation/extension/ChangeValidationTab.scala @@ -0,0 +1,55 @@ +/* + ************************************************************************************* + * Copyright 2024 Normation SAS + ************************************************************************************* + * + * This file is part of Rudder. + * + * Rudder is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * In accordance with the terms of section 7 (7. Additional Terms.) of + * the GNU General Public License version 3, the copyright holders add + * the following Additional permissions: + * Notwithstanding to the terms of section 5 (5. Conveying Modified Source + * Versions) and 6 (6. Conveying Non-Source Forms.) of the GNU General + * Public License version 3, when you create a Related Module, this + * Related Module is not considered as a part of the work and may be + * distributed under the license agreement of your choice. + * A "Related Module" means a set of sources files including their + * documentation that, without modification of the Source Code, enables + * supplementary functions or services in addition to those offered by + * the Software. + * + * Rudder is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Rudder. If not, see . + + * + ************************************************************************************* + */ + +package com.normation.plugins.changevalidation.extension + +import com.normation.plugins.PluginExtensionPoint +import com.normation.plugins.PluginStatus +import com.normation.rudder.web.ChooseTemplate +import com.normation.rudder.web.snippet.administration.Settings +import scala.reflect.ClassTag +import scala.xml.NodeSeq + +class ChangeValidationTab(val status: PluginStatus)(implicit val ttag: ClassTag[Settings]) + extends PluginExtensionPoint[Settings] { + + private val template = ChooseTemplate("template" :: "ChangeValidationManagement" :: Nil, "component-body") + + override def pluginCompose(snippet: Settings): Map[String, NodeSeq => NodeSeq] = Map( + "body" -> Settings.addTab("changeValidationTab", "Change validation", template) + ) +} diff --git a/change-validation/src/main/scala/com/normation/plugins/changevalidation/snippet/ChangeRequestChangesForm.scala b/change-validation/src/main/scala/com/normation/plugins/changevalidation/snippet/ChangeRequestChangesForm.scala index 49834a631..436a36a5d 100644 --- a/change-validation/src/main/scala/com/normation/plugins/changevalidation/snippet/ChangeRequestChangesForm.scala +++ b/change-validation/src/main/scala/com/normation/plugins/changevalidation/snippet/ChangeRequestChangesForm.scala @@ -38,7 +38,6 @@ package com.normation.plugins.changevalidation.snippet import bootstrap.liftweb.* -import com.normation.box.* import com.normation.cfclerk.domain.SectionSpec import com.normation.cfclerk.domain.TechniqueId import com.normation.cfclerk.domain.TechniqueName @@ -66,8 +65,10 @@ import com.normation.rudder.rule.category.RuleCategory import com.normation.rudder.users.CurrentUser import com.normation.rudder.web.ChooseTemplate import com.normation.rudder.web.model.* +import com.normation.rudder.web.snippet.WithNonce import com.normation.utils.DateFormaterService -import net.liftweb.common.* +import com.normation.zio.UnsafeRun +import net.liftweb.common.Loggable import net.liftweb.http.* import net.liftweb.http.js.JE.* import net.liftweb.http.js.JsCmds.* @@ -75,6 +76,7 @@ import net.liftweb.util.Helpers.* import org.apache.commons.text.StringEscapeUtils import org.joda.time.DateTime import scala.xml.* +import zio.json.* object ChangeRequestChangesForm { def form = ChooseTemplate( @@ -107,8 +109,15 @@ class ChangeRequestChangesForm( implicit val qc: QueryContext = CurrentUser.queryContext changeRequest match { case cr: ConfigurationChangeRequest => - ruleCategoryRepository.getRootCategory().toBox match { - case Full(rootRuleCategory) => + ruleCategoryRepository + .getRootCategory() + .chainError("An error occurred when trying to get data from base. ") + .either + .runNow match { + case Left(err) => + logger.error(err.fullMsg) + Text(err.fullMsg) + case Right(rootRuleCategory) => ("#changeTree ul *" #> new ChangesTreeNode(cr, rootRuleCategory).toXml & "#history *" #> displayHistory( rootRuleCategory, @@ -124,12 +133,9 @@ class ChangeRequestChangesForm( cr.rules.values.map(_.changes).toList, cr.globalParams.values.map(_.changes).toList ))(form) ++ - Script(JsRaw(s"""buildChangesTree("#changeTree","${S.contextPath}");""")) // JsRaw ok, const - - case eb: EmptyBox => - val e = eb ?~! "An error occurred when trying to get data from base. " - logger.error(e.messageChain) - Text(e.msg) + WithNonce.scriptWithNonce( + Script(JsRaw(s"""buildChangesTree("#changeTree","${S.contextPath}");""")) + ) // JsRaw ok, const } case _ => Text("not implemented") @@ -266,8 +272,8 @@ class ChangeRequestChangesForm( rules: List[RuleChange] = Nil, globalParams: List[GlobalParameterChange] = Nil )(implicit qc: QueryContext) = { - val crLogs = changeRequestEventLogService.getChangeRequestHistory(changeRequest.id).getOrElse(Seq()) - val wfLogs = workFlowEventLogService.getChangeRequestHistory(changeRequest.id).getOrElse(Seq()) + val crLogs = changeRequestEventLogService.getChangeRequestHistory(changeRequest.id).orElseSucceed(Seq()).runNow + val wfLogs = workFlowEventLogService.getChangeRequestHistory(changeRequest.id).orElseSucceed(Seq()).runNow val lines = { wfLogs.flatMap(displayWorkflowEvent(_)) ++ @@ -309,9 +315,11 @@ class ChangeRequestChangesForm( $$('.dataTables_filter input').attr("placeholder", "Filter"); """) // JsRaw ok, const ("#crBody" #> lines).apply(CRTable) ++ - Script( - SetHtml("diff", diff(rootRuleCategory, directives, groups, rules, globalParams)) & - initDatatable + WithNonce.scriptWithNonce( + Script( + SetHtml("diff", diff(rootRuleCategory, directives, groups, rules, globalParams)) & + initDatatable + ) ) } val CRTable = { @@ -442,7 +450,7 @@ class ChangeRequestChangesForm( "#shortDescription" #> group.description & "#query" #> (group.query match { case None => Text("None") - case Some(q) => Text(q.toJSONString) + case Some(q) => Text(q.toJson) }) & "#isDynamic" #> group.isDynamic & "#properties" #>
      {group.properties.map(p =>
    • {p.name}: {p.valueAsString}
    • )}
    & @@ -467,7 +475,7 @@ class ChangeRequestChangesForm( )(implicit qc: QueryContext) = { def displayQuery(query: Option[Query]) = query match { case None => "None" - case Some(q) => q.toJSONString + case Some(q) => q.toJson } def displayServerList(servers: Set[NodeId]): String = { servers.map(_.value).toList.sortBy(s => s).mkString("\n") @@ -623,7 +631,7 @@ class ChangeRequestChangesForm( (ruleChange.change.map { change => val rule = change.diff.rule (for { - groupLib <- getGroupLib().toBox + groupLib <- getGroupLib() } yield { change.diff match { @@ -641,21 +649,17 @@ class ChangeRequestChangesForm( displayRule(rule, rootRuleCategory, groupLib) } - }) match { - case Full(xml) => - xml - case eb: EmptyBox => - val fail = eb ?~! s"Could not display diff for ${rule.name} (${rule.id.serialize})" - logger.error(fail.messageChain) -
    {fail.messageChain}
    + }).chainError(s"Could not display diff for ${rule.name} (${rule.id.serialize})").either.runNow match { + case Right(xml) => xml + case Left(err) => + logger.error(err.fullMsg) +
    {err.fullMsg}
    } - }) match { - case Full(xml) => - xml - case eb: EmptyBox => - val fail = eb ?~! s"Could not display Rule diffs" - logger.error(fail.messageChain) -
    {fail.messageChain}
    + }).chainError(s"Could not display Rule diffs") match { + case Right(xml) => xml + case Left(err) => + logger.error(err.fullMsg) +
    {err.fullMsg}
    } } }) ++ @@ -718,15 +722,17 @@ class ChangeRequestChangesForm(
    {fun(diff.newValue)}
     ++
    -    Script(
    -      OnLoad(
    -        JsRaw(
    -          s"""
    +    WithNonce.scriptWithNonce(
    +      Script(
    +        OnLoad(
    +          JsRaw(
    +            s"""
                 var before = "before${name}";
                 var after  = "after${name}";
                 var result = "result${name}";
                 makeDiff(before,after,result);"""
    -        ) // JsRaw ok, escaped
    +          ) // JsRaw ok, escaped
    +        )
           )
         )
       }
    diff --git a/change-validation/src/main/scala/com/normation/plugins/changevalidation/snippet/ChangeRequestDetails.scala b/change-validation/src/main/scala/com/normation/plugins/changevalidation/snippet/ChangeRequestDetails.scala
    index ec153093d..788a579aa 100644
    --- a/change-validation/src/main/scala/com/normation/plugins/changevalidation/snippet/ChangeRequestDetails.scala
    +++ b/change-validation/src/main/scala/com/normation/plugins/changevalidation/snippet/ChangeRequestDetails.scala
    @@ -39,8 +39,10 @@ package com.normation.plugins.changevalidation.snippet
     
     import bootstrap.liftweb.RudderConfig
     import bootstrap.rudder.plugin.ChangeValidationConf
    +import com.normation.box.*
    +import com.normation.errors.BoxToIO
    +import com.normation.errors.IOResult
     import com.normation.eventlog.EventActor
    -import com.normation.eventlog.EventLog
     import com.normation.plugins.changevalidation.ChangeValidationLogger
     import com.normation.plugins.changevalidation.TwoValidationStepsWorkflowServiceImpl
     import com.normation.rudder.AuthorizationType
    @@ -55,7 +57,12 @@ import com.normation.rudder.users.CurrentUser
     import com.normation.rudder.web.ChooseTemplate
     import com.normation.rudder.web.model.*
     import com.normation.utils.DateFormaterService
    -import net.liftweb.common.*
    +import com.normation.zio.UnsafeRun
    +import net.liftweb.common.Box
    +import net.liftweb.common.EmptyBox
    +import net.liftweb.common.Failure
    +import net.liftweb.common.Full
    +import net.liftweb.common.Loggable
     import net.liftweb.http.*
     import net.liftweb.http.js.*
     import net.liftweb.http.js.JE.*
    @@ -97,7 +104,7 @@ class ChangeRequestDetails extends DispatchSnippet with Loggable {
       private[this] var changeRequest: Box[ChangeRequest] = {
         CrId match {
           case Full(id) =>
    -        roChangeRequestRepo.get(ChangeRequestId(id)) match {
    +        roChangeRequestRepo.get(ChangeRequestId(id)).toBox match {
               case Full(Some(cr)) =>
                 if (checkAccess(cr))
                   Full(cr)
    @@ -112,7 +119,9 @@ class ChangeRequestDetails extends DispatchSnippet with Loggable {
             Failure(s"Error in the cr id asked: ${fail.msg}")
         }
       }
    -  private[this] def step = changeRequest.flatMap(cr => workflowService.findStep(cr.id))
    +  private[this] def step = changeRequest.flatMap(cr => workflowService.findStep(cr.id).toBox)
    +
    +  implicit private val qc: QueryContext = CurrentUser.queryContext // bug https://issues.rudder.io/issues/26605
     
       def dispatch = {
         // Display Change request Header
    @@ -120,7 +129,7 @@ class ChangeRequestDetails extends DispatchSnippet with Loggable {
           (xml => {
             changeRequest match {
               case eb: EmptyBox => NodeSeq.Empty
    -          case Full(cr) => displayHeader(cr)(CurrentUser.queryContext)
    +          case Full(cr) => displayHeader(cr)
             }
           })
     
    @@ -136,16 +145,16 @@ class ChangeRequestDetails extends DispatchSnippet with Loggable {
                 
    ++ Script( JsRaw( - s"""setTimeout("location.href = '${S.contextPath}/secure/plugins/changes/changeRequests';",5000);""" + s"""setTimeout("location.href = '${S.contextPath}/secure/configurationManager/changes/changeRequests';",5000);""" ) // JsRaw ok, const ) case Full(cr) => new ChangeRequestEditForm( cr.info, cr.owner, - step, + step.toIO, cr.id, - changeDetailsCallback(cr)(_)(CurrentUser.queryContext) + changeDetailsCallback(cr)(_) ).display } }) @@ -165,7 +174,7 @@ class ChangeRequestDetails extends DispatchSnippet with Loggable { _ => { changeRequest match { case eb: EmptyBox => NodeSeq.Empty - case Full(cr) => displayWarnUnmergeable(cr)(CurrentUser.queryContext) + case Full(cr) => displayWarnUnmergeable(cr) } } ) @@ -221,7 +230,7 @@ class ChangeRequestDetails extends DispatchSnippet with Loggable { private[this] def changeDetailsCallback(cr: ChangeRequest)(statusUpdate: ChangeRequestInfo)(implicit qc: QueryContext) = { workflowService match { case ws: TwoValidationStepsWorkflowServiceImpl => - val newCR = ws.updateChangeRequestInfo(cr, statusUpdate, CurrentUser.actor, None) + val newCR = ws.updateChangeRequestInfo(cr, statusUpdate, CurrentUser.actor, None).toBox changeRequest = newCR SetHtml("changeRequestHeader", displayHeader(newCR.openOr(cr))) & SetHtml("changeRequestChanges", new ChangeRequestChangesForm(newCR.openOr(cr)).dispatch("changes")(NodeSeq.Empty)) @@ -233,50 +242,65 @@ class ChangeRequestDetails extends DispatchSnippet with Loggable { } def displayHeader(cr: ChangeRequest)(implicit qc: QueryContext) = { - // last action on the change Request (name/description changed): - val (action, date) = changeRequestEventLogService.getLastLog(cr.id) match { - case eb: EmptyBox => ("Error when retrieving the last action", None) - case Full(None) => ("Error, no action were recorded for that change request", None) // should not happen here ! - case Full(Some(e: EventLog)) => - val actionName = e match { - case _: ModifyChangeRequest => "Modified" - case _: AddChangeRequest => "Created" - case _: DeleteChangeRequest => "Deleted" - } - (s"${actionName} on ${DateFormaterService.getDisplayDate(e.creationDate)} by ${e.principal.name}", Some(e.creationDate)) + val (findStep, last) = (for { + // last action on the change Request (name/description changed): + lastCRLog <- changeRequestEventLogService.getLastLog(cr.id).either + (crAct, crDate) = lastCRLog match { + case Left(err) => (s"Error when retrieving the last change request action : ${err.fullMsg}", None) + case Right(None) => (s"Error: no action was recorded for change request with id ${cr.id.value}", None) + case Right(Some(e)) => + val actionName = e match { + case _: ModifyChangeRequest => "Modified" + case _: AddChangeRequest => "Created" + case _: DeleteChangeRequest => "Deleted" + } + ( + s"${actionName} on ${DateFormaterService.getDisplayDate(e.creationDate)} by ${e.principal.name}", + Some(e.creationDate) + ) + } + + // Last workflow change on that change Request + lastWFLog <- workFlowEventLogService.getLastLog(cr.id).either + (wfAct, wfDate) = lastWFLog match { + case Left(err) => (s"Error when retrieving the last workflow change : ${err.fullMsg}", None) + case Right(None) => (s"Error: no action was recorded for change request with id ${cr.id.value}", None) + case Right(Some(e)) => + val changeStep = eventlogDetailsService + .getWorkflotStepChange(e.details) + .map(step => s"State changed from ${step.from} to ${step.to}") + .getOrElse("Step changed") + + ( + s"${changeStep} on ${DateFormaterService.getDisplayDate(e.creationDate)} by ${e.principal.name}", + Some(e.creationDate) + ) + } + + last = (crDate, wfDate) match { + case (Some(crd), Some(wfd)) => if (crd.isAfter(wfd)) crAct else wfAct + case (None, Some(_)) => wfAct + case (_, None) => crAct + } + findStep <- workflowService + .findStep(cr.id) + .either + } yield { + (findStep, last) + }).runNow + + val (crStatus, actionBtns) = findStep match { + case Left(err) => + (
    Cannot find the status of this change request
    , NodeSeq.Empty) + case Right(step) => + (Text(step.value), displayActionButton(cr, step)) } - // Last workflow change on that change Request - val (step, stepDate) = workFlowEventLogService.getLastLog(cr.id) match { - case eb: EmptyBox => ("Error when retrieving the last action", None) - case Full(None) => ("Error when retrieving the last action", None) // should not happen here ! - case Full(Some(event)) => - val changeStep = eventlogDetailsService - .getWorkflotStepChange(event.details) - .map(step => s"State changed from ${step.from} to ${step.to}") - .getOrElse("Step changed") - - ( - s"${changeStep} on ${DateFormaterService.getDisplayDate(event.creationDate)} by ${event.principal.name}", - Some(event.creationDate) - ) - } - - // Compare both to find the oldest - val last = (date, stepDate) match { - case (None, None) => action - case (Some(_), None) => action - case (None, Some(_)) => step - case (Some(date), Some(stepDate)) => if (date.isAfter(stepDate)) action else step - } - ("#backButton [href]" #> "/secure/plugins/changes/changeRequests" & + ("#backButton [href]" #> "/secure/configurationManager/changes/changeRequests" & "#nameTitle *" #> s"CR #${cr.id}: ${cr.info.name}" & - "#CRStatus *" #> workflowService - .findStep(cr.id) - .map(x => Text(x.value)) - .openOr(
    Cannot find the status of this change request
    ) & + "#CRStatus *" #> crStatus & "#CRLastAction *" #> s"${last}" & - "#actionBtns *" #> workflowService.findStep(cr.id).map(x => displayActionButton(cr, x)).openOr(NodeSeq.Empty))(header) + "#actionBtns *" #> actionBtns)(header) } @@ -292,10 +316,10 @@ class ChangeRequestDetails extends DispatchSnippet with Loggable { def ChangeStepPopup( action: String, - nextSteps: Seq[(WorkflowNodeId, (ChangeRequestId, EventActor, Option[String]) => Box[WorkflowNodeId])], + nextSteps: Seq[(WorkflowNodeId, (ChangeRequestId, EventActor, Option[String]) => IOResult[WorkflowNodeId])], cr: ChangeRequest )(implicit qc: QueryContext) = { - type stepChangeFunction = (ChangeRequestId, EventActor, Option[String]) => Box[WorkflowNodeId] + type stepChangeFunction = (ChangeRequestId, EventActor, Option[String]) => IOResult[WorkflowNodeId] def closePopup: JsCmd = { SetHtml("changeRequestHeader", displayHeader(cr)) & @@ -304,7 +328,8 @@ class ChangeRequestDetails extends DispatchSnippet with Loggable { workflowService .findStep(cr.id) .map(x => Text(x.value)) - .openOr(
    Cannot find the status of this change request
    ) + .orElseSucceed(
    Cannot find the status of this change request
    ) + .runNow ) & SetHtml("changeRequestChanges", new ChangeRequestChangesForm(cr).dispatch("changes")(NodeSeq.Empty)) & JsRaw("""hideBsModal('popupContent');""") // JsRaw ok, const @@ -335,13 +360,13 @@ class ChangeRequestDetails extends DispatchSnippet with Loggable { } val changeMessage = { - import com.normation.rudder.web.services.ReasonBehavior.* + import com.normation.rudder.config.ReasonBehavior.* userPropertyService.reasonsFieldBehavior match { case Disabled => None case Mandatory => Some(buildReasonField(true, "subContainerReasonField")) - case Optionnal => Some(buildReasonField(false, "subContainerReasonField")) + case Optional => Some(buildReasonField(false, "subContainerReasonField")) // for non-exhaustiveness God - yes, enum were not very well designed before scala 3 - case x => throw new IllegalArgumentException(s"This case should not happen, please report to developers") + case _ => throw new IllegalArgumentException(s"This case should not happen, please report to developers") } } @@ -406,22 +431,26 @@ class ChangeRequestDetails extends DispatchSnippet with Loggable { def error(msg: String) =
    {msg}
    def confirm(): JsCmd = { + val user = CurrentUser.actor + if (formTracker.hasErrors) { formTracker.addFormError(error("There was problem with your request")) updateForm(nextChosen) } else { - nextChosen._2(cr.id, CurrentUser.actor, changeMessage.map(_.get)) match { - case Full(next) => + val (_, evalNextStep) = nextChosen + evalNextStep(cr.id, user, changeMessage.map(_.get)) + .chainError("could not change Change request step") + .either + .runNow match { + case Left(err) => + formTracker.addFormError(error(err.fullMsg)) + logger.error(s"Error when saving change request '${cr.id.value}': ${err.fullMsg}") + updateForm(nextChosen) + case Right(next) => SetHtml("workflowActionButtons", displayActionButton(cr, next)) & SetHtml("newStatus", Text(next.value)) & closePopup & JsRaw(""" initBsModal("successWorkflow"); """) // JsRaw ok, const - case eb: EmptyBox => - val fail = eb ?~! "could not change Change request step" - formTracker.addFormError(error(fail.msg)) - logger.error(s"Error when saving change request '${cr.id.value}': ${fail.messageChain}") - updateForm(nextChosen) } - } } diff --git a/change-validation/src/main/scala/com/normation/plugins/changevalidation/snippet/ChangeRequestEditForm.scala b/change-validation/src/main/scala/com/normation/plugins/changevalidation/snippet/ChangeRequestEditForm.scala index ebd07cbb2..264284ce0 100644 --- a/change-validation/src/main/scala/com/normation/plugins/changevalidation/snippet/ChangeRequestEditForm.scala +++ b/change-validation/src/main/scala/com/normation/plugins/changevalidation/snippet/ChangeRequestEditForm.scala @@ -38,12 +38,14 @@ package com.normation.plugins.changevalidation.snippet import bootstrap.liftweb.RudderConfig +import com.normation.errors.IOResult import com.normation.rudder.ActionType import com.normation.rudder.domain.workflows.* import com.normation.rudder.users.CurrentUser import com.normation.rudder.web.ChooseTemplate import com.normation.rudder.web.model.* -import net.liftweb.common.* +import com.normation.zio.UnsafeRun +import net.liftweb.common.Loggable import net.liftweb.http.* import net.liftweb.http.js.* import net.liftweb.http.js.JsCmds.* @@ -61,7 +63,7 @@ object ChangeRequestEditForm { class ChangeRequestEditForm( var info: ChangeRequestInfo, creator: String, - step: Box[WorkflowNodeId], + step: IOResult[WorkflowNodeId], crId: ChangeRequestId, SuccessCallback: ChangeRequestInfo => JsCmd ) extends DispatchSnippet with Loggable { @@ -100,7 +102,7 @@ class ChangeRequestEditForm( val authz = CurrentUser.getRights.authorizationTypes.toSeq.collect { case right: ActionType.Edit => right.authzKind } val isOwner = creator == CurrentUser.actor.name step.map(workflowService.isEditable(authz, _, isOwner)) - }.openOr(false) + }.orElseSucceed(false).runNow private[this] def actionButton = { if (isEditable) @@ -134,7 +136,8 @@ class ChangeRequestEditForm( "#CRId *" #> crId.value & "#CRStatusDetails *" #> step .map(wfId => Text(wfId.toString)) - .openOr(
    Cannot find the status of this change request
    ) & + .orElseSucceed(
    Cannot find the status of this change request
    ) + .runNow & "#CRDescription *" #> CRDescription & "#CRSave *" #> actionButton)(form) } diff --git a/change-validation/src/main/scala/com/normation/plugins/changevalidation/snippet/ChangeRequestManagement.scala b/change-validation/src/main/scala/com/normation/plugins/changevalidation/snippet/ChangeRequestManagement.scala index e1afed8ee..e3fd95cfe 100644 --- a/change-validation/src/main/scala/com/normation/plugins/changevalidation/snippet/ChangeRequestManagement.scala +++ b/change-validation/src/main/scala/com/normation/plugins/changevalidation/snippet/ChangeRequestManagement.scala @@ -48,18 +48,25 @@ import com.normation.rudder.users.CurrentUser import com.normation.rudder.web.services.JsTableData import com.normation.rudder.web.services.JsTableLine import com.normation.utils.DateFormaterService -import net.liftweb.common.* +import com.normation.zio.UnsafeRun +import net.liftweb.common.Box +import net.liftweb.common.EmptyBox +import net.liftweb.common.Full +import net.liftweb.common.Loggable import net.liftweb.http.* import net.liftweb.http.DispatchSnippet import net.liftweb.http.SHtml import net.liftweb.http.SHtml.SelectableOption import net.liftweb.http.js.JE.* import net.liftweb.http.js.JsCmds.* +import net.liftweb.http.js.JsObj import net.liftweb.util.Helpers.* import org.apache.commons.text.StringEscapeUtils import scala.xml.Elem import scala.xml.NodeSeq import scala.xml.Text +import zio.UIO +import zio.syntax.* class ChangeRequestManagement extends DispatchSnippet with Loggable { @@ -99,7 +106,9 @@ class ChangeRequestManagement extends DispatchSnippet with Loggable { val date = eventsMap.get(changeRequest.id).map(event => DateFormaterService.serialize(event.creationDate)).getOrElse("Unknown") - val json = { + override def json(freshName: () => String): JsObj = toJson + + val toJson = { JsObj( "id" -> changeRequest.id.value, "name" -> changeRequest.info.name, @@ -110,29 +119,32 @@ class ChangeRequestManagement extends DispatchSnippet with Loggable { } } - def getLines() = { + def getLines(): UIO[JsTableData[ChangeRequestLine]] = { val changeRequests = if (currentUser) roCrRepo.getAll() else roCrRepo.getByContributor(CurrentUser.actor) - JsTableData(changeRequests match { - case Full(changeRequests) => - val eventMap = getLastEventsMap - val workflowStateMap: Map[ChangeRequestId, WorkflowNodeId] = workflowService.getAllChangeRequestsStep() match { - case Full(stateMap) => stateMap - case eb: EmptyBox => - val fail = eb ?~! "Could not find change requests state" - logger.error(fail.messageChain) - Map() - } - changeRequests.map(ChangeRequestLine(_, workflowStateMap, eventMap)).toList - case eb: EmptyBox => - val fail = eb ?~! s"Could not get change requests because of : ${eb}" - logger.error(fail.msg) - Nil - }) + for { + crs <- changeRequests + .chainError("Could not get change requests") + .catchAll(err => { + logger.error(err.fullMsg) + Vector().succeed + }) + workflowStateMap <- workflowService + .getAllChangeRequestsStep() + .chainError("Could not find change requests state") + .catchAll(err => { + logger.error(err.fullMsg) + Map.empty[ChangeRequestId, WorkflowNodeId].succeed + }) + lastEventsMap <- getLastEventsMap + } yield { + JsTableData(crs.map(ChangeRequestLine(_, workflowStateMap, lastEventsMap)).toList) + } } + def dataTableInit = { val refresh = AnonFunc( - SHtml.ajaxInvoke(() => JsRaw(s"refreshTable('${changeRequestTableId}',${getLines().json.toJsCmd})")) + SHtml.ajaxInvoke(() => JsRaw(s"refreshTable('${changeRequestTableId}',${getLines().runNow.toJson.toJsCmd})")) ) // JsRaw ok, from json val filter = initFilter match { @@ -152,36 +164,38 @@ class ChangeRequestManagement extends DispatchSnippet with Loggable { /** * Get all events, merge them via a mutMap (we only want to keep the most recent event) */ - private[this] def getLastEventsMap = { - val CREventsMap: Map[ChangeRequestId, EventLog] = changeRequestEventLogService.getLastCREvents match { - case Full(map) => map - case eb: EmptyBox => - val fail = eb ?~! "Could not find last Change requests events requests state" - logger.error(fail.messageChain) - Map() - } - val workflowEventsMap: Map[ChangeRequestId, EventLog] = workflowLoggerService.getLastWorkflowEvents() match { - case Full(map) => map - case eb: EmptyBox => - val fail = eb ?~! "Could not find last Change requests events requests state" - logger.error(fail.messageChain) - Map() - } + private[this] def getLastEventsMap: UIO[Map[ChangeRequestId, EventLog]] = { - import scala.collection.mutable.Map as MutMap + for { + crEventsMap <- changeRequestEventLogService.getLastCREvents + .chainError("Could not find last Change requests events requests state") + .catchAll(err => { + logger.error(err.fullMsg) + Map.empty[ChangeRequestId, EventLog].succeed + }) + workflowEventsMap <- workflowLoggerService + .getLastWorkflowEvents() + .chainError("Could not find last Change requests events requests state") + .catchAll(err => { + logger.error(err.fullMsg) + Map.empty[ChangeRequestId, EventLog].succeed + }) + } yield { + import scala.collection.mutable.Map as MutMap - val eventMap = MutMap() ++ CREventsMap + val eventMap = MutMap() ++ crEventsMap - for { - (crId, event) <- workflowEventsMap - } { - eventMap.get(crId) match { - case Some(currentEvent) if (currentEvent.creationDate isAfter event.creationDate) => - case _ => eventMap.update(crId, event) + for { + (crId, event) <- workflowEventsMap + } { + eventMap.get(crId) match { + case Some(currentEvent) if (currentEvent.creationDate isAfter event.creationDate) => + case _ => eventMap.update(crId, event) + } } - } - eventMap.toMap + eventMap.toMap + } } def statusFilter = { diff --git a/change-validation/src/main/scala/com/normation/plugins/changevalidation/snippet/ChangeValidationSettings.scala b/change-validation/src/main/scala/com/normation/plugins/changevalidation/snippet/ChangeValidationSettings.scala index 4b249677f..b3152aa9c 100644 --- a/change-validation/src/main/scala/com/normation/plugins/changevalidation/snippet/ChangeValidationSettings.scala +++ b/change-validation/src/main/scala/com/normation/plugins/changevalidation/snippet/ChangeValidationSettings.scala @@ -40,14 +40,14 @@ package com.normation.plugins.changevalidation.snippet import bootstrap.liftweb.RudderConfig import com.normation.appconfig.ReadConfigService import com.normation.appconfig.UpdateConfigService -import com.normation.box.* -import net.liftweb.common.* +import com.normation.rudder.web.snippet.WithNonce +import com.normation.zio.UnsafeRun import net.liftweb.http.* import net.liftweb.http.js.JsCmds.Run import net.liftweb.http.js.JsCmds.Script -import net.liftweb.util.Helpers import net.liftweb.util.Helpers.* import scala.xml.NodeSeq +import zio.syntax.* class ChangeValidationSettings extends DispatchSnippet { @@ -61,26 +61,38 @@ class ChangeValidationSettings extends DispatchSnippet { def workflowConfiguration: NodeSeq => NodeSeq = { (xml: NodeSeq) => // initial values, updated on successful submit - var initEnabled = configService.rudder_workflow_enabled().toBox - var initSelfVal = configService.rudder_workflow_self_validation().toBox - var initSelfDep = configService.rudder_workflow_self_deployment().toBox + var initEnabled = configService.rudder_workflow_enabled() + var initSelfVal = configService.rudder_workflow_self_validation() + var initSelfDep = configService.rudder_workflow_self_deployment() // form values - var enabled = initEnabled.getOrElse(false) - var selfVal = initSelfVal.getOrElse(false) - var selfDep = initSelfDep.getOrElse(false) + var enabled = initEnabled.orElseSucceed(false).runNow + var selfVal = initSelfVal.orElseSucceed(false).runNow + var selfDep = initSelfDep.orElseSucceed(false).runNow def submit = { - configService.set_rudder_workflow_enabled(enabled).toBox.foreach(updateOk => initEnabled = Full(enabled)) - configService.set_rudder_workflow_self_validation(selfVal).toBox.foreach(updateOk => initSelfVal = Full(selfVal)) - configService.set_rudder_workflow_self_deployment(selfDep).toBox.foreach(updateOk => initSelfDep = Full(selfDep)) + configService.set_rudder_workflow_enabled(enabled).either.runNow match { + case Right(_) => initEnabled = enabled.succeed + case _ => () + } + + configService.set_rudder_workflow_self_validation(selfVal).either.runNow match { + case Right(_) => initSelfVal = selfVal.succeed + case _ => () + } + + configService.set_rudder_workflow_self_deployment(selfDep).either.runNow match { + case Right(_) => initSelfDep = selfDep.succeed + case _ => () + } + S.notice("updateWorkflow", "Change Requests (validation workflow) configuration correctly updated") check() } - def noModif = (initEnabled.map(_ == enabled).getOrElse(false) - && initSelfVal.map(_ == selfVal).getOrElse(false) - && initSelfDep.map(_ == selfDep).getOrElse(false)) + def noModif = (initEnabled.map(_ == enabled).orElseSucceed(false).runNow + && initSelfVal.map(_ == selfVal).orElseSucceed(false).runNow + && initSelfDep.map(_ == selfDep).orElseSucceed(false).runNow) def check() = { if (!noModif) { @@ -108,83 +120,81 @@ class ChangeValidationSettings extends DispatchSnippet { // if the workflow plugin is not loaded, just removed the correstponding config val finalXml = if (workflowLevelService.workflowLevelAllowsEnable) { - xml ++ Script(initJs(enabled)) + xml ++ WithNonce.scriptWithNonce(Script(initJs(enabled))) } else { NodeSeq.Empty } ("#workflowEnabled" #> { - initEnabled match { - case Full(value) => + initEnabled + .chainError("there was an error while fetching value of property: 'Enable Change Requests' ") + .either + .runNow match { + case Right(value) => SHtml.ajaxCheckbox( value, initJs _, ("id", "workflowEnabled"), ("class", "twoCol") ) - case eb: EmptyBox => - val fail = eb ?~ "there was an error, while fetching value of property: 'Enable Change Requests' " -
    {fail.msg}
    + case Left(err) => +
    {err.msg}
    } } & "#selfVal" #> { - initSelfVal match { - case Full(value) => + initSelfVal + .chainError("there was an error while fetching value of property: 'Allow self validation' ") + .either + .runNow match { + case Right(value) => SHtml.ajaxCheckbox( value, (b: Boolean) => { selfVal = b; check() }, ("id", "selfVal"), ("class", "twoCol") ) - case eb: EmptyBox => - val fail = eb ?~ "there was an error, while fetching value of property: 'Allow self validation' " -
    {fail.msg}
    + case Left(err) => +
    {err.msg}
    } - } & "#selfDep " #> { - initSelfDep match { - case Full(value) => + initSelfDep + .chainError("there was an error while fetching value of property: 'Allow self deployment' ") + .either + .runNow match { + case Right(value) => SHtml.ajaxCheckbox( value, (b: Boolean) => { selfDep = b; check() }, ("id", "selfDep"), ("class", "twoCol") ) - case eb: EmptyBox => - val fail = eb ?~ "there was an error, while fetching value of property: 'Allow self deployment' " -
    {fail.msg}
    + case Left(err) => +
    {err.msg}
    } } & "#selfValTooltip *" #> { - initSelfVal match { - case Full(_) => - val tooltipid = Helpers.nextFuncName - - - -
    - Allow users to validate Change Requests they created themselves? Validating is moving a Change Request to the "Pending deployment" status -
    - case _ => NodeSeq.Empty + initSelfVal.either.runNow match { + case Right(_) => + val tooltipMsg = + """Allow users to validate Change Requests they created themselves? Validating is moving a Change Request to the "Pending deployment" status""" + + case Left(_) => NodeSeq.Empty } } & "#selfDepTooltip *" #> { - initSelfDep match { - case Full(_) => - val tooltipid = Helpers.nextFuncName - - - -
    - Allow users to deploy Change Requests they created themselves? Deploying is effectively applying a Change Request in the "Pending deployment" status. -
    - case _ => NodeSeq.Empty + + initSelfDep.either.runNow match { + case Right(_) => + val tooltipMsg = + """Allow users to deploy Change Requests they created themselves? Deploying is effectively applying a Change Request in the "Pending deployment" status.""" + + case Left(_) => NodeSeq.Empty } } & @@ -196,21 +206,24 @@ class ChangeValidationSettings extends DispatchSnippet { // same as workflowConfiguration but with 1 single checkbox, and val autoValidatedUsers = configService.rudder_workflow_validation_auto_validated_users().toBox def validationConfiguration: NodeSeq => NodeSeq = { (xml: NodeSeq) => // initial value, updated on successful submit - var initAutoValidatedUsers = configService.rudder_workflow_validate_all().toBox + var initAutoValidatedUsers = configService.rudder_workflow_validate_all() // form value - var autoValidatedUsers = initAutoValidatedUsers.getOrElse(false) + var autoValidatedUsers = initAutoValidatedUsers.orElseSucceed(false).runNow def submit = { configService .set_rudder_workflow_validate_all(autoValidatedUsers) - .toBox - .foreach(updateOk => initAutoValidatedUsers = Full(autoValidatedUsers)) + .either + .runNow match { + case Right(_) => initAutoValidatedUsers = autoValidatedUsers.succeed + case _ => () + } S.notice("updateValidation", "Validation configuration correctly updated") check() } - def noModif = initAutoValidatedUsers.map(_ == autoValidatedUsers).getOrElse(false) + def noModif = initAutoValidatedUsers.map(_ == autoValidatedUsers).orElseSucceed(false).runNow def check() = { if (!noModif) { @@ -220,17 +233,19 @@ class ChangeValidationSettings extends DispatchSnippet { } ("#validationAutoValidatedUser" #> { - initAutoValidatedUsers match { - case Full(value) => + initAutoValidatedUsers + .chainError("there was an error while fetching value of property: 'Auto validated users' ") + .either + .runNow match { + case Right(value) => SHtml.ajaxCheckbox( value, (b: Boolean) => { autoValidatedUsers = b; check() }, ("id", "validationAutoValidatedUser"), ("class", "twoCol") ) - case eb: EmptyBox => - val fail = eb ?~ "there was an error, while fetching value of property: 'Auto validated users' " -
    {fail.msg}
    + case Left(err) => +
    {err.msg}
    } } & "#validationAutoSubmit " #> { diff --git a/change-validation/src/main/style/change-validation.css b/change-validation/src/main/style/change-validation.css index c0bd6d803..72632e8c9 100644 --- a/change-validation/src/main/style/change-validation.css +++ b/change-validation/src/main/style/change-validation.css @@ -47,9 +47,7 @@ flex-basis : initial !important; flex: initial !important; } -.rudder-template .one-col .template-main .main-details { - padding-bottom: 0px; -} + /* ========= */ ul.clipboard-list > li { padding: 6px 12px; @@ -382,7 +380,7 @@ ul.clipboard-list > li:last-child{ border-radius: 3px; } -#supervised-targets-app .node-check .ion{ +#supervised-targets-app .node-check .fa{ font-size: 12px; top: -10px; position: relative; @@ -397,7 +395,7 @@ ul.clipboard-list > li:last-child{ display: none; } -#supervised-targets-app .node-check input[type=checkbox]:checked + .ion { +#supervised-targets-app .node-check input[type=checkbox]:checked + .fa { visibility: visible; animation: opacity-1 .1s linear forwards; } diff --git a/change-validation/src/test/resources/changevalidation_api/api_changerequest.yml b/change-validation/src/test/resources/changevalidation_api/api_changerequest.yml index cfe96e326..8d342bfd5 100644 --- a/change-validation/src/test/resources/changevalidation_api/api_changerequest.yml +++ b/change-validation/src/test/resources/changevalidation_api/api_changerequest.yml @@ -3050,7 +3050,7 @@ response: "action": "changeRequestDetails", "id": "999", "result": "error", - "errorDetails": "Could not find ChangeRequest 999; cause was: Unexpected: Could not get ChangeRequest 999 details cause is: change request with id 999 does not exist." + "errorDetails": "Could not find ChangeRequest 999; cause was: Inconsistency: Change request with id 999 does not exist." } --- description: Get information about given change request when id is not a number @@ -3063,7 +3063,7 @@ response: "action": "changeRequestDetails", "id": "aaa", "result": "error", - "errorDetails": "Could not find ChangeRequest aaa; cause was: Unexpected: 'aaa' is not a valid change request id (need to be an integer)" + "errorDetails": "Could not find ChangeRequest aaa; cause was: Inconsistency: 'aaa' is not a valid change request id (need to be an integer)" } --- description: Decline given change request @@ -3301,7 +3301,7 @@ response: "action": "declineChangeRequest", "id": "12", "result": "error", - "errorDetails": "Could not decline ChangeRequest 12; cause was: Unexpected: Inconsistency: Could not decline ChangeRequest 12 details cause is: could not decline ChangeRequest 12, because status 'Cancelled' cannot be cancelled." + "errorDetails": "Could not decline ChangeRequest 12; cause was: Inconsistency: Could not decline ChangeRequest 12 details cause is: could not decline ChangeRequest 12, because status 'Cancelled' cannot be cancelled." } --- description: Decline given change request when id is unknown @@ -3314,7 +3314,7 @@ response: "action": "declineChangeRequest", "id": "999", "result": "error", - "errorDetails": "Could not decline ChangeRequest 999; cause was: Unexpected: Could not get ChangeRequest 999 details cause is: change request with id 999 does not exist." + "errorDetails": "Could not decline ChangeRequest 999; cause was: Inconsistency: Change request with id 999 does not exist." } --- description: Decline given change request when id is not a number @@ -3327,7 +3327,7 @@ response: "action": "declineChangeRequest", "id": "aaa", "result": "error", - "errorDetails": "Could not decline ChangeRequest aaa; cause was: Unexpected: 'aaa' is not a valid change request id (need to be an integer)" + "errorDetails": "Could not decline ChangeRequest aaa; cause was: Inconsistency: 'aaa' is not a valid change request id (need to be an integer)" } --- description: Accept given change request @@ -3586,7 +3586,7 @@ response: "action": "acceptChangeRequest", "id": "999", "result": "error", - "errorDetails": "Could not accept ChangeRequest 999; cause was: Unexpected: Could not get ChangeRequest 999 details cause is: change request with id 999 does not exist." + "errorDetails": "Could not accept ChangeRequest 999; cause was: Inconsistency: Change request with id 999 does not exist." } --- description: Accept given change request when id is not a number @@ -3603,17 +3603,19 @@ response: "action": "acceptChangeRequest", "id": "aaa", "result": "error", - "errorDetails": "Could not accept ChangeRequest aaa; cause was: Unexpected: 'aaa' is not a valid change request id (need to be an integer)" + "errorDetails": "Could not accept ChangeRequest aaa; cause was: Inconsistency: 'aaa' is not a valid change request id (need to be an integer)" } --- description: Update information about given change request method: POST url: /api/latest/changeRequests/4 headers: - - "Content-Type: application/x-www-form-urlencoded" -params: - name: "my group new name" - description: "My group new description" + - "Content-Type: application/json" +body: >- + { + "name": "my group new name", + "description": "My group new description" + } response: code: 200 content: >- @@ -3704,7 +3706,7 @@ response: "action": "updateChangeRequest", "id": "999", "result": "error", - "errorDetails": "Could not update ChangeRequest 999; cause was: Unexpected: Could not get ChangeRequest 999 details cause is: change request with id 999 does not exist." + "errorDetails": "Could not update ChangeRequest 999; cause was: Inconsistency: Change request with id 999 does not exist." } --- description: Update information about given change request when id is not a number @@ -3722,12 +3724,16 @@ response: "action": "updateChangeRequest", "id": "aaa", "result": "error", - "errorDetails": "Could not update ChangeRequest aaa; cause was: Unexpected: 'aaa' is not a valid change request id (need to be an integer)" + "errorDetails": "Could not update ChangeRequest aaa; cause was: Inconsistency: 'aaa' is not a valid change request id (need to be an integer)" } --- description: Update information about given change request when no parameter is sent method: POST url: /api/latest/changeRequests/4 +headers: + - "Content-Type: application/json" +body: >- + {} response: code: 500 content: >- @@ -3735,17 +3741,19 @@ response: "action": "updateChangeRequest", "id": "4", "result": "error", - "errorDetails": "Could not update ChangeRequest 4; cause was: Unexpected: Inconsistency: Could not update ChangeRequest 4 details cause is: No changes to save." + "errorDetails": "Could not update ChangeRequest 4; cause was: Inconsistency: Could not update ChangeRequest 4 details cause is: No changes to save." } --- description: Update information about given change request without changing any info method: POST url: /api/latest/changeRequests/11 headers: - - "Content-Type: application/x-www-form-urlencoded" -params: - name: "second cr global param" - description: "My global param second change" + - "Content-Type: application/json" +body: >- + { + "name": "second cr global param", + "description": "My global param second change" + } response: code: 500 content: >- @@ -3753,5 +3761,5 @@ response: "action" : "updateChangeRequest", "id" : "11", "result" : "error", - "errorDetails" : "Could not update ChangeRequest 11; cause was: Unexpected: Inconsistency: Could not update ChangeRequest 11 details cause is: No changes to save." + "errorDetails" : "Could not update ChangeRequest 11; cause was: Inconsistency: Could not update ChangeRequest 11 details cause is: No changes to save." } diff --git a/change-validation/src/test/resources/changevalidation_api/api_workflowinternal.yml b/change-validation/src/test/resources/changevalidation_api/api_workflowinternal.yml new file mode 100644 index 000000000..f100720b2 --- /dev/null +++ b/change-validation/src/test/resources/changevalidation_api/api_workflowinternal.yml @@ -0,0 +1,19 @@ +description: Get number of pending deployment and pending validation change requests +method: GET +url: /secure/api/changevalidation/workflow/pendingCountByStatus +headers: +response: + code: 200 + content: >- + { + "action": "pendingChangeRequestCount", + "result": "success", + "data": { + "workflow": [ + { + "pendingValidation": 2, + "pendingDeployment": 1 + } + ] + } + } \ No newline at end of file diff --git a/change-validation/src/test/resources/emails/change-validation-email.conf b/change-validation/src/test/resources/emails/change-validation-email.conf index e3d3839bf..24a6f6a44 100644 --- a/change-validation/src/test/resources/emails/change-validation-email.conf +++ b/change-validation/src/test/resources/emails/change-validation-email.conf @@ -7,20 +7,20 @@ smtp.password="thepass" # The rudder base URL (domain) as seen from people who will receive emails. This parameter is used # to display link to change requests in notification emails. -# If the CR URL is: https://my.rudder.server/rudder/secure/plugins/changes/changeRequest/1 +# If the CR URL is: https://my.rudder.server/rudder/secure/configurationManager/changes/changeRequest/1 # You should use: https://my.rudder.server/rudder [without end slash] rudder.base.url="https://my.rudder.server/rudder" # The rudder base URL (domain) as seen from people who will receive emails. This parameter is used # to display link to change requests in notification emails. -# If the CR URL is: https://my.rudder.server/rudder/secure/plugins/changes/changeRequest/1 +# If the CR URL is: https://my.rudder.server/rudder/secure/configurationManager/changes/changeRequest/1 # You should use: https://my.rudder.server/rudder [without end slash] rudder.base.url="https://my.rudder.server/rudder" # `subject` parameter support templating. -# Please refer to the documentation : https://docs.rudder.io/reference/6.1/plugins/change-validation.html +# Please refer to the documentation : https://docs.rudder.io/reference/latest/plugins/change-validation.html # to know which parameters can be used for templating. # Pending validation diff --git a/change-validation/src/test/scala/com/normation/plugins/changevalidation/ChangeRequestJdbcRepositoryTest.scala b/change-validation/src/test/scala/com/normation/plugins/changevalidation/ChangeRequestJdbcRepositoryTest.scala index 4c3c2f340..9e61e1d6b 100644 --- a/change-validation/src/test/scala/com/normation/plugins/changevalidation/ChangeRequestJdbcRepositoryTest.scala +++ b/change-validation/src/test/scala/com/normation/plugins/changevalidation/ChangeRequestJdbcRepositoryTest.scala @@ -44,6 +44,8 @@ import com.normation.BoxSpecMatcher import com.normation.GitVersion import com.normation.cfclerk.domain.TechniqueName import com.normation.cfclerk.domain.TechniqueVersionHelper +import com.normation.errors.RudderError +import com.normation.errors.SystemError import com.normation.eventlog.EventActor import com.normation.eventlog.ModificationId import com.normation.rudder.db.DBCommon @@ -66,9 +68,6 @@ import com.typesafe.config.ConfigValueFactory import doobie.Transactor import doobie.specs2.analysisspec.IOChecker import doobie.syntax.all.* -import net.liftweb.common.Box -import net.liftweb.common.Failure -import net.liftweb.common.Full import org.joda.time.DateTime import org.junit.runner.RunWith import org.specs2.mutable.Specification @@ -133,7 +132,7 @@ class ChangeRequestJdbcRepositoryTest extends Specification with DBCommon with I // same change request to test different xpaths : /changeRequest/directives/directive/@id, "/changeRequest/groups/group/@id", "/changeRequest/rules/rule/@id" // We test change of a directive, nodeGroup and rule using the same change request (sql"insert into ChangeRequest (name, description, creationTime, content, modificationId) values ('a change request', 'a change request description', '2023-01-01T00:00:00', ${sampleChangeRequestContent}, '11111111-1111-1111-1111-111111111111')".update.run *> - sql"insert into Workflow (id, state) values (${changeRequestId}, 'Pending validation')".update.run) + sql"insert into Workflow (id, state) values (${changeRequestId.value}, 'Pending validation')".update.run) .transact(xa) }) match { case Right(_) => () @@ -241,9 +240,9 @@ class ChangeRequestJdbcRepositoryTest extends Specification with DBCommon with I sampleChangeRequestContent } lazy val changeRequestChangesUnserialisation: ChangeRequestChangesUnserialisation = _ => { - Full( + Right( ( - Full(Map(directiveId -> directiveChanges)), + Map(directiveId -> directiveChanges), Map(groupId -> nodeGroupChanges), Map(ruleId -> ruleChanges), Map(globalParamId -> globalParamChanges) @@ -288,13 +287,14 @@ class ChangeRequestJdbcRepositoryTest extends Specification with DBCommon with I } "get all change requests" in { - val res = roChangeRequestJdbcRepository.getAll() - (res.map(_.size) must beEqualTo(Full(1))) and (res.flatMap(_.headOption) mustFullEq expectedChangeRequest) + val res = roChangeRequestJdbcRepository.getAll().runNow + res.size must beEqualTo(1) + res.headOption must beSome(expectedChangeRequest) } "get a change request by id" in { - val res = roChangeRequestJdbcRepository.get(changeRequestId) - res.flatMap(Box(_)) mustFullEq expectedChangeRequest + val res = roChangeRequestJdbcRepository.get(changeRequestId).runNow + res must beSome(expectedChangeRequest) } // This does not seem to return any result at all, but the xpath and query look okay... :( @@ -304,21 +304,24 @@ class ChangeRequestJdbcRepositoryTest extends Specification with DBCommon with I // } "get change requests by xpath content" in { - val resDirective = roChangeRequestJdbcRepository.getByDirective(DirectiveUid("foo"), false) - val resNodeGroup = roChangeRequestJdbcRepository.getByNodeGroup(NodeGroupId(NodeGroupUid("bar")), false) - val resRule = roChangeRequestJdbcRepository.getByRule(RuleUid("baz"), false) - ((resDirective - .map(_.size) must beEqualTo(Full(1))) and (resDirective.flatMap(_.headOption) mustFullEq expectedChangeRequest) and - (resNodeGroup.map(_.size) must beEqualTo(Full(1))) and (resNodeGroup.flatMap( - _.headOption - ) mustFullEq expectedChangeRequest) and - (resRule.map(_.size) must beEqualTo(Full(1))) and (resRule.flatMap(_.headOption) mustFullEq expectedChangeRequest)) + val resDirective = roChangeRequestJdbcRepository.getByDirective(DirectiveUid("foo"), false).runNow + val resNodeGroup = roChangeRequestJdbcRepository.getByNodeGroup(NodeGroupId(NodeGroupUid("bar")), false).runNow + val resRule = roChangeRequestJdbcRepository.getByRule(RuleUid("baz"), false).runNow + resDirective.size must beEqualTo(1) + resDirective.headOption must beSome(expectedChangeRequest) + + resNodeGroup.size must beEqualTo(1) + resNodeGroup.headOption must beSome(expectedChangeRequest) + + resRule.size must beEqualTo(1) + resRule.headOption must beSome(expectedChangeRequest) } "get change request by filter" in { val res = roChangeRequestJdbcRepository.getByFilter(ChangeRequestFilter(None, None)).runNow - (res.size must beEqualTo(1)) and (res.head must beEqualTo((expectedChangeRequest, WorkflowNodeId("Pending validation")))) + res.size must beEqualTo(1) + res.head must beEqualTo((expectedChangeRequest, WorkflowNodeId("Pending validation"))) } "create change request" in { @@ -327,7 +330,7 @@ class ChangeRequestJdbcRepositoryTest extends Specification with DBCommon with I actor, Some("reason") ) - res mustFullEq newChangeRequest + res.runNow must beEqualTo(newChangeRequest) } "delete change request (unimplemented)" in { @@ -344,15 +347,20 @@ class ChangeRequestJdbcRepositoryTest extends Specification with DBCommon with I actor, Some("reason") ) - res mustFullEq updatedChangeRequest + res.runNow must beEqualTo(updatedChangeRequest) } "update a non-existing change request" in { - woChangeRequestJdbcRepository.updateChangeRequest( - expectedChangeRequest.copy(id = ChangeRequestId(999)), - actor, - Some("reason") - ) must beEqualTo(Failure(s"Cannot update non-existent Change Request with id 999")) + woChangeRequestJdbcRepository + .updateChangeRequest( + expectedChangeRequest.copy(id = ChangeRequestId(999)), + actor, + Some("reason") + ) + .either + .runNow must beLeft(beLike[RudderError] { + case err: SystemError => err.msg must beEqualTo("Could not update change request with id 999 in database") + }) } } diff --git a/change-validation/src/test/scala/com/normation/plugins/changevalidation/MockServices.scala b/change-validation/src/test/scala/com/normation/plugins/changevalidation/MockServices.scala index 4457b5b02..29cba6eee 100644 --- a/change-validation/src/test/scala/com/normation/plugins/changevalidation/MockServices.scala +++ b/change-validation/src/test/scala/com/normation/plugins/changevalidation/MockServices.scala @@ -37,6 +37,7 @@ package com.normation.plugins.changevalidation import better.files.File +import cats.data.NonEmptyList import com.normation.errors.IOResult import com.normation.eventlog.EventActor import com.normation.eventlog.EventLog @@ -44,6 +45,8 @@ import com.normation.eventlog.ModificationId import com.normation.inventory.domain.NodeId import com.normation.rudder.AuthorizationType import com.normation.rudder.api.ApiAuthorization +import com.normation.rudder.config.StatelessUserPropertyService +import com.normation.rudder.config.UserPropertyService import com.normation.rudder.domain.eventlog.ChangeRequestDiff import com.normation.rudder.domain.eventlog.ChangeRequestEventLog import com.normation.rudder.domain.eventlog.WorkflowStepChanged @@ -71,11 +74,7 @@ import com.normation.rudder.services.workflows.CommitAndDeployChangeRequestServi import com.normation.rudder.users.AuthenticatedUser import com.normation.rudder.users.RudderAccount import com.normation.rudder.users.UserService -import com.normation.rudder.web.services.StatelessUserPropertyService -import com.normation.rudder.web.services.UserPropertyService import com.normation.zio.UnsafeRun -import net.liftweb.common.Box -import net.liftweb.common.Full import scala.collection.immutable.SortedMap import zio.Chunk import zio.Ref @@ -178,10 +177,10 @@ class MockServices(changeRequestsByStatus: Map[WorkflowNodeId, List[ChangeReques .succeed } - override def get(changeRequestId: ChangeRequestId): Box[Option[ChangeRequest]] = { + override def get(changeRequestId: ChangeRequestId): IOResult[Option[ChangeRequest]] = { changeRequestsByStatus.values.flatten.find(_.id == changeRequestId) match { - case Some(cr) => Full(Some(cr)) - case None => Full(None) + case Some(cr) => Some(cr).succeed + case None => None.succeed } } @@ -189,61 +188,66 @@ class MockServices(changeRequestsByStatus: Map[WorkflowNodeId, List[ChangeReques changeRequest: ChangeRequest, actor: EventActor, reason: Option[String] - ): Box[ChangeRequest] = { - Full(changeRequest) + ): IOResult[ChangeRequest] = { + changeRequest.succeed } - override def getAll(): Box[Vector[ChangeRequest]] = ??? + override def getAll(): IOResult[Vector[ChangeRequest]] = ??? - override def getByDirective(id: DirectiveUid, onlyPending: Boolean): Box[Vector[ChangeRequest]] = ??? + override def getByDirective(id: DirectiveUid, onlyPending: Boolean): IOResult[Vector[ChangeRequest]] = ??? - override def getByNodeGroup(id: NodeGroupId, onlyPending: Boolean): Box[Vector[ChangeRequest]] = ??? + override def getByNodeGroup(id: NodeGroupId, onlyPending: Boolean): IOResult[Vector[ChangeRequest]] = ??? - override def getByRule(id: RuleUid, onlyPending: Boolean): Box[Vector[ChangeRequest]] = ??? + override def getByRule(id: RuleUid, onlyPending: Boolean): IOResult[Vector[ChangeRequest]] = ??? - override def getByContributor(actor: EventActor): Box[Vector[ChangeRequest]] = ??? + override def getByContributor(actor: EventActor): IOResult[Vector[ChangeRequest]] = ??? override def createChangeRequest( changeRequest: ChangeRequest, actor: EventActor, reason: Option[String] - ): Box[ChangeRequest] = ??? + ): IOResult[ChangeRequest] = ??? override def deleteChangeRequest( changeRequestId: ChangeRequestId, actor: EventActor, reason: Option[String] - ): Box[ChangeRequest] = ??? + ): IOResult[ChangeRequest] = ??? } object workflowRepository extends RoWorkflowRepository with WoWorkflowRepository { - override def getStateOfChangeRequest(crId: ChangeRequestId): Box[WorkflowNodeId] = { + override def getStateOfChangeRequest(crId: ChangeRequestId): IOResult[WorkflowNodeId] = { changeRequestsByStatus.find(_._2.exists(_.id == crId)).map(_._1) match { - case Some(state) => Full(state) - case None => Full(WorkflowNodeId("unknown")) + case Some(state) => state.succeed + case None => WorkflowNodeId("unknown").succeed } } - override def updateState(crId: ChangeRequestId, from: WorkflowNodeId, state: WorkflowNodeId): Box[WorkflowNodeId] = { - Full(state) + override def updateState(crId: ChangeRequestId, from: WorkflowNodeId, state: WorkflowNodeId): IOResult[WorkflowNodeId] = { + state.succeed } - override def getAllByState(state: WorkflowNodeId): Box[Seq[ChangeRequestId]] = { + override def getCountByState(filter: NonEmptyList[WorkflowNodeId]): IOResult[Map[WorkflowNodeId, Long]] = { + changeRequestsByStatus.flatMap { + case (k, v) => + filter.find(_ == k).map({ _ => (k, v.size.toLong) }) + }.succeed + } + + override def getAllByState(state: WorkflowNodeId): IOResult[Seq[ChangeRequestId]] = { ??? } - override def createWorkflow(crId: ChangeRequestId, state: WorkflowNodeId): Box[WorkflowNodeId] = ??? + override def createWorkflow(crId: ChangeRequestId, state: WorkflowNodeId): IOResult[WorkflowNodeId] = ??? - override def getAllChangeRequestsState(): Box[Map[ChangeRequestId, WorkflowNodeId]] = ??? + override def getAllChangeRequestsState(): IOResult[Map[ChangeRequestId, WorkflowNodeId]] = ??? } object commitAndDeployChangeRequest extends CommitAndDeployChangeRequestService { - def save(changeRequest: ChangeRequest)(implicit cc: ChangeContext): Box[ChangeRequest] = Full( - changeRequest - ) + def save(changeRequest: ChangeRequest)(implicit cc: ChangeContext): IOResult[ChangeRequest] = changeRequest.succeed def isMergeable(changeRequest: ChangeRequest)(implicit qc: QueryContext): Boolean = { // can depend on "mergeable" changeRequest by their id to vary test cases @@ -253,13 +257,14 @@ class MockServices(changeRequestsByStatus: Map[WorkflowNodeId, List[ChangeReques } object workflowEventLogService extends WorkflowEventLogService { - override def saveEventLog(stepChange: WorkflowStepChange, actor: EventActor, reason: Option[String]): Box[EventLog] = Full( - null - ) + override def saveEventLog(stepChange: WorkflowStepChange, actor: EventActor, reason: Option[String]): IOResult[EventLog] = { + val res: EventLog = null + res.succeed + } - override def getChangeRequestHistory(id: ChangeRequestId): Box[Seq[WorkflowStepChanged]] = ??? - override def getLastLog(id: ChangeRequestId): Box[Option[WorkflowStepChanged]] = ??? - override def getLastWorkflowEvents(): Box[Map[ChangeRequestId, EventLog]] = ??? + override def getChangeRequestHistory(id: ChangeRequestId): IOResult[Seq[WorkflowStepChanged]] = ??? + override def getLastLog(id: ChangeRequestId): IOResult[Option[WorkflowStepChanged]] = ??? + override def getLastWorkflowEvents(): IOResult[Map[ChangeRequestId, EventLog]] = ??? } @@ -270,12 +275,15 @@ class MockServices(changeRequestsByStatus: Map[WorkflowNodeId, List[ChangeReques principal: EventActor, diff: ChangeRequestDiff, reason: Option[String] - ): Box[EventLog] = Full(null) + ): IOResult[EventLog] = { + val res: EventLog = null + res.succeed + } - override def getChangeRequestHistory(id: ChangeRequestId): Box[Seq[ChangeRequestEventLog]] = ??? - override def getFirstLog(id: ChangeRequestId): Box[Option[ChangeRequestEventLog]] = ??? - override def getLastLog(id: ChangeRequestId): Box[Option[ChangeRequestEventLog]] = ??? - override def getLastCREvents: Box[Map[ChangeRequestId, EventLog]] = ??? + override def getChangeRequestHistory(id: ChangeRequestId): IOResult[Seq[ChangeRequestEventLog]] = ??? + override def getFirstLog(id: ChangeRequestId): IOResult[Option[ChangeRequestEventLog]] = ??? + override def getLastLog(id: ChangeRequestId): IOResult[Option[ChangeRequestEventLog]] = ??? + override def getLastCREvents: IOResult[Map[ChangeRequestId, EventLog]] = ??? } object notificationService extends NotificationService { diff --git a/change-validation/src/test/scala/com/normation/plugins/changevalidation/TestEmailService.scala b/change-validation/src/test/scala/com/normation/plugins/changevalidation/TestEmailService.scala index 25b00c400..e9fa7860b 100644 --- a/change-validation/src/test/scala/com/normation/plugins/changevalidation/TestEmailService.scala +++ b/change-validation/src/test/scala/com/normation/plugins/changevalidation/TestEmailService.scala @@ -106,7 +106,8 @@ class TestEmailService extends Specification with BeforeAfterAll { val config = notification.getSMTPConf(conf.pathAsString).forceGet - (config.smtpHostServer === "localhost") and (config.port === 2525) + config.smtpHostServer === "localhost" + config.port === 2525 // todo more tests } @@ -114,8 +115,8 @@ class TestEmailService extends Specification with BeforeAfterAll { val template = notification.getStepMailConf(TwoValidationStepsWorkflowServiceImpl.Validation, conf.pathAsString).forceGet - (template.to === Set(Email("validator1@change.req"), Email("validator2@change.req")) and - (template.template === (testDir / "validation-mail.template").pathAsString)) + template.to === Set(Email("validator1@change.req"), Email("validator2@change.req")) + template.template === (testDir / "validation-mail.template").pathAsString } "be able to send a notification email" in { @@ -150,7 +151,7 @@ class TestEmailService extends Specification with BeforeAfterAll { |
  • Author: No One
  • |
  • Description: this CR is for test
  • | - |Click here to review and validate + |Click here to review and validate | |""".stripMargin } @@ -166,9 +167,9 @@ class TestEmailService extends Specification with BeforeAfterAll { s.split("\n").take(2).drop(3).take(2).drop(1).take(20).mkString("\n") } - (isEmpty must beTrue) and - (messages.isEmpty must beFalse) and - (deleteDate(msg) === deleteDate(expectedMessage)) + isEmpty must beTrue + messages.isEmpty must beFalse + deleteDate(msg) === deleteDate(expectedMessage) } } } diff --git a/change-validation/src/test/scala/com/normation/plugins/changevalidation/ValidatedUserJdbcRepositoryTest.scala b/change-validation/src/test/scala/com/normation/plugins/changevalidation/ValidatedUserJdbcRepositoryTest.scala index 014555e30..8cef9698b 100644 --- a/change-validation/src/test/scala/com/normation/plugins/changevalidation/ValidatedUserJdbcRepositoryTest.scala +++ b/change-validation/src/test/scala/com/normation/plugins/changevalidation/ValidatedUserJdbcRepositoryTest.scala @@ -5,7 +5,7 @@ import better.files.Resource import cats.syntax.apply.* import com.normation.eventlog.EventActor import com.normation.rudder.db.DBCommon -import com.normation.rudder.rest.AuthorizationApiMapping +import com.normation.rudder.rest.ExtensibleAuthorizationApiMapping import com.normation.rudder.rest.RoleApiMapping import com.normation.rudder.users.FileUserDetailListProvider import com.normation.rudder.users.PasswordEncoderDispatcher @@ -71,7 +71,7 @@ class ValidatedUserJdbcRepositoryTest extends Specification with DBCommon with I val usersFile = { UserFile("test-users.xml", getUsersInputStream) } - val roleApiMapping = new RoleApiMapping(AuthorizationApiMapping.Core) + val roleApiMapping = new RoleApiMapping(new ExtensibleAuthorizationApiMapping(Nil)) val res = new FileUserDetailListProvider(roleApiMapping, usersFile, new PasswordEncoderDispatcher(12)) res.reload() diff --git a/change-validation/src/test/scala/com/normation/plugins/changevalidation/ValidationNeededTest.scala b/change-validation/src/test/scala/com/normation/plugins/changevalidation/ValidationNeededTest.scala index 0b1eb15b0..d79335593 100644 --- a/change-validation/src/test/scala/com/normation/plugins/changevalidation/ValidationNeededTest.scala +++ b/change-validation/src/test/scala/com/normation/plugins/changevalidation/ValidationNeededTest.scala @@ -57,7 +57,7 @@ import com.normation.rudder.services.workflows.GlobalParamModAction import com.normation.rudder.services.workflows.NodeGroupChangeRequest import com.normation.rudder.services.workflows.RuleChangeRequest import com.normation.rudder.services.workflows.RuleModAction -import net.liftweb.common.Full +import com.normation.zio.UnsafeRun import org.junit.runner.RunWith import org.specs2.mutable.Specification import org.specs2.runner.JUnitRunner @@ -83,19 +83,19 @@ class ValidationNeededTest extends Specification { "always validate a modification in a GlobalParam" in { val globalParamChangeReq = GlobalParamChangeRequest(GlobalParamModAction.Create, None) - nodeGrpValNdd.forGlobalParam(actor, globalParamChangeReq) must beEqualTo(Full(true)) + nodeGrpValNdd.forGlobalParam(actor, globalParamChangeReq).runNow must beTrue } "validate a modification in a node group if that group is supervised" in { val nodeGrp = mockNodeGroups.g0 val nodeGrpChangeReq = NodeGroupChangeRequest(DGModAction.CreateSolo, nodeGrp, None, None) - nodeGrpValNdd.forNodeGroup(actor, nodeGrpChangeReq) must beEqualTo(Full(true)) + nodeGrpValNdd.forNodeGroup(actor, nodeGrpChangeReq).runNow must beTrue } "not validate a modification in a node group if that group is not supervised" in { val nodeGrp = mockNodeGroups.g1 val nodeGrpChangeReq = NodeGroupChangeRequest(DGModAction.CreateSolo, nodeGrp, None, None) - nodeGrpValNdd.forNodeGroup(actor, nodeGrpChangeReq) must beEqualTo(Full(false)) + nodeGrpValNdd.forNodeGroup(actor, nodeGrpChangeReq).runNow must beFalse } "if a modification concerns a rule that" in { @@ -109,7 +109,7 @@ class ValidationNeededTest extends Specification { rule, None ) - nodeGrpValNdd.forRule(actor, ruleChangeReq) must beEqualTo(Full(true)) + nodeGrpValNdd.forRule(actor, ruleChangeReq).runNow must beTrue } "doesn't target a supervised node, the modification mustn't be validated" in { @@ -121,7 +121,7 @@ class ValidationNeededTest extends Specification { rule, None ) - nodeGrpValNdd.forRule(actor, ruleChangeReq) must beEqualTo(Full(false)) + nodeGrpValNdd.forRule(actor, ruleChangeReq).runNow must beFalse } } @@ -144,7 +144,7 @@ class ValidationNeededTest extends Specification { List(mockRules.rules.defaultRule) // targets all nodes, including node group g0 ) - nodeGrpValNdd.forDirective(actor, dirChangeReq) must beEqualTo(Full(true)) + nodeGrpValNdd.forDirective(actor, dirChangeReq).runNow must beTrue } "not validate a modification in a directive if no rule that uses it requires validation" in { @@ -161,7 +161,7 @@ class ValidationNeededTest extends Specification { ) - nodeGrpValNdd.forDirective(actor, dirChangeReq) must beEqualTo(Full(false)) + nodeGrpValNdd.forDirective(actor, dirChangeReq).runNow must beFalse } } diff --git a/change-validation/src/test/scala/com/normation/plugins/changevalidation/WorkflowJdbcRepositoryTest.scala b/change-validation/src/test/scala/com/normation/plugins/changevalidation/WorkflowJdbcRepositoryTest.scala index ce9e32612..280480c8b 100644 --- a/change-validation/src/test/scala/com/normation/plugins/changevalidation/WorkflowJdbcRepositoryTest.scala +++ b/change-validation/src/test/scala/com/normation/plugins/changevalidation/WorkflowJdbcRepositoryTest.scala @@ -37,14 +37,19 @@ package com.normation.plugins.changevalidation +import cats.data.NonEmptyList import cats.effect.IO +import cats.syntax.apply.* +import com.normation.plugins.changevalidation.TwoValidationStepsWorkflowServiceImpl.Cancelled +import com.normation.plugins.changevalidation.TwoValidationStepsWorkflowServiceImpl.Deployment +import com.normation.plugins.changevalidation.TwoValidationStepsWorkflowServiceImpl.Validation import com.normation.rudder.db.DBCommon import com.normation.rudder.domain.workflows.ChangeRequestId import com.normation.rudder.domain.workflows.WorkflowNodeId +import com.normation.zio.UnsafeRun import doobie.Transactor import doobie.specs2.analysisspec.IOChecker import doobie.syntax.all.* -import net.liftweb.common.Full import org.junit.runner.RunWith import org.specs2.mutable.Specification import org.specs2.runner.JUnitRunner @@ -58,9 +63,22 @@ class WorkflowJdbcRepositoryTest extends Specification with DBCommon with IOChec super.initDb() // initialize some change requests to setup change requests for workflow doobie.transactRunEither(xa => { - // id: 1 - sql"insert into ChangeRequest (name, description, creationTime, content, modificationId) values ('a change request', 'a change request description', '2023-01-01T00:00:00', '', '11111111-1111-1111-1111-111111111111')".update.run - .transact(xa) + ( + // id: 1 + sql"insert into ChangeRequest (name, description, creationTime, content, modificationId) values ('a change request', 'a change request description', '2023-01-01T00:00:00', '', '11111111-1111-1111-1111-111111111111')".update.run *> + + // insert a change request that has status "Pending validation" + sql"insert into ChangeRequest (name, description, creationTime, content, modificationId) values ('pendingValidation1', 'a change request description', '2025-01-01T00:00:00', '', '11111111-1111-1111-1111-111111111111')".update.run *> + + // insert two change requests that both have status "Pending deployment" + sql"insert into ChangeRequest (name, description, creationTime, content, modificationId) values ('pendingDeployment2', 'a change request description', '2025-01-01T00:00:00', '', '11111111-1111-1111-1111-111111111111')".update.run *> + sql"insert into ChangeRequest (name, description, creationTime, content, modificationId) values ('pendingDeployment3', 'a change request description', '2025-01-01T00:00:00', '', '11111111-1111-1111-1111-111111111111')".update.run *> + + // insert ids 2, 3 and 4 in the workflow table with their corresponding status + sql"insert into Workflow (id, state) values (2, 'Pending validation')".update.run *> + sql"insert into Workflow (id, state) values (3, 'Pending deployment')".update.run *> + sql"insert into Workflow (id, state) values (4, 'Pending deployment')".update.run + ).transact(xa) }) match { case Right(_) => () case Left(ex) => throw ex @@ -72,12 +90,16 @@ class WorkflowJdbcRepositoryTest extends Specification with DBCommon with IOChec private lazy val roWorkflowJdbcRepository = new RoWorkflowJdbcRepository(doobie) private lazy val woWorkflowJdbcRepository = new WoWorkflowJdbcRepository(doobie) - val changeRequestId = ChangeRequestId(1) + val changeRequestId = ChangeRequestId(1) + val changeRequestId2 = ChangeRequestId(2) + val changeRequestId3 = ChangeRequestId(3) + val changeRequestId4 = ChangeRequestId(4) "WorkflowJdbcRepository" should { "type-check queries" in { check(WorkflowJdbcRepositorySQL.getAllByStateSQL(WorkflowNodeId("foo"))) + check(WorkflowJdbcRepositorySQL.getCountByStateSQL(NonEmptyList.of(WorkflowNodeId("foo")))) check(WorkflowJdbcRepositorySQL.getStateOfChangeRequestSQL(changeRequestId)) check(WorkflowJdbcRepositorySQL.getAllChangeRequestsStateSQL) check(WorkflowJdbcRepositorySQL.createWorkflowSQL(changeRequestId, WorkflowNodeId("foo"))) @@ -87,28 +109,74 @@ class WorkflowJdbcRepositoryTest extends Specification with DBCommon with IOChec val firstWorkflowNodeId = WorkflowNodeId("first") val secondWorkflowNodeId = WorkflowNodeId("second") "create a workflow" in { - woWorkflowJdbcRepository.createWorkflow(changeRequestId, firstWorkflowNodeId) must beEqualTo(Full(firstWorkflowNodeId)) + woWorkflowJdbcRepository.createWorkflow(changeRequestId, firstWorkflowNodeId).runNow must beEqualTo(firstWorkflowNodeId) } "update a workflow" in { - woWorkflowJdbcRepository.updateState(changeRequestId, firstWorkflowNodeId, secondWorkflowNodeId) must beEqualTo( - Full(secondWorkflowNodeId) + woWorkflowJdbcRepository.updateState(changeRequestId, firstWorkflowNodeId, secondWorkflowNodeId).runNow must beEqualTo( + secondWorkflowNodeId ) } "get all change requests by state" in { - roWorkflowJdbcRepository.getAllByState(firstWorkflowNodeId) must beEqualTo(Full(Seq.empty)) - roWorkflowJdbcRepository.getAllByState(secondWorkflowNodeId) must beEqualTo(Full(Vector(changeRequestId))) + roWorkflowJdbcRepository.getAllByState(firstWorkflowNodeId).runNow must beEqualTo(Seq.empty) + roWorkflowJdbcRepository.getAllByState(secondWorkflowNodeId).runNow must beEqualTo(Vector(changeRequestId)) } "get state of change request" in { - roWorkflowJdbcRepository.getStateOfChangeRequest(changeRequestId) must beEqualTo(Full(secondWorkflowNodeId)) + roWorkflowJdbcRepository.getStateOfChangeRequest(changeRequestId).runNow must beEqualTo(secondWorkflowNodeId) } "get all change requests state" in { - roWorkflowJdbcRepository.getAllChangeRequestsState() must beEqualTo(Full(Map(changeRequestId -> secondWorkflowNodeId))) + roWorkflowJdbcRepository.getAllChangeRequestsState().runNow must beEqualTo( + Map( + changeRequestId -> secondWorkflowNodeId, + changeRequestId2 -> Validation.id, + changeRequestId3 -> Deployment.id, + changeRequestId4 -> Deployment.id + ) + ) + } + + "get the count of pending change requests for " in { + + "both the 'Pending validation' and 'Pending deployment' states" in { + roWorkflowJdbcRepository.getCountByState(NonEmptyList.of(Validation.id, Deployment.id)).runNow must beEqualTo( + Map( + Validation.id -> 1, + Deployment.id -> 2 + ) + ) + } + + "the 'Pending validation' state" in { + roWorkflowJdbcRepository.getCountByState(NonEmptyList.of(Validation.id)).runNow must beEqualTo( + Map( + Validation.id -> 1 + ) + ) + } + + "the 'Pending deployment' state" in { + roWorkflowJdbcRepository.getCountByState(NonEmptyList.of(Deployment.id)).runNow must beEqualTo( + Map( + Deployment.id -> 2 + ) + ) + } } + "return 0 for the number of change requests in a given state if no change request is in that state" in { + + (woWorkflowJdbcRepository.updateState(changeRequestId2, Validation.id, Cancelled.id) *> + + roWorkflowJdbcRepository.getCountByState(NonEmptyList.of(Validation.id, Deployment.id))).runNow must beEqualTo( + Map( + Validation.id -> 0, + Deployment.id -> 2 + ) + ) + } } } diff --git a/change-validation/src/test/scala/com/normation/plugins/changevalidation/api/ChangeRequestApiTest.scala b/change-validation/src/test/scala/com/normation/plugins/changevalidation/api/ChangeRequestApiTest.scala index 859b7337f..73058ae50 100644 --- a/change-validation/src/test/scala/com/normation/plugins/changevalidation/api/ChangeRequestApiTest.scala +++ b/change-validation/src/test/scala/com/normation/plugins/changevalidation/api/ChangeRequestApiTest.scala @@ -73,9 +73,9 @@ import com.normation.rudder.domain.policies.Tags import com.normation.rudder.domain.properties.AddGlobalParameterDiff import com.normation.rudder.domain.properties.DeleteGlobalParameterDiff import com.normation.rudder.domain.properties.ModifyToGlobalParameterDiff -import com.normation.rudder.domain.queries.And -import com.normation.rudder.domain.queries.NodeReturnType +import com.normation.rudder.domain.queries.CriterionComposition.* import com.normation.rudder.domain.queries.Query +import com.normation.rudder.domain.queries.QueryReturnType.* import com.normation.rudder.domain.queries.ResultTransformation import com.normation.rudder.domain.workflows.ChangeRequestId import com.normation.rudder.domain.workflows.ChangeRequestInfo @@ -96,10 +96,10 @@ import com.normation.rudder.rest.RestTestSetUp import com.normation.rudder.rest.TraitTestApiFromYamlFiles import com.normation.rudder.services.modification.DiffServiceImpl import java.nio.file.Files -import net.liftweb.common.Full import org.joda.time.DateTime import org.junit.runner.RunWith import zio.* +import zio.syntax.* import zio.test.* import zio.test.junit.ZTestJUnitRunner @@ -624,9 +624,9 @@ class ChangeRequestApiTest extends ZIOSpecDefault { mockServices.changeRequestRepository, mockServices.notificationService, mockServices.userService, - () => Full(true), - () => Full(true), - () => Full(true) + () => true.succeed, + () => true.succeed, + () => true.succeed ) restTestSetUp.workflowLevelService.overrideLevel( @@ -635,8 +635,8 @@ class ChangeRequestApiTest extends ZIOSpecDefault { restTestSetUp.workflowLevelService.defaultWorkflowService, validationWorkflowService, List.empty, - () => Full(true), - () => Full(false), + () => true.succeed, + () => false.succeed, null ) ) diff --git a/change-validation/src/test/scala/com/normation/plugins/changevalidation/api/WorkflowInternalApiTest.scala b/change-validation/src/test/scala/com/normation/plugins/changevalidation/api/WorkflowInternalApiTest.scala new file mode 100644 index 000000000..93a35dd33 --- /dev/null +++ b/change-validation/src/test/scala/com/normation/plugins/changevalidation/api/WorkflowInternalApiTest.scala @@ -0,0 +1,122 @@ +/* + ************************************************************************************* + * Copyright 2025 Normation SAS + ************************************************************************************* + * + * This file is part of Rudder. + * + * Rudder is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * In accordance with the terms of section 7 (7. Additional Terms.) of + * the GNU General Public License version 3, the copyright holders add + * the following Additional permissions: + * Notwithstanding to the terms of section 5 (5. Conveying Modified Source + * Versions) and 6 (6. Conveying Non-Source Forms.) of the GNU General + * Public License version 3, when you create a Related Module, this + * Related Module is not considered as a part of the work and may be + * distributed under the license agreement of your choice. + * A "Related Module" means a set of sources files including their + * documentation that, without modification of the Source Code, enables + * supplementary functions or services in addition to those offered by + * the Software. + * + * Rudder is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Rudder. If not, see . + + * + ************************************************************************************* + */ + +package com.normation.plugins.changevalidation.api + +import better.files.File +import com.normation.errors.IOResult +import com.normation.errors.effectUioUnit +import com.normation.plugins.changevalidation.MockServices +import com.normation.plugins.changevalidation.TwoValidationStepsWorkflowServiceImpl.Cancelled +import com.normation.plugins.changevalidation.TwoValidationStepsWorkflowServiceImpl.Deployment +import com.normation.plugins.changevalidation.TwoValidationStepsWorkflowServiceImpl.Validation +import com.normation.rudder.api.ApiVersion +import com.normation.rudder.domain.workflows.ChangeRequest +import com.normation.rudder.domain.workflows.ChangeRequestId +import com.normation.rudder.domain.workflows.ChangeRequestInfo +import com.normation.rudder.domain.workflows.ConfigurationChangeRequest +import com.normation.rudder.rest.RestTestSetUp +import com.normation.rudder.rest.TraitTestApiFromYamlFiles +import java.nio.file.Files +import org.junit.runner.RunWith +import zio.Scope +import zio.ZIO +import zio.test.Spec +import zio.test.TestEnvironment +import zio.test.ZIOSpecDefault +import zio.test.junit.ZTestJUnitRunner + +@RunWith(classOf[ZTestJUnitRunner]) +class WorkflowInternalApiTest extends ZIOSpecDefault { + + private def mockChangeRequest(id: Int): ChangeRequest = { + ConfigurationChangeRequest( + ChangeRequestId(id), + None, + ChangeRequestInfo(s"cr ${id}", "mock change request"), + Map.empty, + Map.empty, + Map.empty, + Map.empty + ) + } + + val restTestSetUp = RestTestSetUp.newEnv + + val tmpDir: File = File(Files.createTempDirectory("rudder-test-")) + val yamlSourceDirectory = "changevalidation_api" + val yamlDestTmpDirectory = tmpDir / "templates" + + val mockServices = new MockServices( + Map( + Validation.id -> List(mockChangeRequest(1), mockChangeRequest(2)), + Deployment.id -> List(mockChangeRequest(3)), + Cancelled.id -> List(mockChangeRequest(4)) + ) + ) + + val modules = List( + new WorkflowInternalApiImpl( + mockServices.workflowRepository, + restTestSetUp.userService + ) + ) + + val apiVersions = ApiVersion(13, true) :: ApiVersion(14, false) :: Nil + + val (rudderApi, liftRules) = TraitTestApiFromYamlFiles.buildLiftRules(modules, apiVersions, None) + val transformations: Map[String, String => String] = Map() + + override def spec: Spec[TestEnvironment with Scope, Any] = { + suite("All REST tests defined in files") { + + for { + s <- TraitTestApiFromYamlFiles.doTest( + yamlSourceDirectory, + yamlDestTmpDirectory, + liftRules, + List("api_workflowinternal.yml"), + transformations + ) + _ <- effectUioUnit( + if (java.lang.System.getProperty("tests.clean.tmp") != "false") IOResult.attempt(restTestSetUp.cleanup()) + else ZIO.unit + ) + } yield s + } + } +} diff --git a/datasources/packaging/metadata b/datasources/packaging/metadata index 2f98c188c..27ac0e312 100644 --- a/datasources/packaging/metadata +++ b/datasources/packaging/metadata @@ -8,5 +8,6 @@ "jar-files": [ "/opt/rudder/share/plugins/${plugin-name}/${plugin-name}.jar" ], "content": { "files.txz": "/opt/rudder/share/plugins" - } + }, + "requires-license": true } diff --git a/datasources/src/main/resources/template/dataSourceManagement.html b/datasources/src/main/resources/template/dataSourceManagement.html index 84083cac1..dd7d3f0d4 100644 --- a/datasources/src/main/resources/template/dataSourceManagement.html +++ b/datasources/src/main/resources/template/dataSourceManagement.html @@ -6,15 +6,15 @@
    - - -