Step 22 C: the instance configuration in ~/.werkator.yml

ConfigLoader reads the home file (WERKATOR_HOME overrides the location): its
defaults block merges below every repository's layers, and once it exists the
server section, executor and watcher.pollInterval come from it alone — a
repository file still carrying them is told so once, naming both files.
InstanceConfig binds the registry entries; nothing reads them yet.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
mhoennig
2026-09-02 08:55:59 +02:00
co-authored by Claude Fable 5.1
parent 096cce3659
commit ab2a4133e8
4 changed files with 252 additions and 3 deletions
@@ -19,6 +19,14 @@ object ConfigFiles {
/** The machine-specific configuration inside `.git`; secrets live here. */ /** The machine-specific configuration inside `.git`; secrets live here. */
const val REPO_INSTALL = ".git/werkator/$COMMITTED" 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 * The applied instance fragment (`init --apply`, step 23): a config-schema YAML
* fragment installed verbatim as its own layer — above the committed project * fragment installed verbatim as its own layer — above the committed project
@@ -50,6 +50,35 @@ class ConfigLoader(
/** Section-level warnings already reported, keyed by a fixed slug; the config is loaded on every poll cycle. */ /** Section-level warnings already reported, keyed by a fixed slug; the config is loaded on every poll cycle. */
private val warnedSections = ConcurrentHashMap.newKeySet<String>() private val warnedSections = ConcurrentHashMap.newKeySet<String>()
/**
* 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<String, Any?> {
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)) fun load(workingDir: Path = Paths.get(".")): WerkatorConfig = toConfig(loadRaw(workingDir))
/** /**
@@ -307,11 +336,79 @@ class ConfigLoader(
checkTriggerBlocks(project, projectName, ROLLBACK_HINT) checkTriggerBlocks(project, projectName, ROLLBACK_HINT)
checkTriggerBlocks(applied, ConfigFiles.APPLIED, ROLLBACK_HINT) checkTriggerBlocks(applied, ConfigFiles.APPLIED, ROLLBACK_HINT)
checkTriggerBlocks(repoInstall, repoInstallName, ROLLBACK_HINT) checkTriggerBlocks(repoInstall, repoInstallName, ROLLBACK_HINT)
// the applied instance fragment sits above the committed project config and val instance = loadInstanceRaw()
// below the hand-edited machine config, which always has the last word if (instance.isEmpty()) {
return deepMerge(deepMerge(project, applied), repoInstall) // 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<String, Any?> ?: 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<String, Any?>,
file: Path,
): Map<String, Any?> {
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<String, Any?>)?.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<String, Any?>): Map<String, Any?> {
val result = mutableMapOf<String, Any?>()
for (key in INSTANCE_SECTIONS) {
raw[key]?.let { result[key] = it }
}
val watcher = (raw["watcher"] as? Map<String, Any?>)?.filterKeys { it in INSTANCE_WATCHER_KEYS }
if (!watcher.isNullOrEmpty()) {
result["watcher"] = watcher
}
return result
}
private fun describeKeys(instanceKeys: Map<String, Any?>): 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 * 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* * 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. */ /** 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") 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 LEGACY_BRANCHES_WARNING = "legacy-branches-ignored"
private const val NO_TRIGGER_WARNING = "no-build-triggered" private const val NO_TRIGGER_WARNING = "no-build-triggered"
@@ -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<RepositoryEntry> = 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<String, Any?> = 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 = "",
)
@@ -110,6 +110,95 @@ class ConfigLoaderTest : FunSpec() {
"./gradlew fromBranch" "./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<ConfigVersionException> { 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") { test("the applied instance fragment layers above the project config and below the machine config") {
val dir = Files.createTempDirectory("werkator-test") val dir = Files.createTempDirectory("werkator-test")
dir.resolve(".werkator.yml").toFile().writeText("server:\n port: 1000\n publicBaseUrl: \"https://project/\"\n") dir.resolve(".werkator.yml").toFile().writeText("server:\n port: 1000\n publicBaseUrl: \"https://project/\"\n")