Skip to content

Commit 7fdd78a

Browse files
committed
Experiment with addons
Addons allow to execute external programs in some context inside docspell. Currently it is possible to run them after processing files. Addons are provided by URLs to zip files.
1 parent e04a76f commit 7fdd78a

166 files changed

Lines changed: 8181 additions & 115 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)