Files
werkator/src/main/kotlin/de/hoennig/gittally/config/ConfigLoader.kt
T
mhoennig 729eea5e6c A build definition carries the whole build; branches is legacy
`builds` and the legacy `branches` are now either/or: `branches` is read
only while the merged configuration defines no build at all — a leftover
`builds.maxConcurrent` is not one — and ignored with a warning as soon as
one exists. Two half-answers to "what does this build run" would silently
pull against each other, and the committed configs still carrying both
must not change behaviour before they are migrated.

A definition therefore gained the settings it was missing:
`requirePullRequest` and `docker.enabled`/`network`. Those stay pinned —
`stripPinned` now removes them from a branch layer wherever they appear,
in a definition as well as in a legacy branch entry.

`builds.default` becomes the base every other definition inherits its
settings from, never its trigger: `onPush`, `atTimes`, `branches`, and
`activeWithin` say when and where *this* build runs. The inheritance is
applied after all layers are merged, which is what makes a build invented
on a branch inherit the host's sandbox policy instead of the data-class
default — otherwise a branch could get a native build past the pinning by
defining a job the host has never heard of.

Two bugs found on the way, both the same shape as the build command the
artifact page used to get wrong:

- `FileArtifactStore` read the artifact directories from the plain branch
  settings, so a job adding its own `artifactDirs` never had them stored.
  It goes through `GitTallyConfig.buildSettings` now, like everything else
  that asks what a build runs.
- `Watcher.definitionsFor` cached the per-branch definitions by head
  commit alone, so an edited machine or project config only took effect
  once the branch moved — on a quiet branch, never. The primary config is
  part of the cache key now.
2026-08-29 11:18:35 +02:00

372 lines
17 KiB
Kotlin

