Skip to content
New issue

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

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

Already on GitHub? Sign in to your account

Fixes #26243: Improve license errors handling in webapp #6138

Open
wants to merge 6 commits into
base: branches/rudder/8.3
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -155,7 +155,7 @@ object JsonGlobalPluginLimits {
res.copy(licensees = sortedDistinctLicensees)
}

private def comp[A](a: Option[A], b: Option[A], compare: (A, A) => A): Option[A] = (a, b) match {
def comp[A](a: Option[A], b: Option[A], compare: (A, A) => A): Option[A] = (a, b) match {
case (None, None) => None
case (Some(x), None) => Some(x)
case (None, Some(y)) => Some(y)
Expand Down Expand Up @@ -415,7 +415,7 @@ object PluginManagementError {
}

case class PluginId(value: String) extends AnyVal
object PluginId {
object PluginId {
implicit val decoder: JsonDecoder[PluginId] = JsonDecoder[String].mapOrFail(parse)
implicit val encoder: JsonEncoder[PluginId] = JsonEncoder[String].contramap(_.value)
implicit val transformer: Transformer[PluginId, String] = Transformer.derive[PluginId, String]
Expand All @@ -433,8 +433,84 @@ object PluginId {
}
}

/**
* Global license information about many plugins, aggregated such that :
* - more than 1 distinct licensees can exist for the collection of all plugins
* - end date of plugins licenses are aggregated, so that we know how many plugin license expire at a given date
**/
final case class JsonPluginsLicense(
licensees: Option[NonEmptyChunk[String]],
startDate: Option[ZonedDateTime],
endDates: Option[JsonPluginsLicense.DateCounts],
maxNodes: Option[Int]
) {
import JsonGlobalPluginLimits.*
import JsonPluginsLicense.*
def combine(that: JsonPluginsLicense): JsonPluginsLicense = {
// for efficiency : check equality and hash first before field comparison,
// as it will mostly be the case because license information should be similar
if (this == that) this
else {
JsonPluginsLicense(
comp[NonEmptyChunk[String]](this.licensees, that.licensees, _ ++ _),
comp[ZonedDateTime](this.startDate, that.startDate, (x, y) => if (x.isAfter(y)) x else y),
comp[JsonPluginsLicense.DateCounts](this.endDates, that.endDates, (x, y) => x.addValues(y)),
comp[Int](this.maxNodes, that.maxNodes, (x, y) => if (x < y) x else y)
)
}
}
}
object JsonPluginsLicense {
import DateFormaterService.JodaTimeToJava

final case class DateCount(date: ZonedDateTime, count: Int)

// encoded as json list but using a Map for unicity when grouping by date
type DateCounts = Map[ZonedDateTime, JsonPluginsLicense.DateCount]

implicit val dateFieldEncoder: JsonFieldEncoder[ZonedDateTime] =
JsonFieldEncoder[String].contramap(DateFormaterService.serializeZDT)

implicit val dateCountEncoder: JsonEncoder[DateCount] = DeriveJsonEncoder.gen[DateCount]
implicit val dateCountsEncoder: JsonEncoder[DateCounts] = JsonEncoder[Chunk[DateCount]].contramap(m => Chunk.from(m.values))
implicit val encoder: JsonEncoder[JsonPluginsLicense] = DeriveJsonEncoder.gen[JsonPluginsLicense]

def empty: JsonPluginsLicense = JsonPluginsLicense(None, None, None, None)

def fromLicenseInfo(info: PluginLicenseInfo): JsonPluginsLicense = {
val endDate = info.endDate.toJava
JsonPluginsLicense(
Some(NonEmptyChunk(info.licensee)),
Some(info.startDate.toJava),
Some(Map((endDate, DateCount(endDate, 1)))),
info.maxNodes
)
}

private def from(licenses: NonEmptyChunk[PluginLicenseInfo]): JsonPluginsLicense = {
val res = licenses.reduceMapLeft(fromLicenseInfo) { case (lim, lic) => lim.combine(fromLicenseInfo(lic)) }
val sortedDistinctLicensees = res.licensees.map(_.sorted.distinct).flatMap(NonEmptyChunk.fromChunk)
res.copy(licensees = sortedDistinctLicensees)
}

def from(licenses: Chunk[PluginLicenseInfo]): Option[JsonPluginsLicense] = {
NonEmptyChunk
.fromChunk(licenses)
.map(from(_))
.flatMap(r => Option.when(r != empty)(r))
}

implicit class DateCountsOps(x: DateCounts) {
def addValues(y: DateCounts): DateCounts = {
(x.toList ::: y.toList).groupBy { case (date, _) => date }.map {
case (k, v) => k -> DateCount(k, v.map { case (_, dc) => dc.count }.sum)
}.toMap
}
}
}

final case class JsonPluginsSystemDetails(
license: Option[JsonGlobalPluginLimits],
license: Option[JsonPluginsLicense],
plugins: Chunk[JsonPluginSystemDetails]
)
object JsonPluginsSystemDetails {
Expand All @@ -443,7 +519,7 @@ object JsonPluginsSystemDetails {
implicit val encoder: JsonEncoder[JsonPluginsSystemDetails] = DeriveJsonEncoder.gen[JsonPluginsSystemDetails]

def buildDetails(plugins: Chunk[JsonPluginSystemDetails]): JsonPluginsSystemDetails = {
val limits = JsonGlobalPluginLimits.getGlobalLimits(plugins.flatMap(_.license))
val limits = JsonPluginsLicense.from(plugins.flatMap(_.license))
JsonPluginsSystemDetails(limits, plugins)
}
}
Expand Down Expand Up @@ -579,7 +655,7 @@ object RudderPackagePlugin {
trait RudderPackageService {
import RudderPackageService.*

def updateBase(): IOResult[Option[CredentialError]]
def update(): IOResult[Option[PluginSettingsError]]

def listAllPlugins(): IOResult[Chunk[RudderPackagePlugin]]

Expand All @@ -591,18 +667,27 @@ trait RudderPackageService {

object RudderPackageService {

val ERROR_CODE: Int = 1

// see PluginSettings : the url and credentials configuration could cause errors :
final case class CredentialError(msg: String) extends RudderError

object CredentialError {
private val regex = "^.*ERROR.* (Received an HTTP 401 Unauthorized error.*)$".r

def fromResult(cmdResult: CmdResult): Option[CredentialError] = {
(cmdResult.code, cmdResult.stderr.strip) match { // do not forget to strip stderr
case (ERROR_CODE, regex(err)) => Some(CredentialError(err))
case _ => None
// PluginSettings configuration could cause errors with known codes and colored stderr in rudder-package
sealed abstract class PluginSettingsError extends RudderError
object PluginSettingsError {
final case class InvalidCredentials(override val msg: String) extends PluginSettingsError
final case class Unauthorized(override val msg: String) extends PluginSettingsError

private val colorRegex = "\u001b\\[[0-9;]*m".r
private val uncolor = (str: String) => colorRegex.replaceAllIn(str, "")

private val sanitizeCmdResult = (res: CmdResult) => res.transform(uncolor.compose(_.strip))

/**
* Maps known errors codes from rudder package and adapt the error message to have no color
*/
def fromResult(cmdResult: CmdResult): PureResult[Option[PluginSettingsError]] = {
val result = sanitizeCmdResult(cmdResult)
result.code match {
case 0 => Right(None)
case 2 => Right(Some(PluginSettingsError.InvalidCredentials(result.stderr)))
case 3 => Right(Some(PluginSettingsError.Unauthorized(result.stderr)))
case _ => Left(Inconsistency(result.debugString()))
}
}
}
Expand All @@ -619,17 +704,16 @@ class RudderPackageCmdService(configCmdLine: String) extends RudderPackageServic
case h :: tail => Right((h, tail))
}

override def updateBase(): IOResult[Option[CredentialError]] = {
override def update(): IOResult[Option[PluginSettingsError]] = {
// In case of error we need to check the result
for {
res <- runCmd("update" :: Nil)
(cmd, result) = res
err = CredentialError.fromResult(result)
_ <- ZIO.when(result.code != 0 && err.isEmpty) {
Inconsistency(
s"An error occurred while updating plugins list with '${cmd.display}':\n code: ${result.code}\n stderr: ${result.stderr}\n stdout: ${result.stdout}"
).fail
}
err <-
PluginSettingsError
.fromResult(result)
.toIO
.chainError(s"An error occurred while updating plugins list with '${cmd.display}'")
} yield {
err
}
Expand Down Expand Up @@ -686,10 +770,7 @@ class RudderPackageCmdService(configCmdLine: String) extends RudderPackageServic
}
private def runCmdOrFail(params: List[String])(errorMsg: String): IOResult[CmdResult] = {
runCmd(params).reject {
case (cmd, result) if result.code != 0 =>
Inconsistency(
s"${errorMsg} with '${cmd.display}':\n code: ${result.code}\n stderr: ${result.stderr}\n stdout: ${result.stdout}"
)
case (cmd, result) if result.code != 0 => Inconsistency(s"${errorMsg} with '${cmd.display}': ${result.debugString()}")
}.map(_._2)
}
}
Expand All @@ -699,8 +780,8 @@ class RudderPackageCmdService(configCmdLine: String) extends RudderPackageServic
*/
trait PluginSystemService {

def list(): IOResult[Either[CredentialError, Chunk[JsonPluginSystemDetails]]]

def updateIndex(): IOResult[Option[PluginSettingsError]]
def list(): IOResult[Chunk[JsonPluginSystemDetails]]
def install(plugins: Chunk[PluginId]): IOResult[Unit]
def remove(plugins: Chunk[PluginId]): IOResult[Unit]
def updateStatus(status: PluginSystemStatus, plugins: Chunk[PluginId]): IOResult[Unit]
Expand All @@ -711,8 +792,12 @@ trait PluginSystemService {
* Implementation for tests, will do any operation without any error
*/
class InMemoryPluginSystemService(ref: Ref[Map[PluginId, JsonPluginSystemDetails]]) extends PluginSystemService {
override def list(): UIO[Either[CredentialError, Chunk[JsonPluginSystemDetails]]] = {
ref.get.map(m => Right(Chunk.fromIterable(m.values)))
override def updateIndex(): IOResult[Option[PluginSettingsError]] = {
ZIO.none
}

override def list(): UIO[Chunk[JsonPluginSystemDetails]] = {
ref.get.map(m => Chunk.fromIterable(m.values))
}

override def install(plugins: Chunk[PluginId]): UIO[Unit] = {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,24 @@ import zio.syntax.*
final case class Cmd(cmdPath: String, parameters: List[String], environment: Map[String, String], cwdPath: Option[String]) {
def display: String = s"${cmdPath} ${parameters.mkString(" ")}"
}
final case class CmdResult(code: Int, stdout: String, stderr: String)
final case class CmdResult(code: Int, stdout: String, stderr: String) {

/**
* Display the attributes of this result, by default each on a new line with indentation
*/
def debugString(sep: String = "\n "): String = s"code: ${code}\n stderr: ${stderr}\n stdout: ${stdout}"
Copy link
Contributor Author

Choose a reason for hiding this comment

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

I will apply this in places where it should


/**
* Strips the stdout and stderr of the result, may be useful when matching with a regex or for display purpose
*/
def strip: CmdResult = transform(_.strip)

/**
* Apply some transformation (e.g. sanitizing on stdout and stderr)
*/
def transform(f: String => String): CmdResult = copy(stdout = f(stdout), stderr = f(stderr))

}

object RunNuCommand {

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@
*************************************************************************************
*/
package com.normation.plugins

import com.normation.errors.*
import com.normation.rudder.hooks.CmdResult
import org.junit.runner.RunWith
import org.specs2.mutable.Specification
Expand All @@ -44,19 +46,29 @@ import org.specs2.runner.JUnitRunner
class TestRudderPackageService extends Specification {

"RudderPackageService" should {
"handle credentials error" in {
"handle zero error code and message" in {
RudderPackageService.PluginSettingsError.fromResult(CmdResult(0, "", "OK")) must beRight(beNone)
}
"handle non-zero error code and message" in {
val res = CmdResult(
1,
2,
"",
"ERROR Received an HTTP 401 Unauthorized error when trying to get 8.2/rpkg.index. Please check your credentials in the configuration."
"\u001b[31mERROR\u001b[0m Invalid credentials, please check your credentials in the configuration. (received HTTP 401)\n"
)
RudderPackageService.CredentialError.fromResult(res).aka("CredentialError from cmd result") must beSome(
beEqualTo(
RudderPackageService.CredentialError(
"Received an HTTP 401 Unauthorized error when trying to get 8.2/rpkg.index. Please check your credentials in the configuration."
RudderPackageService.PluginSettingsError.fromResult(res).aka("PluginSettingsError from cmd result") must beRight(
beSome(
beEqualTo(
RudderPackageService.PluginSettingsError.InvalidCredentials(
"ERROR Invalid credentials, please check your credentials in the configuration. (received HTTP 401)"
)
)
)
)
}
"handle unknown error code and message" in {
RudderPackageService.PluginSettingsError.fromResult(
CmdResult(12345, "", "\u001b[31mERROR\u001b[0m Unknown error")
) must beLeft(beLike[RudderError] { case err: Inconsistency => err.msg must contain("ERROR Unknown error") })
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -649,6 +649,12 @@ sealed trait PluginInternalApi extends EnumEntry with EndpointSchema with Intern
}
object PluginInternalApi extends Enum[PluginInternalApi] with ApiModuleProvider[PluginInternalApi] {

case object UpdatePluginsIndex extends PluginInternalApi with ZeroParam with StartsAtVersion21 with SortIndex {
val z: Int = implicitly[Line].value
val description = "Update plugins index and licenses"
val (action, path) = POST / "pluginsinternal" / "update"
override def dataContainer: Option[String] = None
}
case object ListPlugins extends PluginInternalApi with ZeroParam with StartsAtVersion21 with SortIndex {
val z: Int = implicitly[Line].value
val description = "List all plugins"
Expand Down
Loading