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:
co-authored by
Claude Fable 5
parent
7a24ad1d7f
commit
5f1a669771
@@ -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 [<branch>]"
|
||||
RetryCommand ← "retry"
|
||||
ConfigPrintCommand ← "config:print [--full]"
|
||||
ControlTokenCommand ← "control-token"
|
||||
```
|
||||
|
||||
`status`, `build`, and `retry` implement `Callable<Int>` 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.
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user