diff --git a/src/main/kotlin/de/hoennig/werkator/config/ConfigFiles.kt b/src/main/kotlin/de/hoennig/werkator/config/ConfigFiles.kt index c9a8bb8..e7e87cb 100644 --- a/src/main/kotlin/de/hoennig/werkator/config/ConfigFiles.kt +++ b/src/main/kotlin/de/hoennig/werkator/config/ConfigFiles.kt @@ -19,6 +19,14 @@ object ConfigFiles { /** The machine-specific configuration inside `.git`; secrets live here. */ const val REPO_INSTALL = ".git/werkator/$COMMITTED" + /** + * The instance configuration (ADR 0009), relative to the home directory of the user + * running Werkator. Deliberately the same file name: only the location carries the + * meaning — home is the instance, the repository root is the project, `.git` is the + * machine. + */ + const val INSTANCE = COMMITTED + /** * The applied instance fragment (`init --apply`, step 23): a config-schema YAML * fragment installed verbatim as its own layer — above the committed project diff --git a/src/main/kotlin/de/hoennig/werkator/config/ConfigLoader.kt b/src/main/kotlin/de/hoennig/werkator/config/ConfigLoader.kt index 49b5a04..2919533 100644 --- a/src/main/kotlin/de/hoennig/werkator/config/ConfigLoader.kt +++ b/src/main/kotlin/de/hoennig/werkator/config/ConfigLoader.kt @@ -50,6 +50,35 @@ class ConfigLoader( /** Section-level warnings already reported, keyed by a fixed slug; the config is loaded on every poll cycle. */ private val warnedSections = ConcurrentHashMap.newKeySet() + /** + * Where the instance configuration lives (ADR 0009): the home directory of the user + * running Werkator, `WERKATOR_HOME` overriding it for tests and unusual layouts. + */ + @Volatile + var homeDir: Path = Paths.get(System.getenv("WERKATOR_HOME")?.takeIf { it.isNotBlank() } ?: System.getProperty("user.home")) + + /** The instance configuration file, whether or not it exists. */ + fun instanceFile(): Path = homeDir.resolve(ConfigFiles.INSTANCE) + + /** + * The instance configuration, or null without a home file — the single-repository + * case, in which the current directory is served exactly as before ADR 0009. + */ + fun loadInstance(): InstanceConfig? { + val raw = loadInstanceRaw() + if (raw.isEmpty()) { + return null + } + return yaml.convertValue(raw, InstanceConfig::class.java) + } + + private fun loadInstanceRaw(): Map { + val file = instanceFile() + val raw = loadFile(file.toFile()) + checkVersion(raw, file.toString(), ROLLBACK_HINT) + return raw + } + fun load(workingDir: Path = Paths.get(".")): WerkatorConfig = toConfig(loadRaw(workingDir)) /** @@ -307,11 +336,79 @@ class ConfigLoader( checkTriggerBlocks(project, projectName, ROLLBACK_HINT) checkTriggerBlocks(applied, ConfigFiles.APPLIED, ROLLBACK_HINT) checkTriggerBlocks(repoInstall, repoInstallName, ROLLBACK_HINT) - // the applied instance fragment sits above the committed project config and - // below the hand-edited machine config, which always has the last word - return deepMerge(deepMerge(project, applied), repoInstall) + val instance = loadInstanceRaw() + if (instance.isEmpty()) { + // the applied instance fragment sits above the committed project config and + // below the hand-edited machine config, which always has the last word + return deepMerge(deepMerge(project, applied), repoInstall) + } + // with a home config (ADR 0009): its `defaults` sit below every repository layer, + // and the instance-level keys come from it alone — a repository file still + // carrying them is told so, never merged silently + val repoLayers = + deepMerge( + deepMerge( + withoutInstanceKeys(project, workingDir.resolve(projectName)), + withoutInstanceKeys(applied, workingDir.resolve(ConfigFiles.APPLIED)), + ), + withoutInstanceKeys(repoInstall, workingDir.resolve(repoInstallName)), + ) + + @Suppress("UNCHECKED_CAST") + val defaults = instance["defaults"] as? Map ?: emptyMap() + return deepMerge(deepMerge(defaults, repoLayers), instanceKeysOf(instance)) } + /** + * Drops the instance-level keys from one repository layer, saying so once per file + * with both file names: the setting the operator wrote there is not in effect, and + * the message must name where it is read from instead. + */ + @Suppress("UNCHECKED_CAST") + private fun withoutInstanceKeys( + layer: Map, + file: Path, + ): Map { + val instanceKeys = instanceKeysOf(layer) + if (instanceKeys.isEmpty()) { + return layer + } + if (warnedSections.add("instance-keys:$file")) { + log.warn( + "ignoring {} in {}: these are instance settings and come from {} now", + describeKeys(instanceKeys), + file, + instanceFile(), + ) + } + val result = layer.toMutableMap() + for (key in INSTANCE_SECTIONS) { + result.remove(key) + } + val watcher = (layer["watcher"] as? Map)?.minus(INSTANCE_WATCHER_KEYS) + if (watcher == null || watcher.isEmpty()) result.remove("watcher") else result["watcher"] = watcher + return result + } + + /** The instance-level part of a raw configuration map: the sections and keys owned by the instance. */ + @Suppress("UNCHECKED_CAST") + private fun instanceKeysOf(raw: Map): Map { + val result = mutableMapOf() + for (key in INSTANCE_SECTIONS) { + raw[key]?.let { result[key] = it } + } + val watcher = (raw["watcher"] as? Map)?.filterKeys { it in INSTANCE_WATCHER_KEYS } + if (!watcher.isNullOrEmpty()) { + result["watcher"] = watcher + } + return result + } + + private fun describeKeys(instanceKeys: Map): String = + instanceKeys.keys.joinToString(", ") { key -> + if (key == "watcher") INSTANCE_WATCHER_KEYS.joinToString(", ") { "watcher.$it" } else key + } + /** * Validates and installs an instance fragment (`init --apply`, step 23): the file * must be non-empty, pass the version and trigger checks, and bind *strictly* @@ -467,6 +564,16 @@ class ConfigLoader( /** The keys that moved into [TRIGGER_KEYS]; still writing them flat is refused, not ignored. */ private val FLAT_TRIGGER_KEYS = setOf("onPush", "atTimes", "branches", "activeWithin") + /** + * Top-level sections owned by the instance once a home config exists (ADR 0009): + * the one server and the one global concurrency cap. Read from the home file, + * ignored with a warning in a repository's files. + */ + private val INSTANCE_SECTIONS = setOf("server", "executor") + + /** The `watcher` keys owned by the instance: one loop, one delay; the gates stay per repository. */ + private val INSTANCE_WATCHER_KEYS = setOf("pollInterval") + private const val LEGACY_BRANCHES_WARNING = "legacy-branches-ignored" private const val NO_TRIGGER_WARNING = "no-build-triggered" diff --git a/src/main/kotlin/de/hoennig/werkator/config/InstanceConfig.kt b/src/main/kotlin/de/hoennig/werkator/config/InstanceConfig.kt new file mode 100644 index 0000000..d2e8378 --- /dev/null +++ b/src/main/kotlin/de/hoennig/werkator/config/InstanceConfig.kt @@ -0,0 +1,45 @@ +package de.hoennig.werkator.config + +/** + * The instance configuration (ADR 0009): `~/.werkator.yml` in the home directory of the + * user running Werkator — one instance per OS user. It owns what is shared by every + * repository the instance serves: the repository registry, the `server` section, the + * global `executor.maxConcurrent`, and the watcher poll interval. Everything else in + * the file is either `defaults` — a fragment in the repository config schema merged + * *below* every repository's own layers — or ignored. + * + * Without this file, Werkator serves the current working directory exactly as before. + * With it, the registry wins over the current directory (`werkator server` serves the + * registered repositories wherever it is started), and the instance-level keys of a + * repository's own files are ignored with a warning naming both files, never merged. + */ +data class InstanceConfig( + /** What this file declares about the Werkator that reads it; see [VersionRequirement]. */ + val werkator: WerkatorMeta = WerkatorMeta(), + val server: ServerConfig = ServerConfig(), + val executor: ExecutorConfig = ExecutorConfig(), + val watcher: InstanceWatcherConfig = InstanceWatcherConfig(), + /** The registry: the repositories this instance serves; empty means the current directory. */ + val repositories: List = emptyList(), + /** + * Repository-level keys in the repository config schema (e.g. one `git.account`/`git.token` + * for every repository of the same forge), merged below each repository's own layers. + * Raw on purpose: it is a fragment, not a configuration, and binds through the same + * path as every other layer. + */ + val defaults: Map = emptyMap(), +) + +/** The watcher settings that are the instance's, not a repository's: one loop, one delay. */ +data class InstanceWatcherConfig( + /** Delay between poll cycles over all repositories, e.g. `10s` or `1m`. */ + val pollInterval: String = "10s", +) + +/** One registry entry: a repository directory and the name it is known by. */ +data class RepositoryEntry( + /** The repository's primary checkout, absolute or relative to the home directory; `~` expands. */ + val path: String = "", + /** Short unique name for display and routes; empty means the directory basename. */ + val name: String = "", +) diff --git a/src/test/kotlin/de/hoennig/werkator/config/ConfigLoaderTest.kt b/src/test/kotlin/de/hoennig/werkator/config/ConfigLoaderTest.kt index da6bc43..3d6f71a 100644 --- a/src/test/kotlin/de/hoennig/werkator/config/ConfigLoaderTest.kt +++ b/src/test/kotlin/de/hoennig/werkator/config/ConfigLoaderTest.kt @@ -110,6 +110,95 @@ class ConfigLoaderTest : FunSpec() { "./gradlew fromBranch" } + test("without a home config the instance is null and the repository layers are read as before") { + val home = Files.createTempDirectory("werkator-home") + val loader = ConfigLoader().apply { homeDir = home } + + loader.loadInstance() shouldBe null + loader.instanceFile() shouldBe home.resolve(".werkator.yml") + } + + test("the home config carries the registry, and its defaults sit below every repository layer") { + val home = Files.createTempDirectory("werkator-home") + home.resolve(".werkator.yml").toFile().writeText( + """ + repositories: + - path: ~/repos/werkator + - path: /srv/werkbaum + name: baum + defaults: + git: + account: shared-bot + token: shared-secret + gitea: + baseUrl: https://git.example.org + """.trimIndent(), + ) + val loader = ConfigLoader().apply { homeDir = home } + val dir = Files.createTempDirectory("werkator-test") + dir.resolve(".werkator.yml").toFile().writeText("gitea:\n owner: my-org\n") + Files.createDirectories(dir.resolve(".git/werkator")) + dir.resolve(".git/werkator/.werkator.yml").toFile().writeText("git:\n token: own-secret\n") + + val instance = loader.loadInstance().shouldNotBeNull() + instance.repositories.map { it.path to it.name } shouldBe listOf("~/repos/werkator" to "", "/srv/werkbaum" to "baum") + + val config = loader.load(dir) + // the repository's own layers win over the defaults, untouched keys fall through + config.git.account shouldBe "shared-bot" + config.git.token shouldBe "own-secret" + config.gitea.baseUrl shouldBe "https://git.example.org" + config.gitea.owner shouldBe "my-org" + } + + test("with a home config the instance keys come from it alone, and a repository's copies are ignored") { + val home = Files.createTempDirectory("werkator-home") + home.resolve(".werkator.yml").toFile().writeText( + """ + server: + port: 18088 + executor: + maxConcurrent: 3 + watcher: + pollInterval: 1m + """.trimIndent(), + ) + val loader = ConfigLoader().apply { homeDir = home } + val dir = Files.createTempDirectory("werkator-test") + dir.resolve(".werkator.yml").toFile().writeText( + """ + server: + port: 1000 + bindAddress: 0.0.0.0 + watcher: + pollInterval: 5s + pullRequestGate: false + """.trimIndent(), + ) + Files.createDirectories(dir.resolve(".git/werkator")) + dir.resolve(".git/werkator/.werkator.yml").toFile().writeText("executor:\n maxConcurrent: 9\n") + + val config = loader.load(dir) + + // the whole server section is the instance's, not merged key by key + config.server.port shouldBe 18088 + config.server.bindAddress shouldBe "127.0.0.1" + config.executor.maxConcurrent shouldBe 3 + config.watcher.pollInterval shouldBe "1m" + // the per-repository watcher gates stay the repository's + config.watcher.pullRequestGate.shouldBeFalse() + } + + test("the home config is version-checked like every other file") { + val home = Files.createTempDirectory("werkator-home") + home.resolve(".werkator.yml").toFile().writeText("werkator:\n version:\n since: \"9.9\"\n") + val loader = loaderRunning("1.0.0").apply { homeDir = home } + + val error = shouldThrow { loader.loadInstance() } + + error.message shouldContain home.resolve(".werkator.yml").toString() + } + test("the applied instance fragment layers above the project config and below the machine config") { val dir = Files.createTempDirectory("werkator-test") dir.resolve(".werkator.yml").toFile().writeText("server:\n port: 1000\n publicBaseUrl: \"https://project/\"\n")