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:
co-authored by
Claude Fable 5.1
parent
096cce3659
commit
ab2a4133e8
@@ -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
|
||||
|
||||
@@ -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<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))
|
||||
|
||||
/**
|
||||
@@ -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<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
|
||||
* 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"
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
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") {
|
||||
val dir = Files.createTempDirectory("werkator-test")
|
||||
dir.resolve(".werkator.yml").toFile().writeText("server:\n port: 1000\n publicBaseUrl: \"https://project/\"\n")
|
||||
|
||||
Reference in New Issue
Block a user