package de.hoennig.gittally.config
import com.fasterxml.jackson.databind.DeserializationFeature
import com.fasterxml.jackson.databind.ObjectMapper
import com.fasterxml.jackson.databind.SerializationFeature
import com.fasterxml.jackson.dataformat.yaml.YAMLFactory
import com.fasterxml.jackson.dataformat.yaml.YAMLGenerator
import com.fasterxml.jackson.module.kotlin.registerKotlinModule
import org.slf4j.LoggerFactory
import org.springframework.beans.factory.ObjectProvider
import org.springframework.boot.info.BuildProperties
import org.springframework.stereotype.Service
import java.io.File
import java.nio.file.Path
import java.nio.file.Paths
import java.util.concurrent.ConcurrentHashMap
@Service
class ConfigLoader(
/** The running version, for the `gitTally.version` check; absent outside a built jar (IDE, tests). */
private val buildProperties: ObjectProvider<BuildProperties>? = null,
) {
private val log = LoggerFactory.getLogger(ConfigLoader::class.java)
private val yaml =
ObjectMapper(YAMLFactory().disable(YAMLGenerator.Feature.WRITE_DOC_START_MARKER))
.registerKotlinModule()
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
.configure(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS, false)
/** Keys already reported by [dropNonDefinitionBuilds]; the config is loaded on every poll cycle. */
private val warnedBuildKeys = ConcurrentHashMap.newKeySet<String>()
/** Version warnings already reported; the config is loaded on every poll cycle, per branch. */
private val warnedVersions = ConcurrentHashMap.newKeySet<String>()
/** Section-level warnings already reported, keyed by a fixed slug; the config is loaded on every poll cycle. */
private val warnedSections = ConcurrentHashMap.newKeySet<String>()
fun load(workingDir: Path = Paths.get(".")): GitTallyConfig = toConfig(loadRaw(workingDir))
/**
* Config for building a branch in [worktreeDir]: the worktree's `.gittally.yml`
* (the committed config of the branch being built) is applied as the branch layer,
* see [loadWithBranchLayer]. With no worktree `.gittally.yml` this is identical
* to [load].
*/
fun loadForWorktree(
workingDir: Path,
worktreeDir: Path,
): GitTallyConfig = withBranchLayer(workingDir, loadFile(worktreeDir.resolve(".gittally.yml").toFile()))
/**
* The primary/`.git` config with the committed `.gittally.yml` of one branch
* ([branchConfigYaml], null or blank for a branch without one) merged on top:
* precedence branch > `.git` > project. A branch describes its own CI — build
* settings (`buildCommand`, `cleanCommand`, `artifactDirs`, `docker.image`/`env`, …)
* and its `builds` definitions — so a new configuration can be tried out on a
* branch without touching any other branch's builds.
*
* The [pinned][stripPinned] keys are the exception, and they are exactly the ones
* that are not a description of this branch's build: secrets (`git`), the host- and
* repository-side sections (`server`, `gitea`, `executor`, `watcher`), the docker
* sandbox policy (`docker.enabled`/`docker.network`), and the trust gate
* (`requirePullRequest`, which decides whether the branch is built at all).
* They are stripped from the branch layer before merging, so a branch can neither
* escape its container, nor bypass its own pull-request gate, nor raise the global
* concurrency, nor reach the credentials.
*/
fun loadWithBranchLayer(
workingDir: Path,
branchConfigYaml: String?,
): GitTallyConfig = withBranchLayer(workingDir, parseYaml(branchConfigYaml))
private fun withBranchLayer(
workingDir: Path,
branchLayer: Map<String, Any?>,
): GitTallyConfig {
// scoped to this branch: an incompatible branch config fails its own builds and
// must never stop the server or hold up the branches that are fine
checkVersion(branchLayer, "the committed .gittally.yml of this branch", BRANCH_HINT)
return toConfig(deepMerge(loadRaw(workingDir), stripPinned(branchLayer)))
}
private fun toConfig(raw: Map<String, Any?>): GitTallyConfig {
val config =
if (raw.isEmpty()) {
GitTallyConfig()
} else {
yaml.convertValue(resolveBuildSections(dropNonDefinitionBuilds(raw)), GitTallyConfig::class.java)
}
return defaultPublicBaseUrl(config)
}
/**
* Ignores `builds` entries that are not a build definition — a scalar where a
* definition belongs, most likely the `builds.maxConcurrent` key that moved to
* `executor.maxConcurrent`. Such a leftover is a warning, not a startup failure:
* the config lives in a repository whose `master` may not be changeable right now,
* and the rest of it is perfectly usable.
*/
@Suppress("UNCHECKED_CAST")
private fun dropNonDefinitionBuilds(raw: Map<String, Any?>): Map<String, Any?> {
val builds = raw["builds"] as? Map<String, Any?> ?: return raw
val definitions = builds.filterValues { it is Map<*, *> }
if (definitions.size == builds.size) {
return raw
}
for (key in builds.keys - definitions.keys) {
if (!warnedBuildKeys.add(key)) {
continue
}
if (key == "maxConcurrent") {
log.warn("ignoring builds.maxConcurrent; the concurrency limit is executor.maxConcurrent since v0.9.15")
} else {
log.warn("ignoring builds.{}: a build definition must be a mapping of keys", key)
}
}
return raw + ("builds" to definitions)
}
/**
* Removes the keys a branch must never override: the secret and host-side top-level
* sections, the trust gate, and the docker sandbox policy — the latter two wherever
* they may appear, in a `builds` definition as well as in a legacy `branches` entry.
* See [loadWithBranchLayer].
*/
@Suppress("UNCHECKED_CAST")
private fun stripPinned(branchLayer: Map<String, Any?>): Map<String, Any?> {
if (branchLayer.isEmpty()) {
return branchLayer
}
val result = branchLayer.toMutableMap()
PINNED_TOP_LEVEL_KEYS.forEach { result.remove(it) }
for (section in listOf("builds", "branches")) {
val entries = result[section] as? Map<String, Any?> ?: continue
result[section] = entries.mapValues { (_, value) -> stripPinnedSettings(value) }
}
return result
}
@Suppress("UNCHECKED_CAST")
private fun stripPinnedSettings(value: Any?): Any? {
val entry = value as? Map<String, Any?> ?: return value
val result = entry.toMutableMap()
PINNED_SETTING_KEYS.forEach { result.remove(it) }
val docker = entry["docker"] as? Map<String, Any?>
if (docker != null) {
val strippedDocker = docker.toMutableMap().apply { PINNED_DOCKER_KEYS.forEach { remove(it) } }
if (strippedDocker.isEmpty()) result.remove("docker") else result["docker"] = strippedDocker
}
return result
}
/**
* Decides which of the two sections describes the builds: `builds` or the legacy
* `branches`, never both. As soon as the merged configuration carries one real build
* definition — `builds.maxConcurrent` alone is not one, it is already dropped by
* [dropNonDefinitionBuilds] — a `branches` section is ignored altogether, because a
* definition now carries the complete description of its build and two half-answers
* would silently pull against each other.
*
* Deliberately decided on the *merged* map, after all layers are in: a branch that
* brings its own `builds` therefore also switches the host's `branches` off for its
* own builds, and — the reason this order matters — a build the branch defines and
* the host has never heard of still inherits the host's `builds.default`, sandbox
* policy included. Were the sections resolved per layer, that build would start with
* an empty docker policy and run natively on the host, which is exactly the escape
* the pinned keys exist to prevent.
*/
private fun resolveBuildSections(raw: Map<String, Any?>): Map<String, Any?> {
@Suppress("UNCHECKED_CAST")
val definitions = raw["builds"] as? Map<String, Any?> ?: emptyMap()
if (definitions.isEmpty()) {
return mergeBranchDefaults(raw)
}
if (raw.containsKey("branches") && warnedSections.add(LEGACY_BRANCHES_WARNING)) {
log.warn(
"ignoring the branches section: this configuration defines builds, and a build definition " +
"carries its own settings; move what is still needed into builds — branches is going away",
)
}
warnWhenNothingIsTriggered(definitions)
return mergeBuildDefaults(raw - "branches")
}
/**
* An explicit `builds.default` replaces the implicit on-push build, so a set of
* definitions can end up with no trigger at all — an instance that will never build
* anything. That is a plausible intention for a moment and a mistake for a week, so
* it is said out loud once instead of being enforced.
*/
private fun warnWhenNothingIsTriggered(definitions: Map<String, Any?>) {
if (BuildDefinition.DEFAULT !in definitions || definitions.values.any { isTriggered(it) }) {
return
}
if (warnedSections.add(NO_TRIGGER_WARNING)) {
log.warn("no build defines onPush or atTimes; the watcher will never start a build on its own")
}
}
private fun isTriggered(definition: Any?): Boolean {
val entry = definition as? Map<*, *> ?: return false
return entry["onPush"] == true || (entry["atTimes"] as? List<*>)?.isNotEmpty() == true
}
/**
* Applies `builds.default` as the base of every other build definition — the settings
* only. A trigger is never inherited: `onPush` and `atTimes` say when *this* build
* runs, and the selectors say for which branches, so inheriting them would make every
* job fire whenever the default one does.
*/
@Suppress("UNCHECKED_CAST")
private fun mergeBuildDefaults(raw: Map<String, Any?>): Map<String, Any?> {
val builds = raw["builds"] as? Map<String, Any?> ?: return raw
val base = (builds[BuildDefinition.DEFAULT] as? Map<String, Any?>)?.minus(SELECTOR_KEYS) ?: return raw
if (base.isEmpty()) {
return raw
}
val merged =
builds.mapValues { (name, value) ->
if (name == BuildDefinition.DEFAULT) {
value
} else {
deepMerge(base, value as? Map<String, Any?> ?: emptyMap())
}
}
return raw + ("builds" to merged)
}
/** Legacy default: an empty `server.publicBaseUrl` becomes `https://<nginx.serverName>/`. */
private fun defaultPublicBaseUrl(config: GitTallyConfig): GitTallyConfig {
if (config.server.publicBaseUrl.isNotBlank() ||
config.server.nginx.serverName
.isBlank()
) {
return config
}
return config.copy(server = config.server.copy(publicBaseUrl = "https://${config.server.nginx.serverName}/"))
}
fun loadRaw(workingDir: Path = Paths.get(".")): Map<String, Any?> {
val repoInstall = loadFile(workingDir.resolve(".git/gittally/.gittally.yml").toFile())
val project = loadFile(workingDir.resolve(".gittally.yml").toFile())
// per file, so the message names the file to fix — the merged map has no provenance
checkVersion(project, ".gittally.yml", ROLLBACK_HINT)
checkVersion(repoInstall, ".git/gittally/.gittally.yml", ROLLBACK_HINT)
return deepMerge(project, repoInstall)
}
/**
* Enforces the `gitTally.version` declaration of one configuration file.
* An incompatible file throws — reading it would mean honoring keys that mean
* something else now, which is worse than not building. A file that merely exceeds
* its own `below` marker is a warning, logged once: an unmaintained marker must
* never stop a CI.
*/
private fun checkVersion(
raw: Map<String, Any?>,
source: String,
hint: String,
) {
if (raw.isEmpty()) {
return
}
val running = buildProperties?.getIfAvailable()?.version
when (val verdict = ConfigVersions.verdict(requirementOf(raw), running)) {
is VersionVerdict.Compatible -> Unit
is VersionVerdict.Warn ->
if (warnedVersions.add("$source: ${verdict.message}")) {
log.warn("{} {}", source, verdict.message)
}
is VersionVerdict.Incompatible -> throw ConfigVersionException("$source ${verdict.message}. $hint")
}
}
@Suppress("UNCHECKED_CAST")
private fun requirementOf(raw: Map<String, Any?>): VersionRequirement {
val version = (raw["gitTally"] as? Map<String, Any?>)?.get("version") as? Map<String, Any?> ?: return VersionRequirement()
return VersionRequirement(
since = version["since"]?.toString()?.trim().orEmpty(),
below = version["below"]?.toString()?.trim().orEmpty(),
)
}
fun toYaml(value: Any): String = yaml.writeValueAsString(value)
private fun loadFile(file: File): Map<String, Any?> {
if (!file.exists()) return emptyMap()
@Suppress("UNCHECKED_CAST")
return yaml.readValue(file, Map::class.java) as Map<String, Any?>
}
/** Parses a `.gittally.yml` read from git (not from disk); blank or null yields no layer. */
private fun parseYaml(text: String?): Map<String, Any?> {
if (text.isNullOrBlank()) return emptyMap()
@Suppress("UNCHECKED_CAST")
return yaml.readValue(text, Map::class.java) as? Map<String, Any?> ?: emptyMap()
}
@Suppress("UNCHECKED_CAST")
private fun mergeBranchDefaults(raw: Map<String, Any?>): Map<String, Any?> {
val branches = raw["branches"] as? Map<String, Any?> ?: return raw
val default = branches["default"] as? Map<String, Any?> ?: return raw
if (default.isEmpty()) return raw
val merged =
branches.mapValues { (name, value) ->
if (name == "default") {
value
} else {
deepMerge(default, value as? Map<String, Any?> ?: emptyMap())
}
}
return raw + ("branches" to merged)
}
@Suppress("UNCHECKED_CAST")
private fun deepMerge(
base: Map<String, Any?>,
overlay: Map<String, Any?>,
): Map<String, Any?> {
val result = base.toMutableMap()
for ((key, value) in overlay) {
val existing = result[key]
result[key] =
if (existing is Map<*, *> && value is Map<*, *>) {
deepMerge(existing as Map<String, Any?>, value as Map<String, Any?>)
} else {
value
}
}
return result
}
companion object {
/**
* Top-level sections a branch must never override, because none of them describes
* this branch's build: secrets (`git`), and the host- and repository-side settings
* (`server`, `gitea`, `executor`, `watcher`) — a branch must not be able to reach
* the credentials, report statuses to another repository, raise the global
* concurrency, or turn off the pull-request gate for the whole watcher.
* The `builds` section is deliberately *not* pinned: it describes what the branch
* builds, which is the branch's own business — only the individual settings in
* [PINNED_SETTING_KEYS] and [PINNED_DOCKER_KEYS] are taken out of it.
*/
private val PINNED_TOP_LEVEL_KEYS = setOf("git", "gitea", "server", "executor", "watcher")
/**
* Settings keys a branch must never override, in a build definition as well as in
* a legacy branch entry: the trust gate that decides whether the watcher builds
* this branch at all.
*/
private val PINNED_SETTING_KEYS = setOf("requirePullRequest")
/** `docker` keys a branch must never override: the sandbox policy. */
private val PINNED_DOCKER_KEYS = setOf("enabled", "network")
/** Keys of a build definition that say *when* it runs; never inherited from `builds.default`. */
private val SELECTOR_KEYS = setOf("onPush", "atTimes", "branches", "activeWithin")
private const val LEGACY_BRANCHES_WARNING = "legacy-branches-ignored"
private const val NO_TRIGGER_WARNING = "no-build-triggered"
private const val ROLLBACK_HINT =
"Migrate the file, or roll back to the GitTally version it was written for."
private const val BRANCH_HINT =
"Migrate the file on this branch; the other branches keep building."
}
}