From 703860a24e7dd4ea28e71b8f50d7377b63e3eb2d Mon Sep 17 00:00:00 2001 From: Tibor Casteleijn Date: Mon, 23 Feb 2026 12:04:53 +0100 Subject: [PATCH 01/12] Move config up because project depends on it --- build.sbt | 31 ++++++++++++++++--------------- 1 file changed, 16 insertions(+), 15 deletions(-) diff --git a/build.sbt b/build.sbt index 40ea9dea7a..bd6db9e43b 100644 --- a/build.sbt +++ b/build.sbt @@ -712,6 +712,19 @@ val joexapi = project ) .dependsOn(common, loggingScribe, addonlib) +val config = project + .in(file("modules/config")) + .disablePlugins(RevolverPlugin) + .settings(sharedSettings) + .withTestSettings + .settings( + name := "docspell-config", + libraryDependencies ++= + Dependencies.fs2 ++ + Dependencies.pureconfig + ) + .dependsOn(common, loggingApi, ftspsql, store, addonlib) + val backend = project .in(file("modules/backend")) .disablePlugins(RevolverPlugin) @@ -723,10 +736,12 @@ val backend = project Dependencies.fs2 ++ Dependencies.bcrypt ++ Dependencies.http4sClient ++ - Dependencies.emil + Dependencies.emil ++ + Dependencies.pureconfig ) .dependsOn( addonlib, + config, store, notificationApi, joexapi, @@ -771,20 +786,6 @@ val webapp = project ) .dependsOn(query.js) -// Config project shared among the two applications only -val config = project - .in(file("modules/config")) - .disablePlugins(RevolverPlugin) - .settings(sharedSettings) - .withTestSettings - .settings( - name := "docspell-config", - libraryDependencies ++= - Dependencies.fs2 ++ - Dependencies.pureconfig - ) - .dependsOn(common, loggingApi, ftspsql, store, addonlib) - // --- Application(s) val joex = project From 692fe05603ec47b71e590b2f7bd4ff57766082c4 Mon Sep 17 00:00:00 2001 From: Tibor Casteleijn Date: Mon, 23 Feb 2026 12:32:15 +0100 Subject: [PATCH 02/12] Add AddonConfig types, AddonEnvConfig.configs, and reference.conf - Add AddonConfig, AddonEnvVar, AddonEnvVarFrom case classes - Extend AddonEnvConfig with configs: List[AddonConfig] - Add configs = [] to docspell.joex.addons in reference.conf - Add HOCON parsing tests for AddonConfig and AddonEnvConfig Co-authored-by: Cursor --- .../docspell/backend/joex/AddonConfig.scala | 29 ++++ .../backend/joex/AddonEnvConfig.scala | 3 +- .../backend/joex/AddonConfigTest.scala | 124 ++++++++++++++++++ .../joex/src/main/resources/reference.conf | 3 + 4 files changed, 158 insertions(+), 1 deletion(-) create mode 100644 modules/backend/src/main/scala/docspell/backend/joex/AddonConfig.scala create mode 100644 modules/backend/src/test/scala/docspell/backend/joex/AddonConfigTest.scala diff --git a/modules/backend/src/main/scala/docspell/backend/joex/AddonConfig.scala b/modules/backend/src/main/scala/docspell/backend/joex/AddonConfig.scala new file mode 100644 index 0000000000..eb5d8d6e38 --- /dev/null +++ b/modules/backend/src/main/scala/docspell/backend/joex/AddonConfig.scala @@ -0,0 +1,29 @@ +/* + * Copyright 2020 Eike K. & Contributors + * + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package docspell.backend.joex + +/** Per-addon configuration for custom environment variables. + * Matched by addon name (AddonMeta.Meta.name). + */ +final case class AddonConfig( + name: String, + enabled: Boolean = true, + envs: List[AddonEnvVar] = Nil +) + +/** A single environment variable to inject, with either direct value or valueFrom. */ +final case class AddonEnvVar( + name: String, + value: Option[String] = None, + valueFrom: Option[AddonEnvVarFrom] = None +) + +/** Kubernetes-style valueFrom: read value from another source (e.g. process env). */ +final case class AddonEnvVarFrom( + env: Option[String] = None, + optional: Boolean = true +) diff --git a/modules/backend/src/main/scala/docspell/backend/joex/AddonEnvConfig.scala b/modules/backend/src/main/scala/docspell/backend/joex/AddonEnvConfig.scala index dba1cbf4ea..b82a1c9dfe 100644 --- a/modules/backend/src/main/scala/docspell/backend/joex/AddonEnvConfig.scala +++ b/modules/backend/src/main/scala/docspell/backend/joex/AddonEnvConfig.scala @@ -13,5 +13,6 @@ import docspell.addons.AddonExecutorConfig final case class AddonEnvConfig( workingDir: Path, cacheDir: Path, - executorConfig: AddonExecutorConfig + executorConfig: AddonExecutorConfig, + configs: List[AddonConfig] = Nil ) diff --git a/modules/backend/src/test/scala/docspell/backend/joex/AddonConfigTest.scala b/modules/backend/src/test/scala/docspell/backend/joex/AddonConfigTest.scala new file mode 100644 index 0000000000..e0728c0db6 --- /dev/null +++ b/modules/backend/src/test/scala/docspell/backend/joex/AddonConfigTest.scala @@ -0,0 +1,124 @@ +/* + * Copyright 2020 Eike K. & Contributors + * + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package docspell.backend.joex + +import docspell.config.Implicits._ + +import pureconfig.ConfigSource +import pureconfig.generic.auto._ + +import munit.FunSuite + +class AddonConfigTest extends FunSuite { + + test("parse AddonConfig from HOCON with value only") { + val config = ConfigSource.string(""" + |name = "my-addon" + |enabled = true + |envs = [ + | { name = "FOO", value = "bar" } + |] + |""".stripMargin) + val result = config.at("").load[AddonConfig] + assert(result.isRight, clue = result.left.map(_.toString)) + val cfg = result.toOption.get + assertEquals(cfg.name, "my-addon") + assertEquals(cfg.enabled, true) + assertEquals(cfg.envs.size, 1) + assertEquals(cfg.envs.head.name, "FOO") + assertEquals(cfg.envs.head.value, Some("bar")) + assertEquals(cfg.envs.head.valueFrom, None) + } + + test("parse AddonConfig from HOCON with valueFrom only") { + val config = ConfigSource.string(""" + |name = "my-addon" + |enabled = true + |envs = [ + | { + | name = "SECRET" + | value-from = { env = "DS_SECRET", optional = true } + | } + |] + |""".stripMargin) + val result = config.at("").load[AddonConfig] + assert(result.isRight, clue = result.left.map(_.toString)) + val cfg = result.toOption.get + assertEquals(cfg.envs.size, 1) + assertEquals(cfg.envs.head.name, "SECRET") + assertEquals(cfg.envs.head.value, None) + assertEquals(cfg.envs.head.valueFrom, Some(AddonEnvVarFrom(env = Some("DS_SECRET"), optional = true))) + } + + test("parse AddonConfig with both value and valueFrom") { + val config = ConfigSource.string(""" + |name = "my-addon" + |envs = [ + | { + | name = "MIXED" + | value = "direct" + | value-from = { env = "DS_MIXED", optional = false } + | } + |] + |""".stripMargin) + val result = config.at("").load[AddonConfig] + assert(result.isRight, clue = result.left.map(_.toString)) + val cfg = result.toOption.get + assertEquals(cfg.envs.head.value, Some("direct")) + assertEquals(cfg.envs.head.valueFrom, Some(AddonEnvVarFrom(env = Some("DS_MIXED"), optional = false))) + } + + test("parse AddonEnvConfig with empty addonConfigs") { + val config = ConfigSource.string(""" + |working-dir = "/tmp/work" + |cache-dir = "/tmp/cache" + |executor-config { + | runner = "trivial" + | run-timeout = "5 minutes" + | fail-fast = true + | nspawn = { enabled = false, sudo-binary = "sudo", nspawn-binary = "nspawn", container-wait = "100 millis" } + | nix-runner = { nix-binary = "nix", build-timeout = "5 minutes" } + | docker-runner = { docker-binary = "docker", build-timeout = "5 minutes" } + |} + |""".stripMargin) + val result = config.at("").load[AddonEnvConfig] + assert(result.isRight, clue = result.left.map(_.toString)) + val cfg = result.toOption.get + assertEquals(cfg.configs, Nil) + } + + test("parse AddonEnvConfig with non-empty addonConfigs") { + val config = ConfigSource.string(""" + |working-dir = "/tmp/work" + |cache-dir = "/tmp/cache" + |executor-config { + | runner = "trivial" + | run-timeout = "5 minutes" + | fail-fast = true + | nspawn = { enabled = false, sudo-binary = "sudo", nspawn-binary = "nspawn", container-wait = "100 millis" } + | nix-runner = { nix-binary = "nix", build-timeout = "5 minutes" } + | docker-runner = { docker-binary = "docker", build-timeout = "5 minutes" } + |} + |configs = [ + | { + | name = "postgres-addon" + | enabled = true + | envs = [ + | { name = "PG_HOST", value = "localhost" } + | ] + | } + |] + |""".stripMargin) + val result = config.at("").load[AddonEnvConfig] + assert(result.isRight, clue = result.left.map(_.toString)) + val cfg = result.toOption.get + assertEquals(cfg.configs.size, 1) + assertEquals(cfg.configs.head.name, "postgres-addon") + assertEquals(cfg.configs.head.envs.head.name, "PG_HOST") + assertEquals(cfg.configs.head.envs.head.value, Some("localhost")) + } +} diff --git a/modules/joex/src/main/resources/reference.conf b/modules/joex/src/main/resources/reference.conf index ae62b130a9..0770d9544f 100644 --- a/modules/joex/src/main/resources/reference.conf +++ b/modules/joex/src/main/resources/reference.conf @@ -863,6 +863,9 @@ Docpell Update Check } addons { + # Per-addon environment variable configuration. Keyed by addon name. + configs = [] + # A directory to extract addons when running them. Everything in # here will be cleared after each run. working-dir = ${java.io.tmpdir}"/docspell-addons" From 6fefef3a16a93dd00a41ad401a03be49aac28f99 Mon Sep 17 00:00:00 2001 From: Tibor Casteleijn Date: Mon, 23 Feb 2026 12:33:16 +0100 Subject: [PATCH 03/12] Add env var resolution and toEnv in AddonConfig - Add AddonEnvVar.resolve (value, valueFrom.env, optional) - Add AddonConfig.toEnv for enabled configs - Add tests for resolve and toEnv Co-authored-by: Cursor --- .../docspell/backend/joex/AddonConfig.scala | 30 +++++++++++++- .../backend/joex/AddonConfigTest.scala | 41 +++++++++++++++++++ 2 files changed, 69 insertions(+), 2 deletions(-) diff --git a/modules/backend/src/main/scala/docspell/backend/joex/AddonConfig.scala b/modules/backend/src/main/scala/docspell/backend/joex/AddonConfig.scala index eb5d8d6e38..53c10c34f5 100644 --- a/modules/backend/src/main/scala/docspell/backend/joex/AddonConfig.scala +++ b/modules/backend/src/main/scala/docspell/backend/joex/AddonConfig.scala @@ -6,6 +6,8 @@ package docspell.backend.joex +import docspell.common.exec.Env + /** Per-addon configuration for custom environment variables. * Matched by addon name (AddonMeta.Meta.name). */ @@ -13,14 +15,38 @@ final case class AddonConfig( name: String, enabled: Boolean = true, envs: List[AddonEnvVar] = Nil -) +) { + + /** Resolve all env vars to an Env map. Only applies when enabled. */ + def toEnv: Env = + if (!enabled) Env.empty + else envs.foldLeft(Env.empty) { (acc, ev) => + ev.resolve.fold(acc)(kv => acc.add(kv._1, kv._2)) + } +} /** A single environment variable to inject, with either direct value or valueFrom. */ final case class AddonEnvVar( name: String, value: Option[String] = None, valueFrom: Option[AddonEnvVarFrom] = None -) +) { + + /** Resolve to Some((name, value)) or None if skipped. */ + def resolve: Option[(String, String)] = + value match { + case Some(v) => Some(name -> v) + case None => + valueFrom.flatMap(_.env) match { + case Some(envVar) => + val fromEnv = System.getenv(envVar) + if (fromEnv != null) Some(name -> fromEnv) + else if (valueFrom.exists(!_.optional)) Some(name -> "") + else None + case None => None + } + } +} /** Kubernetes-style valueFrom: read value from another source (e.g. process env). */ final case class AddonEnvVarFrom( diff --git a/modules/backend/src/test/scala/docspell/backend/joex/AddonConfigTest.scala b/modules/backend/src/test/scala/docspell/backend/joex/AddonConfigTest.scala index e0728c0db6..cd01216028 100644 --- a/modules/backend/src/test/scala/docspell/backend/joex/AddonConfigTest.scala +++ b/modules/backend/src/test/scala/docspell/backend/joex/AddonConfigTest.scala @@ -6,6 +6,7 @@ package docspell.backend.joex +import docspell.common.exec.Env import docspell.config.Implicits._ import pureconfig.ConfigSource @@ -121,4 +122,44 @@ class AddonConfigTest extends FunSuite { assertEquals(cfg.configs.head.envs.head.name, "PG_HOST") assertEquals(cfg.configs.head.envs.head.value, Some("localhost")) } + + test("AddonEnvVar.resolve with value") { + val ev = AddonEnvVar(name = "FOO", value = Some("bar")) + assertEquals(ev.resolve, Some("FOO" -> "bar")) + } + + test("AddonEnvVar.resolve with valueFrom, optional=true, env unset") { + val ev = AddonEnvVar( + name = "SECRET", + valueFrom = Some(AddonEnvVarFrom(env = Some("DOCSPELL_ADDON_TEST_UNLIKELY_12345"), optional = true)) + ) + assertEquals(ev.resolve, None) + } + + test("AddonEnvVar.resolve with valueFrom, optional=false, env unset") { + val ev = AddonEnvVar( + name = "REQUIRED", + valueFrom = Some(AddonEnvVarFrom(env = Some("DOCSPELL_ADDON_TEST_UNLIKELY_67890"), optional = false)) + ) + assertEquals(ev.resolve, Some("REQUIRED" -> "")) + } + + test("AddonConfig.toEnv when disabled") { + val cfg = AddonConfig(name = "x", enabled = false, envs = List(AddonEnvVar("A", value = Some("a")))) + assertEquals(cfg.toEnv, Env.empty) + } + + test("AddonConfig.toEnv when enabled") { + val cfg = AddonConfig( + name = "x", + enabled = true, + envs = List( + AddonEnvVar("A", value = Some("a")), + AddonEnvVar("B", value = Some("b")) + ) + ) + val env = cfg.toEnv + assertEquals(env.values.get("A"), Some("a")) + assertEquals(env.values.get("B"), Some("b")) + } } From 7cc43e8f7cf848dd40fde2a65fb4e444f5d031e3 Mon Sep 17 00:00:00 2001 From: Tibor Casteleijn Date: Mon, 23 Feb 2026 12:34:38 +0100 Subject: [PATCH 04/12] Wire addon env configs into AddonExecutor and AddonOps - Add addonEnvResolver: String => Env to AddonExecutor - Merge config env in runAddon before runner.run - Build resolver from cfg.configs in AddonOps.getExecutor - Add inject env vars test in AddonExecutorTest Co-authored-by: Cursor --- .../scala/docspell/addons/AddonExecutor.scala | 7 +++++-- .../docspell/addons/AddonExecutorTest.scala | 18 ++++++++++++++++++ .../scala/docspell/backend/joex/AddonOps.scala | 7 +++++-- 3 files changed, 28 insertions(+), 4 deletions(-) diff --git a/modules/addonlib/src/main/scala/docspell/addons/AddonExecutor.scala b/modules/addonlib/src/main/scala/docspell/addons/AddonExecutor.scala index e79581d7a4..97d713b740 100644 --- a/modules/addonlib/src/main/scala/docspell/addons/AddonExecutor.scala +++ b/modules/addonlib/src/main/scala/docspell/addons/AddonExecutor.scala @@ -31,7 +31,8 @@ object AddonExecutor { def apply[F[_]: Async: Files]( cfg: AddonExecutorConfig, - urlReader: UrlReader[F] + urlReader: UrlReader[F], + addonEnvResolver: String => Env = _ => Env.empty ): AddonExecutor[F] = new AddonExecutor[F] with AddonLoggerExtension { val config = cfg @@ -99,8 +100,10 @@ object AddonExecutor { .compile .drain + addonEnv = addonEnvResolver(ctx.meta.meta.name) + mergedEnv = env.addAll(addonEnv) runner <- selectRunner(cfg, ctx.meta, ctx.addonDir) - result <- runner.run(logger, env, ctx) + result <- runner.run(logger, mergedEnv, ctx) } yield result } diff --git a/modules/addonlib/src/test/scala/docspell/addons/AddonExecutorTest.scala b/modules/addonlib/src/test/scala/docspell/addons/AddonExecutorTest.scala index 940fe091f2..fb4d1f02a5 100644 --- a/modules/addonlib/src/test/scala/docspell/addons/AddonExecutorTest.scala +++ b/modules/addonlib/src/test/scala/docspell/addons/AddonExecutorTest.scala @@ -10,6 +10,7 @@ import cats.effect._ import cats.syntax.all._ import docspell.addons.out.AddonOutput +import docspell.common.exec.Env import docspell.common.UrlReader import docspell.common.bc.{BackendCommand, ItemAction} import docspell.logging.{Level, TestLoggingConfig} @@ -109,6 +110,23 @@ class AddonExecutorTest extends CatsEffectSuite with Fixtures with TestLoggingCo } } + tempDir.test("inject env vars from addonEnvResolver") { dir => + val addonEnvResolver: String => Env = { + case "env-test-addon" => Env.of("ADDON_FOO" -> "injected-value") + case _ => Env.empty + } + val cfg = testExecutorConfig(RunnerType.Trivial) + val exec = + AddonExecutor[IO](cfg, UrlReader.defaultReader, addonEnvResolver).execute(logger) + val addon = AddonGenerator.generate("env-test-addon", "1.0", collectOutput = false)( + """if [ "$ADDON_FOO" = "injected-value" ]; then exit 0; else exit 1; fi""" + ) + val result = createInputEnv(dir, addon).use(exec.run) + result.map { res => + assert(res.isSuccess, clue = res) + } + } + tempDir.test("combine outputs") { dir => val cfg = testExecutorConfig(RunnerType.Trivial).copy(failFast = false) val exec = AddonExecutor[IO](cfg, UrlReader.defaultReader).execute(logger) diff --git a/modules/backend/src/main/scala/docspell/backend/joex/AddonOps.scala b/modules/backend/src/main/scala/docspell/backend/joex/AddonOps.scala index 52ccaf8c3f..615a87e8a7 100644 --- a/modules/backend/src/main/scala/docspell/backend/joex/AddonOps.scala +++ b/modules/backend/src/main/scala/docspell/backend/joex/AddonOps.scala @@ -170,8 +170,11 @@ object AddonOps { .ephemeralRun[F] } yield mm - def getExecutor(cfg: AddonExecutorConfig): F[AddonExecutor[F]] = - Async[F].pure(AddonExecutor(cfg, urlReader)) + def getExecutor(execCfg: AddonExecutorConfig): F[AddonExecutor[F]] = { + val addonEnvResolver: String => Env = name => + cfg.configs.find(_.name == name).fold(Env.empty)(_.toEnv) + Async[F].pure(AddonExecutor(execCfg, urlReader, addonEnvResolver)) + } def findAddonRefs( collective: CollectiveId, From bbba6b3d4657eb402444ff7aaa69951e5645d584 Mon Sep 17 00:00:00 2001 From: Tibor Casteleijn Date: Mon, 23 Feb 2026 12:34:47 +0100 Subject: [PATCH 05/12] Fix ZolaPlugin pure expression warning Use tuple discard for task dependencies to avoid 'pure expression does nothing in statement position' compiler warning. Co-authored-by: Cursor --- project/ZolaPlugin.scala | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/project/ZolaPlugin.scala b/project/ZolaPlugin.scala index 1311fadcc6..6c5f95d44d 100644 --- a/project/ZolaPlugin.scala +++ b/project/ZolaPlugin.scala @@ -38,8 +38,7 @@ object ZolaPlugin extends AutoPlugin { zolaBuild := { val logger = streams.value.log logger.info("Building web site using zola ...") - (Compile / resources).value - zolaPrepare.value + val _ = ((Compile / resources).value, zolaPrepare.value) buildSite(zolaCommand.value, zolaRootDir.value, zolaOutputDir.value, None, logger) logger.info("Website built") }, @@ -47,8 +46,7 @@ object ZolaPlugin extends AutoPlugin { val logger = streams.value.log val baseurl = zolaTestBaseUrl.value logger.info("Building web site (test) using zola ...") - (Compile / resources).value - zolaPrepare.value + val _ = ((Compile / resources).value, zolaPrepare.value) buildSite( zolaCommand.value, zolaRootDir.value, From f56d95e549f1ea41781c6a3fad893ad011317f74 Mon Sep 17 00:00:00 2001 From: Tibor Casteleijn Date: Mon, 23 Feb 2026 13:26:06 +0100 Subject: [PATCH 06/12] Scala format --- .../docspell/backend/joex/AddonConfig.scala | 11 +-- .../backend/joex/AddonConfigTest.scala | 78 ++++++++++++------- 2 files changed, 57 insertions(+), 32 deletions(-) diff --git a/modules/backend/src/main/scala/docspell/backend/joex/AddonConfig.scala b/modules/backend/src/main/scala/docspell/backend/joex/AddonConfig.scala index 53c10c34f5..f53c83c6c1 100644 --- a/modules/backend/src/main/scala/docspell/backend/joex/AddonConfig.scala +++ b/modules/backend/src/main/scala/docspell/backend/joex/AddonConfig.scala @@ -8,8 +8,8 @@ package docspell.backend.joex import docspell.common.exec.Env -/** Per-addon configuration for custom environment variables. - * Matched by addon name (AddonMeta.Meta.name). +/** Per-addon configuration for custom environment variables. Matched by addon name + * (AddonMeta.Meta.name). */ final case class AddonConfig( name: String, @@ -20,9 +20,10 @@ final case class AddonConfig( /** Resolve all env vars to an Env map. Only applies when enabled. */ def toEnv: Env = if (!enabled) Env.empty - else envs.foldLeft(Env.empty) { (acc, ev) => - ev.resolve.fold(acc)(kv => acc.add(kv._1, kv._2)) - } + else + envs.foldLeft(Env.empty) { (acc, ev) => + ev.resolve.fold(acc)(kv => acc.add(kv._1, kv._2)) + } } /** A single environment variable to inject, with either direct value or valueFrom. */ diff --git a/modules/backend/src/test/scala/docspell/backend/joex/AddonConfigTest.scala b/modules/backend/src/test/scala/docspell/backend/joex/AddonConfigTest.scala index cd01216028..5ea63645ab 100644 --- a/modules/backend/src/test/scala/docspell/backend/joex/AddonConfigTest.scala +++ b/modules/backend/src/test/scala/docspell/backend/joex/AddonConfigTest.scala @@ -18,12 +18,12 @@ class AddonConfigTest extends FunSuite { test("parse AddonConfig from HOCON with value only") { val config = ConfigSource.string(""" - |name = "my-addon" - |enabled = true - |envs = [ - | { name = "FOO", value = "bar" } - |] - |""".stripMargin) + |name = "my-addon" + |enabled = true + |envs = [ + | { name = "FOO", value = "bar" } + |] + |""".stripMargin) val result = config.at("").load[AddonConfig] assert(result.isRight, clue = result.left.map(_.toString)) val cfg = result.toOption.get @@ -36,27 +36,32 @@ class AddonConfigTest extends FunSuite { } test("parse AddonConfig from HOCON with valueFrom only") { - val config = ConfigSource.string(""" - |name = "my-addon" - |enabled = true - |envs = [ - | { - | name = "SECRET" - | value-from = { env = "DS_SECRET", optional = true } - | } - |] - |""".stripMargin) + val config = + ConfigSource.string(""" + |name = "my-addon" + |enabled = true + |envs = [ + | { + | name = "SECRET" + | value-from = { env = "DS_SECRET", optional = true } + | } + |] + |""".stripMargin) val result = config.at("").load[AddonConfig] assert(result.isRight, clue = result.left.map(_.toString)) val cfg = result.toOption.get assertEquals(cfg.envs.size, 1) assertEquals(cfg.envs.head.name, "SECRET") assertEquals(cfg.envs.head.value, None) - assertEquals(cfg.envs.head.valueFrom, Some(AddonEnvVarFrom(env = Some("DS_SECRET"), optional = true))) + assertEquals( + cfg.envs.head.valueFrom, + Some(AddonEnvVarFrom(env = Some("DS_SECRET"), optional = true)) + ) } test("parse AddonConfig with both value and valueFrom") { - val config = ConfigSource.string(""" + val config = ConfigSource.string( + """ |name = "my-addon" |envs = [ | { @@ -65,16 +70,21 @@ class AddonConfigTest extends FunSuite { | value-from = { env = "DS_MIXED", optional = false } | } |] - |""".stripMargin) + |""".stripMargin + ) val result = config.at("").load[AddonConfig] assert(result.isRight, clue = result.left.map(_.toString)) val cfg = result.toOption.get assertEquals(cfg.envs.head.value, Some("direct")) - assertEquals(cfg.envs.head.valueFrom, Some(AddonEnvVarFrom(env = Some("DS_MIXED"), optional = false))) + assertEquals( + cfg.envs.head.valueFrom, + Some(AddonEnvVarFrom(env = Some("DS_MIXED"), optional = false)) + ) } test("parse AddonEnvConfig with empty addonConfigs") { - val config = ConfigSource.string(""" + val config = ConfigSource.string( + """ |working-dir = "/tmp/work" |cache-dir = "/tmp/cache" |executor-config { @@ -85,7 +95,8 @@ class AddonConfigTest extends FunSuite { | nix-runner = { nix-binary = "nix", build-timeout = "5 minutes" } | docker-runner = { docker-binary = "docker", build-timeout = "5 minutes" } |} - |""".stripMargin) + |""".stripMargin + ) val result = config.at("").load[AddonEnvConfig] assert(result.isRight, clue = result.left.map(_.toString)) val cfg = result.toOption.get @@ -93,7 +104,8 @@ class AddonConfigTest extends FunSuite { } test("parse AddonEnvConfig with non-empty addonConfigs") { - val config = ConfigSource.string(""" + val config = ConfigSource.string( + """ |working-dir = "/tmp/work" |cache-dir = "/tmp/cache" |executor-config { @@ -113,7 +125,8 @@ class AddonConfigTest extends FunSuite { | ] | } |] - |""".stripMargin) + |""".stripMargin + ) val result = config.at("").load[AddonEnvConfig] assert(result.isRight, clue = result.left.map(_.toString)) val cfg = result.toOption.get @@ -131,7 +144,9 @@ class AddonConfigTest extends FunSuite { test("AddonEnvVar.resolve with valueFrom, optional=true, env unset") { val ev = AddonEnvVar( name = "SECRET", - valueFrom = Some(AddonEnvVarFrom(env = Some("DOCSPELL_ADDON_TEST_UNLIKELY_12345"), optional = true)) + valueFrom = Some( + AddonEnvVarFrom(env = Some("DOCSPELL_ADDON_TEST_UNLIKELY_12345"), optional = true) + ) ) assertEquals(ev.resolve, None) } @@ -139,13 +154,22 @@ class AddonConfigTest extends FunSuite { test("AddonEnvVar.resolve with valueFrom, optional=false, env unset") { val ev = AddonEnvVar( name = "REQUIRED", - valueFrom = Some(AddonEnvVarFrom(env = Some("DOCSPELL_ADDON_TEST_UNLIKELY_67890"), optional = false)) + valueFrom = Some( + AddonEnvVarFrom( + env = Some("DOCSPELL_ADDON_TEST_UNLIKELY_67890"), + optional = false + ) + ) ) assertEquals(ev.resolve, Some("REQUIRED" -> "")) } test("AddonConfig.toEnv when disabled") { - val cfg = AddonConfig(name = "x", enabled = false, envs = List(AddonEnvVar("A", value = Some("a")))) + val cfg = AddonConfig( + name = "x", + enabled = false, + envs = List(AddonEnvVar("A", value = Some("a"))) + ) assertEquals(cfg.toEnv, Env.empty) } From 19db7d6db41bd22c5e3e04e03638dc40b282ecf1 Mon Sep 17 00:00:00 2001 From: Tibor Casteleijn Date: Mon, 23 Feb 2026 13:31:09 +0100 Subject: [PATCH 07/12] Add env var override test for addons.configs Verify DOCSPELL_JOEX_ADDONS_CONFIGS_* override works in EnvConfigTest Co-authored-by: Cursor --- .../scala/docspell/config/EnvConfigTest.scala | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/modules/config/src/test/scala/docspell/config/EnvConfigTest.scala b/modules/config/src/test/scala/docspell/config/EnvConfigTest.scala index 044e0e5736..ff423aac52 100644 --- a/modules/config/src/test/scala/docspell/config/EnvConfigTest.scala +++ b/modules/config/src/test/scala/docspell/config/EnvConfigTest.scala @@ -42,4 +42,19 @@ class EnvConfigTest extends FunSuite { val cfg = EnvConfig.loadFrom(Map("A_B_C" -> "12").view) assert(!cfg.hasPath("a.b.c")) } + + test("override addons.configs via env vars") { + val cfg = EnvConfig.loadFrom( + Map( + "DOCSPELL_JOEX_ADDONS_CONFIGS_0_NAME" -> "postgres-addon", + "DOCSPELL_JOEX_ADDONS_CONFIGS_0_ENABLED" -> "true", + "DOCSPELL_JOEX_ADDONS_CONFIGS_0_ENVS_0_NAME" -> "PG_HOST", + "DOCSPELL_JOEX_ADDONS_CONFIGS_0_ENVS_0_VALUE" -> "localhost" + ).view + ) + assertEquals(cfg.getString("docspell.joex.addons.configs.0.name"), "postgres-addon") + assertEquals(cfg.getBoolean("docspell.joex.addons.configs.0.enabled"), true) + assertEquals(cfg.getString("docspell.joex.addons.configs.0.envs.0.name"), "PG_HOST") + assertEquals(cfg.getString("docspell.joex.addons.configs.0.envs.0.value"), "localhost") + } } From 6e7c2a2dffe9681651f43be936f45a3da4dadf8d Mon Sep 17 00:00:00 2001 From: Tibor Casteleijn Date: Mon, 23 Feb 2026 13:31:10 +0100 Subject: [PATCH 08/12] Document addons.configs in reference.conf and control.md - Expand reference.conf comment with example and value/value-from - Add Environment variables section to addons control docs Co-authored-by: Cursor --- .../joex/src/main/resources/reference.conf | 19 +++++++++++- website/site/content/docs/addons/control.md | 30 +++++++++++++++++++ 2 files changed, 48 insertions(+), 1 deletion(-) diff --git a/modules/joex/src/main/resources/reference.conf b/modules/joex/src/main/resources/reference.conf index 0770d9544f..6dc9bf3958 100644 --- a/modules/joex/src/main/resources/reference.conf +++ b/modules/joex/src/main/resources/reference.conf @@ -863,7 +863,24 @@ Docpell Update Check } addons { - # Per-addon environment variable configuration. Keyed by addon name. + # Per-addon environment variable configuration. Each entry is matched by + # addon name (from the addon descriptor). Environment variables are + # injected when the addon runs. Example: + # + # configs = [ + # { + # name = "postgres-addon" + # enabled = true + # envs = [ + # { name = "PG_HOST", value = "localhost" } + # { name = "PG_PASS", value-from = { env = "DS_PG_PASS", optional = true } } + # ] + # } + # ] + # + # For each env: use "value" for a literal, or "value-from" to read from + # the process environment. With value-from.env, optional=true skips the + # variable when unset; optional=false injects empty string when unset. configs = [] # A directory to extract addons when running them. Everything in diff --git a/website/site/content/docs/addons/control.md b/website/site/content/docs/addons/control.md index 2fd6dce301..8e2044de47 100644 --- a/website/site/content/docs/addons/control.md +++ b/website/site/content/docs/addons/control.md @@ -81,6 +81,36 @@ runner (possibly in combination with `systemd-nspawn`) can be used. # Runtime +## Environment variables + +You can inject custom environment variables into addons via +`addons.configs` in the joex config. Each entry is matched by addon +name (from the addon descriptor). This is useful for passing secrets +(e.g. API keys) or connection details (e.g. database host) without +hardcoding them in run configurations. + +Example: + +```hocon +addons { + configs = [ + { + name = "postgres-addon" + enabled = true + envs = [ + { name = "PG_HOST", value = "localhost" } + { name = "PG_PASS", value-from = { env = "DS_PG_PASS", optional = true } } + ] + } + ] +} +``` + +For each variable use `value` for a literal string, or `value-from` to +read from the process environment. With `value-from.env` and +`optional = true`, the variable is skipped when unset; with +`optional = false`, an empty string is injected when unset. + ## Cache directory Addons can use a "cache directory" to store data between runs. This From 994c034107929d8a60fc5a3072573688f568533a Mon Sep 17 00:00:00 2001 From: Tibor Casteleijn Date: Mon, 23 Feb 2026 15:47:08 +0100 Subject: [PATCH 09/12] improve the docs to clarify the use and describe how to use the optional flag --- modules/joex/src/main/resources/reference.conf | 5 +++-- website/site/content/docs/addons/control.md | 10 +++++++--- website/site/content/docs/addons/using.md | 4 ++++ 3 files changed, 14 insertions(+), 5 deletions(-) diff --git a/modules/joex/src/main/resources/reference.conf b/modules/joex/src/main/resources/reference.conf index 6dc9bf3958..d21e137113 100644 --- a/modules/joex/src/main/resources/reference.conf +++ b/modules/joex/src/main/resources/reference.conf @@ -879,8 +879,9 @@ Docpell Update Check # ] # # For each env: use "value" for a literal, or "value-from" to read from - # the process environment. With value-from.env, optional=true skips the - # variable when unset; optional=false injects empty string when unset. + # the process environment. In value-from: "env" is the source variable + # name; "optional" (default: true) controls behavior when the source is + # unset: true = skip the variable; false = inject empty string. configs = [] # A directory to extract addons when running them. Everything in diff --git a/website/site/content/docs/addons/control.md b/website/site/content/docs/addons/control.md index 8e2044de47..8a5108eb48 100644 --- a/website/site/content/docs/addons/control.md +++ b/website/site/content/docs/addons/control.md @@ -107,9 +107,13 @@ addons { ``` For each variable use `value` for a literal string, or `value-from` to -read from the process environment. With `value-from.env` and -`optional = true`, the variable is skipped when unset; with -`optional = false`, an empty string is injected when unset. +read from the process environment. In `value-from`, `env` is the source +variable name. The `optional` flag (default: `true`) controls behavior when +the source is unset: `true` skips the variable; `false` injects an empty +string. Use `optional = false` when the addon expects the variable to +always exist. + +To try this out, use the [docspell-addon-example](https://github.com/tiborrr/docspell-addon-example). It logs all `DOCSPELL_TEST_*` env vars to stderr. Add a config for `docspell-addon-example` with env vars like `DOCSPELL_TEST_FOO` and `DOCSPELL_TEST_BAZ`, then run the addon and check the joex logs. ## Cache directory diff --git a/website/site/content/docs/addons/using.md b/website/site/content/docs/addons/using.md index 7c19f9e4db..b4d8686c4b 100644 --- a/website/site/content/docs/addons/using.md +++ b/website/site/content/docs/addons/using.md @@ -24,6 +24,10 @@ addon: - +The [docspell-addon-example](https://github.com/tiborrr/docspell-addon-example) is a minimal reference addon that logs all context Docspell provides (user input, env vars, item data). Useful for testing and as a template: + +- + This url points to a fixed version. It is also possible to use urls that are "moving targets": From b900132c49c014e5af3eedf91432421c7837f987 Mon Sep 17 00:00:00 2001 From: Tibor Casteleijn Date: Mon, 23 Feb 2026 15:56:22 +0100 Subject: [PATCH 10/12] use more generic conf format to fix warning --- website/site/content/docs/addons/control.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/website/site/content/docs/addons/control.md b/website/site/content/docs/addons/control.md index 8a5108eb48..373ba59258 100644 --- a/website/site/content/docs/addons/control.md +++ b/website/site/content/docs/addons/control.md @@ -91,7 +91,7 @@ hardcoding them in run configurations. Example: -```hocon +```conf addons { configs = [ { From d22366c27d31b993547907704663e860efb97ea8 Mon Sep 17 00:00:00 2001 From: Tibor Casteleijn Date: Mon, 23 Feb 2026 15:59:23 +0100 Subject: [PATCH 11/12] scala fmt file --- .../src/test/scala/docspell/config/EnvConfigTest.scala | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/modules/config/src/test/scala/docspell/config/EnvConfigTest.scala b/modules/config/src/test/scala/docspell/config/EnvConfigTest.scala index ff423aac52..1763050137 100644 --- a/modules/config/src/test/scala/docspell/config/EnvConfigTest.scala +++ b/modules/config/src/test/scala/docspell/config/EnvConfigTest.scala @@ -55,6 +55,9 @@ class EnvConfigTest extends FunSuite { assertEquals(cfg.getString("docspell.joex.addons.configs.0.name"), "postgres-addon") assertEquals(cfg.getBoolean("docspell.joex.addons.configs.0.enabled"), true) assertEquals(cfg.getString("docspell.joex.addons.configs.0.envs.0.name"), "PG_HOST") - assertEquals(cfg.getString("docspell.joex.addons.configs.0.envs.0.value"), "localhost") + assertEquals( + cfg.getString("docspell.joex.addons.configs.0.envs.0.value"), + "localhost" + ) } } From 7d27ee810eedf9197af9ddc92a86c21d8045662e Mon Sep 17 00:00:00 2001 From: Tibor Casteleijn Date: Mon, 23 Feb 2026 16:09:58 +0100 Subject: [PATCH 12/12] use correct import order --- .../src/test/scala/docspell/addons/AddonExecutorTest.scala | 2 +- .../src/test/scala/docspell/backend/joex/AddonConfigTest.scala | 3 +-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/modules/addonlib/src/test/scala/docspell/addons/AddonExecutorTest.scala b/modules/addonlib/src/test/scala/docspell/addons/AddonExecutorTest.scala index fb4d1f02a5..a5cc0adf0e 100644 --- a/modules/addonlib/src/test/scala/docspell/addons/AddonExecutorTest.scala +++ b/modules/addonlib/src/test/scala/docspell/addons/AddonExecutorTest.scala @@ -10,9 +10,9 @@ import cats.effect._ import cats.syntax.all._ import docspell.addons.out.AddonOutput -import docspell.common.exec.Env import docspell.common.UrlReader import docspell.common.bc.{BackendCommand, ItemAction} +import docspell.common.exec.Env import docspell.logging.{Level, TestLoggingConfig} import munit._ diff --git a/modules/backend/src/test/scala/docspell/backend/joex/AddonConfigTest.scala b/modules/backend/src/test/scala/docspell/backend/joex/AddonConfigTest.scala index 5ea63645ab..87a42fcc9b 100644 --- a/modules/backend/src/test/scala/docspell/backend/joex/AddonConfigTest.scala +++ b/modules/backend/src/test/scala/docspell/backend/joex/AddonConfigTest.scala @@ -9,11 +9,10 @@ package docspell.backend.joex import docspell.common.exec.Env import docspell.config.Implicits._ +import munit.FunSuite import pureconfig.ConfigSource import pureconfig.generic.auto._ -import munit.FunSuite - class AddonConfigTest extends FunSuite { test("parse AddonConfig from HOCON with value only") {