Step 23 session A: init --apply, the applied instance layer, control-token

'werkator init --apply FILE' installs a config-schema YAML fragment as
its own layer: validated strictly (an unknown key is refused loudly,
never ignored — a typo must not install a silent no-op), then copied
verbatim to .git/werkator/.werkator.applied.yml, above the project
config and below the hand-edited machine config, which always wins.
Deviation from the plan sketch, recorded there: a separate layer
instead of an in-place merge, because merging would re-serialize the
machine config — destroying its comments and rewriting the file that
holds the secrets; re-apply is a plain file replacement.

init --systemd now also generates werkator.htaccess beside the units
whenever a publicBaseUrl is configured — generated host integration for
the managed-webspace Apache, copied into the docroot by the wrapper.

New subcommand 'werkator control-token' prints (and lazily creates) the
token via ControlTokenService, so no wrapper needs its own generator.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
mhoennig
2026-09-01 18:46:31 +02:00
co-authored by Claude Fable 5
parent 7a24ad1d7f
commit 5f1a669771
14 changed files with 323 additions and 12 deletions
@@ -2,6 +2,7 @@ package de.hoennig.werkator
import de.hoennig.werkator.commands.BuildCommand
import de.hoennig.werkator.commands.ConfigPrintCommand
import de.hoennig.werkator.commands.ControlTokenCommand
import de.hoennig.werkator.commands.InitCommand
import de.hoennig.werkator.commands.RetryCommand
import de.hoennig.werkator.commands.ServerCommand
@@ -22,6 +23,7 @@ import picocli.CommandLine.Command
BuildCommand::class,
RetryCommand::class,
ConfigPrintCommand::class,
ControlTokenCommand::class,
],
mixinStandardHelpOptions = true,
versionProvider = BuildPropertiesVersionProvider::class,
@@ -0,0 +1,38 @@
package de.hoennig.werkator.commands
import de.hoennig.werkator.git.GitService
import de.hoennig.werkator.server.ControlTokenService
import org.springframework.stereotype.Component
import picocli.CommandLine.Command
import java.nio.file.Path
import java.nio.file.Paths
import java.util.concurrent.Callable
/**
* Prints the control token guarding the mutating build endpoints, creating it
* exactly like the server does ([ControlTokenService] owns the format) — so no
* wrapper script ever needs its own token generator (step 23).
*/
@Component
@Command(
name = "control-token",
description = ["Print the control token for the mutating build endpoints, creating it if missing"],
mixinStandardHelpOptions = true,
)
class ControlTokenCommand(
private val gitService: GitService,
) : Callable<Int> {
var workingDir: Path = Paths.get(".")
override fun call(): Int {
val root =
try {
gitService.getTopLevel(workingDir.toAbsolutePath().normalize())
} catch (e: Exception) {
println("Error: ${e.message}")
return 2
}
println(ControlTokenService(root.resolve(".git/werkator/control-token")).token())
return 0
}
}
@@ -31,6 +31,16 @@ class InitCommand(
)
var systemd: Boolean = false
@Option(
names = ["--apply"],
description = [
"install a config-schema YAML fragment as the applied instance layer " +
"(validated strictly; re-applying replaces the previous fragment)",
],
paramLabel = "FILE",
)
var apply: Path? = null
/** Replaceable for tests: the jar this JVM was started from, or null when not run via `java -jar`. */
internal var jarPathResolver: () -> Path? = { runningJarPath() }
@@ -52,6 +62,17 @@ class InitCommand(
createRepoInstallConfig(root, detected, normalizedWorkingDir)
createProjectConfig(root, detected, normalizedWorkingDir)
// before the systemd files, which read the effective configuration —
// an applied fragment's port and limits must reach the generated unit
apply?.let { fragment ->
try {
val target = configLoader.applyInstanceFragment(root, fragment)
println("applied $fragment as ${target.toFile().relativeTo(normalizedWorkingDir.toFile())}")
} catch (e: Exception) {
println("Error: ${e.message}")
return
}
}
if (systemd) {
createSystemdFiles(root, normalizedWorkingDir)
}
@@ -274,12 +295,14 @@ class InitCommand(
* already loadable (re-running `init --systemd` on an installed instance); during
* the very first bootstrap they stay unset and the defaults (no directives) apply.
*/
private fun loadedSystemdConfig(): de.hoennig.werkator.config.SystemdConfig =
private fun loadedSystemdConfig(): de.hoennig.werkator.config.SystemdConfig = loadedServerConfig().systemd
private fun loadedServerConfig(): de.hoennig.werkator.config.ServerConfig =
try {
configLoader.load(Paths.get(".")).server.systemd
configLoader.load(Paths.get(".")).server
} catch (_: Exception) {
de.hoennig.werkator.config
.SystemdConfig()
.ServerConfig()
}
private fun createSystemdFiles(
@@ -325,6 +348,19 @@ class InitCommand(
pruneTimerFile.toFile().writeText(SystemdServiceFiles.pruneTimerContent())
println("created ${pruneTimerFile.toFile().relativeTo(normalizedWorkingDir.toFile())}")
// generated host integration like the units: only meaningful behind a web
// frontend, so it needs a public base URL; unused elsewhere and harmless
val server = loadedServerConfig()
if (server.publicBaseUrl.isNotBlank()) {
val htaccessFile = werkatorDir.resolve(SystemdServiceFiles.HTACCESS_NAME)
htaccessFile.toFile().writeText(SystemdServiceFiles.htaccessContent(server.port))
println(
"created ${htaccessFile.toFile().relativeTo(
normalizedWorkingDir.toFile(),
)} (Apache reverse proxy; copy it into the domain docroot on a managed webspace)",
)
}
println("install and start the service and the nightly Docker cleanup with:")
println(" ln -sf $unitFile ~/.config/systemd/user/$unitName")
println(" ln -sf $pruneServiceFile ~/.config/systemd/user/${SystemdServiceFiles.PRUNE_SERVICE_NAME}")
@@ -11,6 +11,8 @@ object SystemdServiceFiles {
const val ENV_FILE_NAME = "werkator.env"
/** Host-global unit names of the nightly Docker cleanup — shared by all Werkator repositories on the host. */
const val HTACCESS_NAME = "werkator.htaccess"
const val PRUNE_SERVICE_NAME = "werkator-docker-prune.service"
const val PRUNE_TIMER_NAME = "werkator-docker-prune.timer"
@@ -89,6 +91,20 @@ object SystemdServiceFiles {
#JAVA_OPTS=-Xmx256m
""".trimIndent() + "\n"
/**
* Apache reverse proxy for a Hostsharing managed webspace: the platform's
* Apache terminates TLS for the domain and forwards everything to the
* localhost port of the "eigener Serverdienst". Generated host integration
* like the units — the wrapper copies it into the domain's docroot.
*/
fun htaccessContent(port: Int): String =
"""
DirectoryIndex disabled
RewriteEngine On
RewriteBase /
RewriteRule .* http://127.0.0.1:$port%{REQUEST_URI} [proxy]
""".trimIndent() + "\n"
private fun sanitize(name: String): String = name.replace(Regex("[^A-Za-z0-9_.-]"), "-")
/** Escape `%` specifiers in systemd unit values (legacy `systemd_path`). */
@@ -19,6 +19,15 @@ object ConfigFiles {
/** The machine-specific configuration inside `.git`; secrets live here. */
const val REPO_INSTALL = ".git/werkator/$COMMITTED"
/**
* The applied instance fragment (`init --apply`, step 23): a config-schema YAML
* fragment installed verbatim as its own layer — above the committed project
* config, below the hand-edited machine config. Kept separate so applying never
* rewrites the machine config (its comments and secrets stay untouched) and
* re-applying is a plain file replacement, never a merge that can duplicate.
*/
const val APPLIED = ".git/werkator/.werkator.applied.yml"
/** The name the committed configuration had before the rename. */
const val LEGACY_COMMITTED = ".gittally.yml"
@@ -11,8 +11,10 @@ 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.Files
import java.nio.file.Path
import java.nio.file.Paths
import java.nio.file.StandardCopyOption
import java.util.concurrent.ConcurrentHashMap
@Service
@@ -28,6 +30,17 @@ class ConfigLoader(
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
.configure(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS, false)
/**
* For validating instance fragments ([applyInstanceFragment]) only: unknown keys
* fail there instead of being ignored — the regular layers stay lenient so an old
* Werkator can read a newer file.
*/
private val strictYaml =
ObjectMapper(YAMLFactory().disable(YAMLGenerator.Feature.WRITE_DOC_START_MARKER))
.registerKotlinModule()
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, true)
.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>()
@@ -285,13 +298,50 @@ class ConfigLoader(
val repoInstallName = ConfigFiles.firstExisting(workingDir, ConfigFiles.repoInstall)
val projectName = ConfigFiles.firstExisting(workingDir)
val repoInstall = loadFile(workingDir.resolve(repoInstallName).toFile())
val applied = loadFile(workingDir.resolve(ConfigFiles.APPLIED).toFile())
val project = loadFile(workingDir.resolve(projectName).toFile())
// per file, so the message names the file to fix — the merged map has no provenance
checkVersion(project, projectName, ROLLBACK_HINT)
checkVersion(applied, ConfigFiles.APPLIED, ROLLBACK_HINT)
checkVersion(repoInstall, repoInstallName, ROLLBACK_HINT)
checkTriggerBlocks(project, projectName, ROLLBACK_HINT)
checkTriggerBlocks(applied, ConfigFiles.APPLIED, ROLLBACK_HINT)
checkTriggerBlocks(repoInstall, repoInstallName, ROLLBACK_HINT)
return deepMerge(project, 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)
}
/**
* 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*
* against the schema — an unknown key is refused loudly, never ignored, because a
* typo in a fragment would otherwise install a value that silently does nothing.
* The fragment is then copied verbatim (comments included) to [ConfigFiles.APPLIED];
* re-applying replaces the file, so nothing can accumulate or duplicate.
*/
fun applyInstanceFragment(
workingDir: Path,
fragment: Path,
): Path {
val raw = loadFile(fragment.toFile())
require(raw.isNotEmpty()) { "instance fragment $fragment is missing, empty, or not a YAML mapping" }
checkVersion(raw, fragment.toString(), ROLLBACK_HINT)
checkTriggerBlocks(raw, fragment.toString(), ROLLBACK_HINT)
try {
strictYaml.convertValue(resolveBuildSections(dropNonDefinitionBuilds(raw)), WerkatorConfig::class.java)
} catch (e: IllegalArgumentException) {
throw IllegalArgumentException(
"instance fragment $fragment does not match the configuration schema: ${e.message}",
e,
)
}
val target = workingDir.resolve(ConfigFiles.APPLIED)
Files.createDirectories(target.parent)
val temp = Files.createTempFile(target.parent, ".werkator.applied", ".tmp")
Files.copy(fragment, temp, StandardCopyOption.REPLACE_EXISTING)
Files.move(temp, target, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE)
return target
}
/**
@@ -0,0 +1,37 @@
package de.hoennig.werkator.commands
import de.hoennig.werkator.git.GitService
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.shouldBe
import io.kotest.matchers.string.shouldMatch
import io.mockk.every
import io.mockk.mockk
import java.nio.file.Files
class ControlTokenCommandTest : FunSpec() {
private val gitService = mockk<GitService>()
private val command = ControlTokenCommand(gitService)
init {
test("creates the token like the server would and prints the same one on a re-run") {
val tempDir = Files.createTempDirectory("werkator-token-test")
command.workingDir = tempDir
every { gitService.getTopLevel(any()) } returns tempDir
command.call() shouldBe 0
val tokenFile = tempDir.resolve(".git/werkator/control-token")
val token = tokenFile.toFile().readText().trim()
token shouldMatch Regex("[0-9a-f]{48}")
command.call() shouldBe 0
tokenFile.toFile().readText().trim() shouldBe token
}
test("fails with exit code 2 outside a repository") {
every { gitService.getTopLevel(any()) } throws IllegalStateException("not a git repository")
command.call() shouldBe 2
}
}
}
@@ -17,8 +17,10 @@ class InitCommandTest : FunSpec() {
private val initCommand =
InitCommand(
gitService,
// default (null) BuildProperties provider: a relaxed ObjectProvider mock
// returns a raw Object under type erasure and breaks the version check
de.hoennig.werkator.config
.ConfigLoader(mockk(relaxed = true)),
.ConfigLoader(),
)
init {
@@ -122,6 +124,47 @@ class InitCommandTest : FunSpec() {
projectConfig.toFile().readText() shouldBe "existing: content"
}
test("--apply installs the fragment as the applied layer and the effective config sees it") {
val tempDir = Files.createTempDirectory("werkator-init-test")
initCommand.workingDir = tempDir
val fragment = tempDir.resolve("mih34.yml")
fragment.toFile().writeText("server:\n port: 18088\n")
initCommand.apply = fragment
every { gitService.getTopLevel(tempDir) } returns tempDir
every { gitService.getOriginUrl(tempDir) } returns "https://git.example.org/my-org/my-repo.git"
initCommand.run()
tempDir
.resolve(de.hoennig.werkator.config.ConfigFiles.APPLIED)
.toFile()
.shouldExist()
de.hoennig.werkator.config
.ConfigLoader()
.load(tempDir)
.server.port shouldBe 18088
initCommand.apply = null
}
test("--apply with an invalid fragment installs nothing") {
val tempDir = Files.createTempDirectory("werkator-init-test")
initCommand.workingDir = tempDir
val fragment = tempDir.resolve("typo.yml")
fragment.toFile().writeText("server:\n prot: 18088\n")
initCommand.apply = fragment
every { gitService.getTopLevel(tempDir) } returns tempDir
every { gitService.getOriginUrl(tempDir) } returns "https://git.example.org/my-org/my-repo.git"
initCommand.run()
Files
.exists(tempDir.resolve(de.hoennig.werkator.config.ConfigFiles.APPLIED))
.shouldBeFalse()
initCommand.apply = null
}
test("--systemd generates unit and environment file with install instructions") {
val tempDir = Files.createTempDirectory("werkator-init-test")
initCommand.workingDir = tempDir
@@ -91,5 +91,11 @@ class SystemdServiceFilesTest : FunSpec() {
content shouldContain "#JAVA_OPTS="
content shouldContain ".werkator.yml"
}
test("the htaccess proxies everything to the configured localhost port") {
val content = SystemdServiceFiles.htaccessContent(18088)
content shouldContain "DirectoryIndex disabled"
content shouldContain "RewriteRule .* http://127.0.0.1:18088%{REQUEST_URI} [proxy]"
}
}
}
@@ -110,6 +110,63 @@ class ConfigLoaderTest : FunSpec() {
"./gradlew fromBranch"
}
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")
Files.createDirectories(dir.resolve(".git/werkator"))
dir.resolve(ConfigFiles.APPLIED).toFile().writeText("server:\n port: 2000\n bindAddress: 0.0.0.0\n")
dir
.resolve(".git/werkator/.werkator.yml")
.toFile()
.writeText("server:\n port: 3000\n")
val server = loader.load(dir).server
// machine wins over applied wins over project; untouched keys fall through
server.port shouldBe 3000
server.bindAddress shouldBe "0.0.0.0"
server.publicBaseUrl shouldBe "https://project/"
}
test("applyInstanceFragment installs a valid fragment verbatim, and re-applying replaces it") {
val dir = Files.createTempDirectory("werkator-test")
val fragment = dir.resolve("mih34.yml")
fragment.toFile().writeText("# instance mih34\nserver:\n port: 18088\n")
val target = loader.applyInstanceFragment(dir, fragment)
target shouldBe dir.resolve(ConfigFiles.APPLIED)
// verbatim copy: the comment survives
target.toFile().readText() shouldContain "# instance mih34"
loader.load(dir).server.port shouldBe 18088
fragment.toFile().writeText("server:\n port: 19099\n")
loader.applyInstanceFragment(dir, fragment)
loader.load(dir).server.port shouldBe 19099
}
test("applyInstanceFragment refuses an unknown key loudly instead of installing a silent no-op") {
val dir = Files.createTempDirectory("werkator-test")
val fragment = dir.resolve("typo.yml")
fragment.toFile().writeText("server:\n prot: 18088\n")
val exception =
shouldThrow<IllegalArgumentException> {
loader.applyInstanceFragment(dir, fragment)
}
exception.message shouldContain "typo.yml"
Files.exists(dir.resolve(ConfigFiles.APPLIED)).shouldBeFalse()
}
test("applyInstanceFragment refuses a missing or empty fragment") {
val dir = Files.createTempDirectory("werkator-test")
shouldThrow<IllegalArgumentException> {
loader.applyInstanceFragment(dir, dir.resolve("absent.yml"))
}
}
test("reads executor.maxConcurrent and defaults it to 1") {
val dir = Files.createTempDirectory("werkator-test")
loader.load(dir).executor.maxConcurrent shouldBe 1