diff --git a/docs/werkator-migrationsplan.md b/docs/werkator-migrationsplan.md index 6ca944e..f46c530 100644 --- a/docs/werkator-migrationsplan.md +++ b/docs/werkator-migrationsplan.md @@ -25,9 +25,9 @@ It leaves every setting at its default, so an installation that updated without The fallback is temporary and goes away once the watched repositories have been renamed. -## What Has To Be Moved By Hand +## What Else the Rename Touches -### 1. The state directory +### 1. The state directory — done automatically `.git/gittally/` → `.git/werkator/`, with everything in it: @@ -38,11 +38,19 @@ The fallback is temporary and goes away once the watched repositories have been - `worktrees//` — the per-branch build worktrees - the generated systemd unit and its `EnvironmentFile` -There is no fallback for this path. -Without the move the instance starts with an empty history, a fresh control token, and no memory of today's scheduled builds — again without a single failure. +There is no name fallback for this path, so the first start after the update moves it (`StateDirMigration`). +Without the move the instance would start with an empty history, a fresh control token, and no memory of today's scheduled builds — again without a single failure, which is why it is done rather than documented. -The worktrees hold absolute paths in both directions (`.git/worktrees//gitdir` and the worktree's own `.git` file). -Either run `git worktree repair` after the move, or simply delete `.git/werkator/worktrees/` — a worktree is rebuilt on the next build of that branch. +The move happens only when the old directory exists and the new one does not. +Where both exist, nothing is moved and a warning names the leftover: which of the two is the live state is not something to guess. +A move that fails is logged as an error and does not stop the start. + +Two things follow from the move, both handled or reported: + +- The worktrees hold absolute paths in both directions (`.git/worktrees//gitdir` and the worktree's own `.git` file), so they are dropped instead of repaired. + Each is recreated by its branch's next build, which prunes the stale admin entry first. +- The generated systemd unit moves with the directory, which leaves its symlink in `~/.config/systemd/user` dangling — the running service is unaffected, the next start is not. + A warning names the unit; re-run `werkator init --systemd` and re-link it, see step 3. ### 2. The artifact root @@ -105,13 +113,12 @@ Move the state directory with the artifact root, and remove the old container so Per installation, and only while `/api/builds/current` is `[]` — a running build is interrupted by the restart and re-enqueued, but there is no reason to force that. 1. `systemctl --user stop werkator-.service` (old name). -2. Move the state directory, the artifact root, and the bundle. -3. Repair or delete the worktrees. -4. Rename the configuration files at the same time, or leave them to the fallback. -5. Deploy the new version. -6. `werkator init --systemd`, disable the old units, enable the new ones. -7. Start the service. -8. Update the Gitea branch protection rule if it names the check. +2. Move the artifact root and the bundle; the state directory moves itself at the first start. +3. Rename the configuration files at the same time, or leave them to the fallback. +4. Deploy the new version and start it once, so the state directory moves. +5. `werkator init --systemd`, disable the old units, enable the new ones. +6. Start the service. +7. Update the Gitea branch protection rule if it names the check. ## Verification @@ -124,13 +131,12 @@ Per installation, and only while `/api/builds/current` is `[]` — a running bui Keep the previous bundle and a timestamped copy of the machine configuration. Note the asymmetry: the new version reads both names, the old one only reads the old name. So a rollback works as long as the configuration files still carry — or carry again — their pre-rename names. -The state directory has to move back with it. +The state directory has to be moved back by hand; nothing moves it in that direction. ## Open Points -- **Should the state directory get the same fallback as the configuration?** - It would make an update a single step, at the price of a second lookup path in every place that writes state. - Currently intended as a deliberate manual move, because unlike a configuration the state is written, not only read, and a fallback that writes would have to decide which of two directories wins. +- **Settled:** the state directory is moved at the first start instead of getting a name fallback. + A fallback would have to decide, on every write, which of two directories wins; a one-time move decides once and leaves one path afterwards. - **This repository's own file names** are a separate step: 121 paths still contain `gittally`, package directories included. The rename script for it is written and waits. - **When the fallback goes away**, `ConfigFiles` loses its legacy entries and a leftover `.gittally.yml` should be rejected by name rather than ignored — the same reasoning as for the legacy `branches` section in [plan step 18](plan/18-remove-branches-section.md). diff --git a/src/main/kotlin/de/hoennig/werkator/StateDirMigration.kt b/src/main/kotlin/de/hoennig/werkator/StateDirMigration.kt new file mode 100644 index 0000000..434803a --- /dev/null +++ b/src/main/kotlin/de/hoennig/werkator/StateDirMigration.kt @@ -0,0 +1,83 @@ +package de.hoennig.werkator + +import org.slf4j.LoggerFactory +import java.io.IOException +import java.nio.file.Files +import java.nio.file.Path + +/** + * Moves the state directory of an installation that predates the rename to Werkator, + * once, at the first start after the update. + * + * The configuration is found under either name ([de.hoennig.werkator.config.ConfigFiles]), + * but the state is not: build history, the control token, the auto-build slots and the + * build worktrees live at one fixed path. A missing state directory is as quiet as a + * missing configuration — the instance would come up with an empty history and a fresh + * control token, and nothing would fail. So this is done rather than documented. + */ +object StateDirMigration { + const val DIR = ".git/werkator" + + private const val LEGACY_DIR = ".git/gittally" + private const val WORKTREES = "worktrees" + + private val log = LoggerFactory.getLogger(StateDirMigration::class.java) + + /** + * Renames `.git/gittally` to `.git/werkator` in [workingDir], if the first exists and + * the second does not. Never throws: a failed move must not stop a CI, it must say + * what to do by hand. + */ + fun migrateIfNeeded(workingDir: Path) { + val legacy = workingDir.resolve(LEGACY_DIR) + val current = workingDir.resolve(DIR) + if (!Files.isDirectory(legacy)) return + if (Files.exists(current)) { + // both exist: which of the two is the live state is not ours to guess + log.warn("{} exists next to {} — the leftover is ignored, remove it once you are sure", LEGACY_DIR, DIR) + return + } + try { + Files.move(legacy, current) + } catch (e: IOException) { + log.error("could not move {} to {}: {} — move it by hand", LEGACY_DIR, DIR, e.message) + return + } + log.info("moved {} to {}: build history, control token and configuration kept", LEGACY_DIR, DIR) + dropWorktrees(current) + warnAboutUnits(current) + } + + /** + * The moved worktrees point at their old path in both directions, so they are dropped + * rather than repaired: the next build of a branch creates its worktree again, and + * `GitWorktreeWorkspaces` prunes the stale admin entry before it does. + */ + private fun dropWorktrees(stateDir: Path) { + val worktrees = stateDir.resolve(WORKTREES) + if (!Files.isDirectory(worktrees)) return + if (worktrees.toFile().deleteRecursively()) { + log.info("dropped the moved build worktrees; each is recreated by its branch's next build") + } else { + log.warn("could not drop the moved build worktrees in {} — delete them by hand", worktrees) + } + } + + /** + * The generated systemd unit lives in the state directory and is symlinked from + * `~/.config/systemd/user`, so the move leaves that link dangling — the service keeps + * running and fails to start the next time. + */ + private fun warnAboutUnits(stateDir: Path) { + val units = + Files + .list(stateDir) + .use { paths -> paths.map { it.fileName.toString() }.filter { it.endsWith(".service") }.toList() } + if (units.isEmpty()) return + log.warn( + "the systemd unit {} moved with the state directory and its symlink now dangles — " + + "re-run `werkator init --systemd` and re-link it", + units.joinToString(", "), + ) + } +} diff --git a/src/main/kotlin/de/hoennig/werkator/WerkatorApplication.kt b/src/main/kotlin/de/hoennig/werkator/WerkatorApplication.kt index 99e5aa9..02d1462 100644 --- a/src/main/kotlin/de/hoennig/werkator/WerkatorApplication.kt +++ b/src/main/kotlin/de/hoennig/werkator/WerkatorApplication.kt @@ -10,6 +10,7 @@ import org.springframework.context.annotation.Profile import org.springframework.stereotype.Component import picocli.CommandLine import picocli.CommandLine.IFactory +import java.nio.file.Paths import kotlin.system.exitProcess @SpringBootApplication @@ -26,6 +27,8 @@ class CliRunner( private var exitCode = 0 override fun run(vararg args: String) { + // before any command resolves a path under it, and once per process + StateDirMigration.migrateIfNeeded(Paths.get(".")) exitCode = CommandLine(rootCommand, factory) .setExecutionExceptionHandler { exception, commandLine, _ -> diff --git a/src/main/resources/templates/releases.html b/src/main/resources/templates/releases.html index beaccf5..b471b57 100644 --- a/src/main/resources/templates/releases.html +++ b/src/main/resources/templates/releases.html @@ -25,9 +25,16 @@ rather than merged. The fallback is there because a configuration that is not found is not an error — it leaves every setting at its default, so an installation that updated without renaming would have come up looking healthy while having forgotten - its credentials and its builds. Rename at your convenience; the state directory - .git/werkator/ has no such fallback and does have to be moved, or the - instance starts without its build history. + its credentials and its builds. Rename at your convenience. +
  • The state directory has no such fallback, so the first start after the update moves + it: .git/gittally/ becomes .git/werkator/, with the build + history, the control token and the scheduled-build state in it. It moves only when + the old directory exists and the new one does not — where both exist nothing is + touched and a warning names the leftover. The build worktrees are dropped rather + than moved, since they point at their old path in both directions, and each is + recreated by its branch's next build. A generated systemd unit moves with the + directory and leaves its symlink dangling, which is warned about: re-run + werkator init --systemd.
  • The default Gitea check context is werkator. Where the old name is pinned in a branch protection rule, the rule has to be updated with it, or a pull request waits forever for a check nobody posts any more. Statuses already written diff --git a/src/test/kotlin/de/hoennig/werkator/StateDirMigrationTest.kt b/src/test/kotlin/de/hoennig/werkator/StateDirMigrationTest.kt new file mode 100644 index 0000000..6a3ac9e --- /dev/null +++ b/src/test/kotlin/de/hoennig/werkator/StateDirMigrationTest.kt @@ -0,0 +1,60 @@ +package de.hoennig.werkator + +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.booleans.shouldBeFalse +import io.kotest.matchers.booleans.shouldBeTrue +import io.kotest.matchers.shouldBe +import java.nio.file.Files +import java.nio.file.Path + +class StateDirMigrationTest : FunSpec() { + private fun repoWithLegacyState(): Path { + val dir = Files.createTempDirectory("werkator-state") + Files.createDirectories(dir.resolve(".git/gittally")) + dir.resolve(".git/gittally/build-results.json").toFile().writeText("[]") + return dir + } + + init { + test("moves the pre-rename state directory to its current path") { + val dir = repoWithLegacyState() + + StateDirMigration.migrateIfNeeded(dir) + + Files.exists(dir.resolve(".git/gittally")).shouldBeFalse() + dir.resolve(".git/werkator/build-results.json").toFile().readText() shouldBe "[]" + } + + test("drops the moved worktrees, because they point at their old path") { + val dir = repoWithLegacyState() + Files.createDirectories(dir.resolve(".git/gittally/worktrees/main")) + + StateDirMigration.migrateIfNeeded(dir) + + Files.exists(dir.resolve(".git/werkator/worktrees")).shouldBeFalse() + // the rest of the state survives the drop + Files.exists(dir.resolve(".git/werkator/build-results.json")).shouldBeTrue() + } + + test("leaves both alone when the current directory already exists") { + val dir = repoWithLegacyState() + Files.createDirectories(dir.resolve(".git/werkator")) + dir.resolve(".git/werkator/build-results.json").toFile().writeText("[\"live\"]") + + StateDirMigration.migrateIfNeeded(dir) + + // which of the two is the live state is not guessed + dir.resolve(".git/werkator/build-results.json").toFile().readText() shouldBe "[\"live\"]" + Files.exists(dir.resolve(".git/gittally")).shouldBeTrue() + } + + test("does nothing where there is no pre-rename directory") { + val dir = Files.createTempDirectory("werkator-state") + Files.createDirectories(dir.resolve(".git")) + + StateDirMigration.migrateIfNeeded(dir) + + Files.exists(dir.resolve(".git/werkator")).shouldBeFalse() + } + } +}