Skip to content

Commit 8a86de4

Browse files
authored
Merge pull request #1550 from eikek/addons-experiment
Addons experiment
2 parents 53a006c + 73747c4 commit 8a86de4

199 files changed

Lines changed: 11062 additions & 128 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

build.sbt

Lines changed: 39 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -293,6 +293,15 @@ val openapiScalaSettings = Seq(
293293
field.copy(typeDef =
294294
TypeDef("DownloadState", Imports("docspell.common.DownloadState"))
295295
)
296+
case "addon-trigger-type" =>
297+
field =>
298+
field.copy(typeDef =
299+
TypeDef("AddonTriggerType", Imports("docspell.addons.AddonTriggerType"))
300+
)
301+
case "addon-runner-type" =>
302+
field =>
303+
field
304+
.copy(typeDef = TypeDef("RunnerType", Imports("docspell.addons.RunnerType")))
296305
})
297306
)
298307

@@ -325,6 +334,7 @@ val common = project
325334
libraryDependencies ++=
326335
Dependencies.fs2 ++
327336
Dependencies.circe ++
337+
Dependencies.circeGenericExtra ++
328338
Dependencies.calevCore ++
329339
Dependencies.calevCirce
330340
)
@@ -351,7 +361,7 @@ val files = project
351361
.in(file("modules/files"))
352362
.disablePlugins(RevolverPlugin)
353363
.settings(sharedSettings)
354-
.withTestSettings
364+
.withTestSettingsDependsOn(loggingScribe)
355365
.settings(
356366
name := "docspell-files",
357367
libraryDependencies ++=
@@ -448,6 +458,19 @@ val notificationApi = project
448458
)
449459
.dependsOn(common, loggingScribe)
450460

461+
val addonlib = project
462+
.in(file("modules/addonlib"))
463+
.disablePlugins(RevolverPlugin)
464+
.settings(sharedSettings)
465+
.withTestSettingsDependsOn(loggingScribe)
466+
.settings(
467+
libraryDependencies ++=
468+
Dependencies.fs2 ++
469+
Dependencies.circe ++
470+
Dependencies.circeYaml
471+
)
472+
.dependsOn(common, files, loggingScribe)
473+
451474
val store = project
452475
.in(file("modules/store"))
453476
.disablePlugins(RevolverPlugin)
@@ -469,7 +492,16 @@ val store = project
469492
libraryDependencies ++=
470493
Dependencies.testContainer.map(_ % Test)
471494
)
472-
.dependsOn(common, query.jvm, totp, files, notificationApi, jsonminiq, loggingScribe)
495+
.dependsOn(
496+
common,
497+
addonlib,
498+
query.jvm,
499+
totp,
500+
files,
501+
notificationApi,
502+
jsonminiq,
503+
loggingScribe
504+
)
473505

474506
val notificationImpl = project
475507
.in(file("modules/notification/impl"))
@@ -647,7 +679,7 @@ val restapi = project
647679
openapiSpec := (Compile / resourceDirectory).value / "docspell-openapi.yml",
648680
openapiStaticGen := OpenApiDocGenerator.Redoc
649681
)
650-
.dependsOn(common, query.jvm, notificationApi, jsonminiq)
682+
.dependsOn(common, query.jvm, notificationApi, jsonminiq, addonlib)
651683

652684
val joexapi = project
653685
.in(file("modules/joexapi"))
@@ -667,7 +699,7 @@ val joexapi = project
667699
openapiSpec := (Compile / resourceDirectory).value / "joex-openapi.yml",
668700
openapiStaticGen := OpenApiDocGenerator.Redoc
669701
)
670-
.dependsOn(common, loggingScribe)
702+
.dependsOn(common, loggingScribe, addonlib)
671703

672704
val backend = project
673705
.in(file("modules/backend"))
@@ -683,6 +715,7 @@ val backend = project
683715
Dependencies.emil
684716
)
685717
.dependsOn(
718+
addonlib,
686719
store,
687720
notificationApi,
688721
joexapi,
@@ -739,7 +772,7 @@ val config = project
739772
Dependencies.fs2 ++
740773
Dependencies.pureconfig
741774
)
742-
.dependsOn(common, loggingApi, ftspsql, store)
775+
.dependsOn(common, loggingApi, ftspsql, store, addonlib)
743776

744777
// --- Application(s)
745778

