From 5f1a66977163bcca82fd52333ca73b49d8a7efa9 Mon Sep 17 00:00:00 2001 From: mhoennig Date: Tue, 1 Sep 2026 18:46:31 +0200 Subject: [PATCH] Step 23 session A: init --apply, the applied instance layer, control-token MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit '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 --- .claude/skills/architecture/SKILL.md | 10 ++-- docs/bootstrapping.md | 6 ++ docs/configuration.md | 9 ++- docs/plan/23-init-owns-the-files.md | 6 +- .../de/hoennig/werkator/WerkatorCommand.kt | 2 + .../werkator/commands/ControlTokenCommand.kt | 38 +++++++++++++ .../hoennig/werkator/commands/InitCommand.kt | 42 +++++++++++++- .../werkator/commands/SystemdServiceFiles.kt | 16 ++++++ .../de/hoennig/werkator/config/ConfigFiles.kt | 9 +++ .../hoennig/werkator/config/ConfigLoader.kt | 52 ++++++++++++++++- .../commands/ControlTokenCommandTest.kt | 37 ++++++++++++ .../werkator/commands/InitCommandTest.kt | 45 ++++++++++++++- .../commands/SystemdServiceFilesTest.kt | 6 ++ .../werkator/config/ConfigLoaderTest.kt | 57 +++++++++++++++++++ 14 files changed, 323 insertions(+), 12 deletions(-) create mode 100644 src/main/kotlin/de/hoennig/werkator/commands/ControlTokenCommand.kt create mode 100644 src/test/kotlin/de/hoennig/werkator/commands/ControlTokenCommandTest.kt diff --git a/.claude/skills/architecture/SKILL.md b/.claude/skills/architecture/SKILL.md index 0ed169d..0660ec9 100644 --- a/.claude/skills/architecture/SKILL.md +++ b/.claude/skills/architecture/SKILL.md @@ -19,12 +19,13 @@ WerkatorApplication ← @SpringBootApplication CliRunner ← CommandLineRunner + ExitCodeGenerator WerkatorCommand ← root @Command, delegates to subcommands commands/ - InitCommand ← "init [--systemd]" + InitCommand ← "init [--systemd] [--apply FILE]" ServerCommand ← "server" StatusCommand ← "status [--history]" BuildCommand ← "build []" RetryCommand ← "retry" ConfigPrintCommand ← "config:print [--full]" + ControlTokenCommand ← "control-token" ``` `status`, `build`, and `retry` implement `Callable` for their exit codes (0 success, 1 build failure, 2 usage/config errors). @@ -40,14 +41,15 @@ Two independent staleness signals, never merged: the `live-indicator` badge says ## Configuration System -Werkator is configured by two YAML files, deep-merged by `ConfigLoader` (later wins): +Werkator is configured by three YAML files, deep-merged by `ConfigLoader` (later wins): 1. `.werkator.yml` at the repo root — committed, shared team settings. -2. `.git/werkator/.werkator.yml` — not committed; machine-specific overrides and secrets (`git.account`, `git.token`). +2. `.git/werkator/.werkator.applied.yml` — not committed; the instance fragment installed verbatim by `init --apply` (step 23), validated strictly (unknown keys refused) and replaced wholesale on re-apply. +3. `.git/werkator/.werkator.yml` — not committed; machine-specific overrides and secrets (`git.account`, `git.token`); hand-edited, always wins. Every lookup falls back to the pre-rename name (`ConfigFiles`): `.gittally.yml`, and `.git/gittally/.gittally.yml` for the machine layer. Current name first, and where both exist the old one is ignored rather than merged — a missing config is not an error, so an un-renamed installation would otherwise start on defaults without a single failure. -On top of those comes the **branch layer**: the `.werkator.yml` committed on a branch, applied by `loadWithBranchLayer` (the watcher passes the content read via `git show`, `loadForWorktree` the file in the build worktree). A branch describes its own CI and wins over both layers — the whole `builds` section — so a configuration can be tried out on a branch without touching other branches' builds. `stripPinned` removes what is not a description of this branch's build: `git`, `server`, `gitea`, `executor`, `watcher`, and — inside every `builds` definition as well as every legacy `branches` entry — `requirePullRequest`, `statusContext`, and `docker.enabled`/`docker.network`. +On top of those comes the **branch layer**: the `.werkator.yml` committed on a branch, applied by `loadWithBranchLayer` (the watcher passes the content read via `git show`, `loadForWorktree` the file in the build worktree). A branch describes its own CI and wins over both layers — the whole `builds` section — so a configuration can be tried out on a branch without touching other branches' builds. `stripPinned` removes what is not a description of this branch's build: `git`, `server`, `gitea`, `executor`, `watcher`, and — inside every `builds` definition as well as every legacy `branches` entry — `requirePullRequest`, `statusContext`, `docker.enabled`/`docker.network`, and `bwrap.enabled`/`bwrap.rootfs`/`bwrap.werkdock`. Each file is version-checked before merging (`werkator.version.since`/`below`, `ConfigVersions.verdict`), so the message can name the file to fix: `since` is hard in both directions — too old a Werkator, or a file written before `ConfigVersions.FORMAT_BROKE_IN` and read after it — while `below` only warns. There is no format version (`apiVersion`) on purpose: only one configuration generation is supported, and the declared version exists to make the incompatibility nameable. diff --git a/docs/bootstrapping.md b/docs/bootstrapping.md index 14aa526..a6f1c59 100644 --- a/docs/bootstrapping.md +++ b/docs/bootstrapping.md @@ -86,6 +86,12 @@ gitea: Then, you have to configure *Werkator* by amending this config file according to [configuration.md](configuration.md). +### 5. Optionally Install an Instance Fragment (`--apply`) + +`init --apply FILE` installs a YAML fragment in the configuration schema as the applied instance layer — see [configuration.md](configuration.md#the-applied-instance-fragment-init---apply). +Deployment tooling hands its parameters over this way instead of patching config files; the fragment is validated strictly and replaced wholesale on re-apply. +It runs before `--systemd`, so an applied `server.port` reaches the generated unit and the Apache `.htaccess` (written beside the units when a `publicBaseUrl` is configured). + ## Output `init` prints one line per action taken: diff --git a/docs/configuration.md b/docs/configuration.md index 97a0bfc..a495588 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -7,10 +7,17 @@ Werkator is configured via YAML files. Settings are merged from several sources | Layer | Path | Committed to Git | Purpose | |--------------------------|----------------------------|------------------|----------------------------------------------| | Project config | `.werkator.yml` | Yes | Shared team settings | +| Applied instance fragment | `.git/werkator/.werkator.applied.yml` | No | Instance parameters installed by `init --apply` | | Repo installation config | `.git/werkator/.werkator.yml` | No | Machine- or user-specific overrides, secrets | | Branch config | `.werkator.yml` committed on a branch | Yes | That branch's build settings and build definitions | -The repo install config (`.git/werkator/.werkator.yml`) wins on any key present in both files. Typically used to set `git.token` and `git.account` without committing them. +The repo install config (`.git/werkator/.werkator.yml`) wins on any key present in several files; the applied fragment wins over the project config. Typically the repo install config sets `git.token` and `git.account` without committing them. + +### The applied instance fragment (`init --apply`) + +`werkator init --apply FILE` installs a YAML fragment in this very schema as its own layer (step 23) — the file a deployment wrapper hands over instead of patching configs. +The fragment is validated strictly before installing: an unknown key is refused loudly, never ignored, because a typo would otherwise install a value that silently does nothing. +It is then copied verbatim (comments included) to `.git/werkator/.werkator.applied.yml`; re-applying replaces the file, so nothing accumulates or duplicates, and the hand-edited repo install config — which always wins — is never rewritten. ### Which Werkator a file is written for diff --git a/docs/plan/23-init-owns-the-files.md b/docs/plan/23-init-owns-the-files.md index 2a0fc17..9b667ec 100644 --- a/docs/plan/23-init-owns-the-files.md +++ b/docs/plan/23-init-owns-the-files.md @@ -31,7 +31,7 @@ The removed legacy env-to-YAML conversion stays removed — there is no conversi ## The Sessions -### A — Werkator side +### A — Werkator side (implemented 2026-09-01 on branch `init-apply-config`) - `init --apply FILE`: deep-merge the YAML fragment into the machine config — reusing the loader's merge, creating missing sections, updating given values, never duplicating (the duplication class dies here); a fragment that fails the schema binding or carries unknown keys is refused loudly. - `init --systemd` keeps generating the units; decide in the session whether the Apache `.htaccess` becomes part of the host-integration output when the applied config carries `server.port` and a public domain (proposal: yes, under `init --systemd`, since it is generated host integration exactly like the units). @@ -50,6 +50,8 @@ The removed legacy env-to-YAML conversion stays removed — there is no conversi ## Acceptance Criteria -- Session A: `werkator init --apply …` merges and re-merges a fragment idempotently; `werkator control-token` exists; full suite green. +- Session A: done 2026-09-01 — `werkator init --apply …` installs and replaces a fragment idempotently; `werkator control-token` exists; full suite green. + Deviation from the sketch above: the fragment is NOT merged into the machine config — it is installed verbatim as its own layer (`.git/werkator/.werkator.applied.yml`, above project, below machine config), because an in-place merge would re-serialize the machine config, destroying its comments and rewriting the file that holds the secrets; a verbatim copy also makes re-apply a plain file replacement. + The `.htaccess` decision fell as proposed: generated beside the units by `init --systemd` whenever a `publicBaseUrl` is configured; the wrapper copies it into the domain docroot. - Session B: `tools/remote` contains no YAML heredocs and no `sed` into the machine config; the prerequisites bash script is gone. - Session C: the mih34 re-runs change nothing on a configured host and the deployment docs show only `--env`-style calls. diff --git a/src/main/kotlin/de/hoennig/werkator/WerkatorCommand.kt b/src/main/kotlin/de/hoennig/werkator/WerkatorCommand.kt index e9409c6..ee7dc4f 100644 --- a/src/main/kotlin/de/hoennig/werkator/WerkatorCommand.kt +++ b/src/main/kotlin/de/hoennig/werkator/WerkatorCommand.kt @@ -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, diff --git a/src/main/kotlin/de/hoennig/werkator/commands/ControlTokenCommand.kt b/src/main/kotlin/de/hoennig/werkator/commands/ControlTokenCommand.kt new file mode 100644 index 0000000..4d64c8a --- /dev/null +++ b/src/main/kotlin/de/hoennig/werkator/commands/ControlTokenCommand.kt @@ -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 { + 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 + } +} diff --git a/src/main/kotlin/de/hoennig/werkator/commands/InitCommand.kt b/src/main/kotlin/de/hoennig/werkator/commands/InitCommand.kt index 139490b..1871685 100644 --- a/src/main/kotlin/de/hoennig/werkator/commands/InitCommand.kt +++ b/src/main/kotlin/de/hoennig/werkator/commands/InitCommand.kt @@ -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}") diff --git a/src/main/kotlin/de/hoennig/werkator/commands/SystemdServiceFiles.kt b/src/main/kotlin/de/hoennig/werkator/commands/SystemdServiceFiles.kt index 566532c..2b7e2ba 100644 --- a/src/main/kotlin/de/hoennig/werkator/commands/SystemdServiceFiles.kt +++ b/src/main/kotlin/de/hoennig/werkator/commands/SystemdServiceFiles.kt @@ -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`). */ diff --git a/src/main/kotlin/de/hoennig/werkator/config/ConfigFiles.kt b/src/main/kotlin/de/hoennig/werkator/config/ConfigFiles.kt index ad95d74..c9a8bb8 100644 --- a/src/main/kotlin/de/hoennig/werkator/config/ConfigFiles.kt +++ b/src/main/kotlin/de/hoennig/werkator/config/ConfigFiles.kt @@ -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" diff --git a/src/main/kotlin/de/hoennig/werkator/config/ConfigLoader.kt b/src/main/kotlin/de/hoennig/werkator/config/ConfigLoader.kt index 88c909f..49b5a04 100644 --- a/src/main/kotlin/de/hoennig/werkator/config/ConfigLoader.kt +++ b/src/main/kotlin/de/hoennig/werkator/config/ConfigLoader.kt @@ -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() @@ -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 } /** diff --git a/src/test/kotlin/de/hoennig/werkator/commands/ControlTokenCommandTest.kt b/src/test/kotlin/de/hoennig/werkator/commands/ControlTokenCommandTest.kt new file mode 100644 index 0000000..abec3da --- /dev/null +++ b/src/test/kotlin/de/hoennig/werkator/commands/ControlTokenCommandTest.kt @@ -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() + 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 + } + } +} diff --git a/src/test/kotlin/de/hoennig/werkator/commands/InitCommandTest.kt b/src/test/kotlin/de/hoennig/werkator/commands/InitCommandTest.kt index 554d6d0..14ae12d 100644 --- a/src/test/kotlin/de/hoennig/werkator/commands/InitCommandTest.kt +++ b/src/test/kotlin/de/hoennig/werkator/commands/InitCommandTest.kt @@ -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 diff --git a/src/test/kotlin/de/hoennig/werkator/commands/SystemdServiceFilesTest.kt b/src/test/kotlin/de/hoennig/werkator/commands/SystemdServiceFilesTest.kt index 1417ac3..feb2053 100644 --- a/src/test/kotlin/de/hoennig/werkator/commands/SystemdServiceFilesTest.kt +++ b/src/test/kotlin/de/hoennig/werkator/commands/SystemdServiceFilesTest.kt @@ -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]" + } } } diff --git a/src/test/kotlin/de/hoennig/werkator/config/ConfigLoaderTest.kt b/src/test/kotlin/de/hoennig/werkator/config/ConfigLoaderTest.kt index 018f689..da6bc43 100644 --- a/src/test/kotlin/de/hoennig/werkator/config/ConfigLoaderTest.kt +++ b/src/test/kotlin/de/hoennig/werkator/config/ConfigLoaderTest.kt @@ -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 { + 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 { + 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