@@ -946,6 +979,7 @@ val root = project
946979
)
947980
.aggregate(
948981
common,
982+
addonlib,
949983
loggingApi,
950984
loggingScribe,
951985
config,
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
/*
2+
* Copyright 2020 Eike K. & Contributors
3+
*
4+
* SPDX-License-Identifier: AGPL-3.0-or-later
5+
*/
6+
7+
package docspell.addons
8+
9+
import cats.effect._
10+
import cats.syntax.all._
11+
import fs2.Stream
12+
import fs2.io.file.{Files, Path}
13+
14+
import docspell.common._
15+
import docspell.files.Zip
16+
17+
final case class AddonArchive(url: LenientUri, name: String, version: String) {
18+
def nameAndVersion: String =
19+
s"$name-$version"
20+
21+
def extractTo[F[_]: Async](
22+
reader: UrlReader[F],
23+
directory: Path,
24+
withSubdir: Boolean = true,
25+
glob: Glob = Glob.all
26+
): F[Path] = {
27+
val logger = docspell.logging.getLogger[F]
28+
val target =
29+
if (withSubdir) directory.absolute / nameAndVersion
30+
else directory.absolute
31+
32+
Files[F]
33+
.exists(target)
34+
.flatMap {
35+
case true => target.pure[F]
36+
case false =>
37+
Files[F].createDirectories(target) *>
38+
reader(url)
39+
.through(Zip.unzip(8192, glob))
40+
.through(Zip.saveTo(logger, target, moveUp = true))
41+
.compile
42+
.drain
43+
.as(target)
44+
}
45+
}
46+
47+
/** Read meta either from the given directory or extract the url to find the metadata
48+
* file to read
49+
*/
50+
def readMeta[F[_]: Async](
51+
urlReader: UrlReader[F],
52+
directory: Option[Path] = None
53+
): F[AddonMeta] =
54+
directory
55+
.map(AddonMeta.findInDirectory[F])
56+
.getOrElse(AddonMeta.findInZip(urlReader(url)))
57+
}
58+
59+
object AddonArchive {
60+
def read[F[_]: Async](
61+
url: LenientUri,
62+
urlReader: UrlReader[F],
63+
extractDir: Option[Path] = None
64+
): F[AddonArchive] = {
65+
val addon = AddonArchive(url, "", "")
66+
addon
67+
.readMeta(urlReader, extractDir)
68+
.map(m => addon.copy(name = m.meta.name, version = m.meta.version))
69+
}
70+
71+
def dockerAndFlakeExists[F[_]: Async](
72+
archive: Either[Path, Stream[F, Byte]]
73+
): F[(Boolean, Boolean)] = {
74+
val files = Files[F]
75+
def forPath(path: Path): F[(Boolean, Boolean)] =
76+
(files.exists(path / "Dockerfile"), files.exists(path / "flake.nix")).tupled
77+
78+
def forZip(data: Stream[F, Byte]): F[(Boolean, Boolean)] =
79+
data
80+
.through(Zip.unzip(8192, Glob("Dockerfile|flake.nix")))
81+
.collect {
82+
case bin if bin.name == "Dockerfile" => (true, false)
83+
case bin if bin.name == "flake.nix" => (false, true)
84+
}
85+
.compile
86+
.fold((false, false))((r, e) => (r._1 || e._1, r._2 || e._2))
87+
88+
archive.fold(forPath, forZip)
89+
}
90+
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
/*
2+
* Copyright 2020 Eike K. & Contributors
3+
*
4+
* SPDX-License-Identifier: AGPL-3.0-or-later
5+
*/
6+
7+
package docspell.addons
8+
9+
import cats.Monoid
10+
import cats.syntax.all._
11+
12+
case class AddonExecutionResult(
13+
addonResults: List[AddonResult],
14+
pure: Boolean
15+
) {
16+
def addonResult: AddonResult = addonResults.combineAll
17+
def isFailure: Boolean = addonResult.isFailure
18+
def isSuccess: Boolean = addonResult.isSuccess
19+
}
20+
21+
object AddonExecutionResult {
22+
val empty: AddonExecutionResult =
23+
AddonExecutionResult(Nil, false)
24+
25+
def combine(a: AddonExecutionResult, b: AddonExecutionResult): AddonExecutionResult =
26+
AddonExecutionResult(
27+
a.addonResults ::: b.addonResults,
28+
a.pure && b.pure
29+
)
30+
31+
implicit val executionResultMonoid: Monoid[AddonExecutionResult] =
32+
Monoid.instance(empty, combine)
33+
}
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
/*
2+
* Copyright 2020 Eike K. & Contributors
3+
*
4+
* SPDX-License-Identifier: AGPL-3.0-or-later
5+
*/
6+
7+
package docspell.addons
8+
9+
import cats.data.Kleisli
10+
import cats.effect._
11+
import cats.syntax.all._
12+
import fs2.Stream
13+
import fs2.io.file._
14+
15+
import docspell.common.UrlReader
16+
import docspell.common.exec.Env
17+
import docspell.logging.Logger
18+
19+
trait AddonExecutor[F[_]] {
20+
21+
def config: AddonExecutorConfig
22+
23+
def execute(logger: Logger[F]): AddonExec[F]
24+
25+
def execute(logger: Logger[F], in: InputEnv): F[AddonExecutionResult] =
26+
execute(logger).run(in)
27+
}
28+
29+
object AddonExecutor {
30+
31+
def apply[F[_]: Async](
32+
cfg: AddonExecutorConfig,
33+
urlReader: UrlReader[F]
34+
): AddonExecutor[F] =
35+
new AddonExecutor[F] with AddonLoggerExtension {
36+
val config = cfg
37+
38+
def execute(logger: Logger[F]): AddonExec[F] =
39+
Kleisli { in =>
40+
for {
41+
_ <- logger.info(s"About to run ${in.addons.size} addon(s) in ${in.baseDir}")
42+
ctx <- prepareDirectory(
43+
logger,
44+
in.baseDir,
45+
in.outputDir,
46+
in.cacheDir,
47+
in.addons
48+
)
49+
rs <- ctx.traverse(c => runAddon(logger.withAddon(c), in.env)(c))
50+
pure = ctx.foldl(true)((b, c) => b && c.meta.isPure)
51+
} yield AddonExecutionResult(rs, pure)
52+
}
53+
54+
private def prepareDirectory(
55+
logger: Logger[F],
56+
baseDir: Path,
57+
outDir: Path,
58+
cacheDir: Path,
59+
addons: List[AddonRef]
60+
): F[List[Context]] =
61+
for {
62+
addonsDir <- Directory.create(baseDir / "addons")
63+
_ <- Directory.createAll(Context.tempDir(baseDir), outDir, cacheDir)
64+
_ <- Context
65+
.userInputFile(baseDir)
66+
.parent
67+
.fold(().pure[F])(Files[F].createDirectories)
68+
archives = addons.map(_.archive).distinctBy(_.url)
69+
_ <- logger.info(s"Extract ${archives.size} addons to $addonsDir")
70+
mkCtxs <- archives.traverse { archive =>
71+
for {
72+
_ <- logger.debug(s"Extracting $archive")
73+
addonDir <- archive.extractTo(urlReader, addonsDir)
74+
meta <- AddonMeta.findInDirectory(addonDir)
75+
mkCtx = (ref: AddonRef) =>
76+
Context(ref, meta, baseDir, addonDir, outDir, cacheDir)
77+
} yield archive.url -> mkCtx
78+
}
79+
ctxFactory = mkCtxs.toMap
80+
res = addons.map(ref => ctxFactory(ref.archive.url)(ref))
81+
} yield res
82+
83+
private def runAddon(logger: Logger[F], env: Env)(
84+
ctx: Context
85+
): F[AddonResult] =
86+
for {
87+
_ <- logger.info(s"Executing addon ${ctx.meta.nameAndVersion}")
88+
_ <- logger.trace("Storing user input into file")
89+
_ <- Stream
90+
.emit(ctx.addon.args)
91+
.through(fs2.text.utf8.encode)
92+
.through(Files[F].writeAll(ctx.userInputFile, Flags.Write))
93+
.compile
94+
.drain
95+
96+
runner <- selectRunner(cfg, ctx.meta, ctx.addonDir)
97+
result <- runner.run(logger, env, ctx)
98+
} yield result
99+
}
100+
101+
def selectRunner[F[_]: Async](
102+
cfg: AddonExecutorConfig,
103+
meta: AddonMeta,
104+
addonDir: Path
105+
): F[AddonRunner[F]] =
106+
for {
107+
addonRunner <- meta.enabledTypes(Left(addonDir))
108+
// intersect on list retains order in first
109+
possibleRunner = cfg.runner
110+
.intersect(addonRunner)
111+
.map(AddonRunner.forType[F](cfg))
112+
runner = possibleRunner match {
113+
case Nil =>
114+
AddonRunner.failWith(
115+
s"No runner available for addon config ${meta.runner} and config ${cfg.runner}."
116+
)
117+
case list =>
118+
AddonRunner.firstSuccessful(list)
119+
}
120+
} yield runner
121+
}

0 commit comments

Comments
 (0)