Prune manual-deploy rollback backups, and report them once (#2525) - #2528
Conversation
A dogfood box was carrying 46 _rollback_manual_* directories, 5.48 GB, the oldest three weeks old - and the #2185 install-location report named every one of them on every start. Each of those 46 warnings was true. Collectively they were a guard that had stopped guarding by being too loud: a real layout problem arrives as warning 47 in a list of 46 identical ones. Retention (deploy time). There was no deploy script to bolt pruning onto - install-darling.ps1 registers a service, it does not lay a build over one - so this adds the missing supported step, upgrade-darling.ps1, which ships in the zip. It keeps the newest -KeepRollbacks (3) and prunes the rest AFTER taking the new backup, verifies the source zip's SHA256, names but never kills processes holding the install tree, refuses to run from the install directory it is about to overwrite, confirms darling.json is unchanged, and is safe to re-run at every step. The service prunes nothing: it did not create these. Recognition (service start). DarlingInstallDirectoryReport now knows the convention and reports the whole set on one line with a count, a total, the oldest and the prune command - informational within retention, a warning past it. Per-directory lines stay for directories the product genuinely cannot account for, which is what makes them worth reading. This half matters even where retention runs: the backlog predates it and no upgrade removes it. The convention is one shared constant, and CI runs the script's own predicate against the service's over a shared case table - two spellings of one convention is how this happened, and the drift would be silent. Two defects came out of running the script's functions against planted trees rather than reading them: a future-dated backup read as "recent" and would have suppressed the deploy's own backup, and a failure while composing a log line was caught by the delete's handler and reported as a failed delete. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
| # ============================ the stop guard ============================ | ||
|
|
||
| $holders = Get-DarlingProcessesUnderPath $InstallRoot | ||
| if ($holders.Count -gt 0 -and -not $SkipStopGuard) { | ||
| # Parenthesised before -join on purpose: `$x | ForEach-Object { ... } -join ', '` binds -join to | ||
| # ForEach-Object as a parameter and throws, which is a fine way to lose a deploy to a formatting bug. | ||
| $names = @($holders | ForEach-Object { "$($_.ProcessName) (pid $($_.Id))" }) | ||
| Note "Processes are running out of the install tree:" | ||
| foreach ($name in $names) { Note " $name" } | ||
| Fail "Close them and re-run. Nothing has been stopped or copied. Do NOT kill them blindly — the bundled PostgreSQL runs from $InstallRoot\pg-runtime and killing it takes the store down; stopping the service stops it properly. If these are your own psql.exe or a shell sitting in the install directory, just exit them. Use -SkipStopGuard only if you are certain the copy will not hit a locked file." | ||
| } |
There was a problem hiding this comment.
The stop guard runs before the service is stopped, so it will fire on every normal upgrade of a running install.
Get-DarlingProcessesUnderPath $InstallRoot (called here) finds any process whose executable path lives under $InstallRoot. But at this point in the script the Darling service — the thing being upgraded — is still running, and its own exe (PerformanceMonitor.Darling.Service.exe) lives at $InstallRoot\PerformanceMonitor.Darling.Service.exe. The bundled PostgreSQL under pg-runtime is very likely running too. So $holders will contain at least the service process itself on essentially every real-world invocation, and the script will Fail here unless -SkipStopGuard is passed — which defeats the guard and contradicts the documented flow ("stop, back up, copy, start" — step 4 names/stops before step 5 stops the service, per the .DESCRIPTION block above).
The function's own doc comment says the opposite of where it's called: Get-DarlingProcessesUnderPath's comment (line ~237) reads "Stopping the service stops the store properly; anything still holding the tree after that is a person's session" — implying this check belongs after Stop-Service, to catch genuine strays (an operator's stray psql.exe, per the PR description's own field example) rather than the service being upgraded.
Suggest moving this guard to run after Stop-Service/WaitForStatus('Stopped', ...) succeeds (around line 511, before the backup step), so it only catches processes that are still holding the tree once the service itself is down — which is also the scenario it can't currently protect against (pg-runtime processes that outlive the service stop).
Note the C# test suite (DarlingDeployRollbackRetentionTests) only exercises individual extracted functions in isolation via RunWindowsPowerShell/ExtractFunction — nothing pins the overall script's control-flow ordering, so this wouldn't be caught by the new pins.
There was a problem hiding this comment.
Correct, and this was the serious one — thank you. The service exe lives at $InstallRoot\PerformanceMonitor.Darling.Service.exe and is running by definition on any install worth upgrading, so the guard would have failed on every real invocation. And the workaround is -SkipStopGuard, which is the worst possible outcome: a guard that fails closed on the happy path teaches people to disable it, and then it guards nothing. That is the same disease as the 46 warnings this PR is about, one layer earlier.
Fixed in 555a7be, but not by moving it — by running it twice, because the two positions answer different questions and neither subsumes the other:
- Phase one, before the stop, filters out what the stop will clear (the service exe, anything under
pg-runtime\). What is left is what a service stop will not close: an operator's ownpsql.exe, a shell sitting in the folder, a Darling Viewer left open holdingviewer\*.dll. Those are caught for the price of a re-run and no outage, which is the case from the field report. - Phase two, after the stop, runs with no exclusions at all. A hit there is usually a postmaster that outlived the stop — precisely the process nothing may kill. Phase one structurally cannot see this.
The Viewer is deliberately not excused even though we ship it: the service does not own it and stopping the service does not close it, so it holds viewer\ exactly as hard as any third-party app would. That is now a table-driven pin, along with pg-runtime-prev not matching a pg-runtime prefix.
You were also right that nothing pinned control flow, and that is the class of bug this was — so the new pin is about ordering (phase one before Stop-Service, phase two after, and which of the two filters), not about text. The excusal predicate now takes its separator from the runtime rather than a hardcoded \, so a rule that decides what gets excused from a guard is verifiable off-Windows; it runs green against a planted tree.
| Copy-Item -Path (Join-Path $Source '*') -Destination $InstallRoot -Recurse -Force | ||
| } | ||
| $copied = $true | ||
| break |
There was a problem hiding this comment.
Minor/secondary, lower confidence: the rollback backup only copies the install root's top-level files (deliberately, per the comment above) — viewer\, wwwroot\, runtimes\ and pg-runtime\ are never captured. That's fine for the happy path (retry succeeds), but the failure message a few lines down after a double copy failure says:
"restore the files from the newest
_rollback_manual_*directory"
If the copy fails partway through Expand-Archive/Copy-Item -Recurse (step 8), those subdirectories can be left in a mixed old/new state, and restoring only the backed-up root files won't fix that — the operator would end up with old binaries paired with a partially-new viewer/wwwroot/runtimes. Worth either backing up those directories too (accepting the size cost) or making the failure guidance explicit that a full recovery also needs re-extracting the previous zip's viewer/wwwroot/runtimes over the top, not just the _rollback_manual_* files.
There was a problem hiding this comment.
Also correct. The backup is root files only — that is exactly what keeps one to ~120 MB rather than ~1 GB, and it is why the field box reached 5.48 GB in 46 deploys rather than 46 GB — so after a partial Expand-Archive the advice was genuinely incomplete: viewer\, wwwroot\ and runtimes\ can be left mixed old-and-new, and restoring root files over that leaves old binaries paired with partly-new subdirectories.
I took the guidance option rather than the bigger-backup option, deliberately, and wrote the reasoning into the script at the point the backup is taken so the next reader does not re-open it:
- Backing those directories up multiplies precisely the retained disk this issue is about, to cover a case whose real fix is re-extracting a zip the operator still has.
- It would still be incomplete —
pg-runtimeis never backed up either, and never should be. runtimes\is the one with real teeth (native assets the service loads), and the previous zip restores it correctly, which a partial backup would not necessarily.
So in 555a7be the double-failure message now names both halves in order: re-extract the previous version's zip over the install root, then copy the _rollback_manual_* files over the top — and says why, that the backup does not hold those directories. It also leads with the thing to try first, which is re-running (the backup is reused, not replaced, so that is safe). The README carries the same caveat, and since the accuracy of that message is now the entire mitigation, it is pinned rather than trusted.
|
Reviewed the diff (CHANGELOG, new One correctness bug worth fixing before merge (left as an inline comment): the new stop guard in A second, lower-confidence note on the same file about the failure-recovery guidance after a double copy failure being incomplete (rollback backup only covers root files, not Everything else — the shared |
Two findings from the review pass, both correct. 1. The stop guard ran BEFORE Stop-Service and had no exclusions, so it found the service's own exe - which lives in the install root and is running by definition on any install worth upgrading - and failed. The script would have refused every real upgrade, and the workaround is -SkipStopGuard, i.e. a guard that fails closed on the happy path and teaches people to disable it. Same disease as the 46 warnings, one layer earlier. Now it checks twice. Phase one runs before the stop and filters out what the stop will clear (the service exe, anything under pg-runtime), leaving an operator's psql.exe or a left-open Viewer - caught at the cost of a re-run and no outage. Phase two runs after the stop with no exclusions, where a hit is usually a postmaster that outlived the stop, which is exactly what must never be killed. The Viewer is deliberately NOT excused: we ship it, but the service does not own it and stopping the service does not close it. Pinned by ordering (phase one before Stop-Service, phase two after, and which one filters), which is the class of bug this was - review correctly noted nothing pinned control flow. 2. The post-failure advice said to restore from _rollback_manual_*, but the backup holds root FILES only, so a half-extracted tree restored that way leaves old root binaries against partly-new viewer\ / wwwroot\ / runtimes\. Backing those up instead was rejected - it multiplies the retained disk this issue is about to cover a case whose real fix is a zip you still have, and would still miss pg-runtime. The message now names both halves, and the decision is written down where the backup is taken. The excusal predicate takes its separator from the runtime rather than a hardcoded backslash: it decides which processes are EXCUSED from a guard, and a rule that excuses things should be testable on the machine it was written on. Verified against a planted tree, including that pg-runtime-prev is not excused by a pg-runtime prefix. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
| Note "Laying the new build over $InstallRoot ..." | ||
| $copied = $false | ||
| foreach ($attempt in 1, 2) { | ||
| try { | ||
| if ($sourceIsZip) { | ||
| Expand-Archive -LiteralPath $Source -DestinationPath $InstallRoot -Force | ||
| } | ||
| else { | ||
| Copy-Item -Path (Join-Path $Source '*') -Destination $InstallRoot -Recurse -Force | ||
| } | ||
| $copied = $true | ||
| break | ||
| } | ||
| catch { | ||
| # The transient one is a DLL an antivirus scan or a not-yet-exited process still holds, and a retry | ||
| # a moment later has worked more than once. Two attempts, then stop: a third would just be a longer | ||
| # way to arrive at the same half-written tree. | ||
| if ($attempt -eq 1) { | ||
| Warn "The copy failed ($($_.Exception.Message)). Retrying in 10 seconds — this step has lost to a transiently locked DLL before." | ||
| Start-Sleep -Seconds 10 | ||
| } | ||
| else { | ||
| Fail "The copy failed twice ($($_.Exception.Message)). The service is STOPPED and the install tree may be HALF WRITTEN — do not start it. Re-run this script with the same arguments: it will reuse the rollback backup it already took rather than replacing it, and finish the copy, which is the FIRST thing to try. To go back to the old version instead, note that a half-written tree needs BOTH halves: re-extract the PREVIOUS version's zip over $InstallRoot (that restores viewer\, wwwroot\ and runtimes\, which the backup does not hold), then copy the files from the newest _rollback_manual_* directory over the top. Restoring only the backup leaves old root binaries paired with partly-new subdirectories." | ||
| } | ||
| } | ||
| } |
There was a problem hiding this comment.
Expand-Archive -Force (and the Copy-Item -Recurse -Force fallback for a folder source) only overwrite files that exist in the new build — neither one deletes a file that existed in the old install but was removed from the new one. A dependency dropped between versions, a renamed assembly, an old satellite-resource folder for a culture the new build no longer ships — all of that lingers in the install tree forever with no cleanup and, unlike a whole foreign top-level directory, no reporting: DarlingInstallDirectoryReport only walks top-level directories, not stray files inside directories the product still owns (viewer\, wwwroot\, runtimes\, or the root itself).
Given how carefully this script accounts for every other partial-state scenario (half-extracted trees, future-dated backups, mixed old/new subdirectories after a failed copy), it's worth calling out explicitly whether this is an accepted gap or worth a follow-up — e.g. diffing the new build's file manifest against the install root's files after the copy and at least warning about anything left over that the new build didn't ship.
There was a problem hiding this comment.
Right on the mechanism, and it is a real gap: Expand-Archive -Force is an overlay, it deletes nothing, and you are also right that DarlingInstallDirectoryReport structurally cannot catch it — it walks top-level directories, so a stale DLL in the root or inside viewer\ is invisible to it by construction, not by oversight.
I filed it as #2529 rather than fixing it here, and named it in a comment at the copy step so the next reader finds it where it matters.
The reason it is not a quick add: the obvious repair (diff the new build's manifest against the install root, warn about the remainder) needs an allowlist of everything that legitimately lives in that tree and never came from a zip — darling.json, the DPAPI credential blobs, the .bak-* config backups, the _rollback_manual_* directories this very script creates, pg-runtime\ and pg-runtime-prev\, pg-runtime.zip, plus whatever an operator put there. Get that list wrong in the loud direction and it warns about darling.json on every upgrade — which is #2525 again with a new subject, and the same guard-stops-guarding outcome. That is the wrong thing to design in the last commit of a PR about noise.
The issue carries four options and, more usefully, the measurement that should decide between them: diff the file lists of two consecutive release zips and find out whether we have ever actually removed a shipped file. If no release in the last year did, this is a documented gap and nothing more. My current preference is the variant that diffs old zip against new zip rather than zip against install root — it cannot produce a false positive, because everything it can name provably came out of one of our own zips, so it needs no allowlist at all.
There was a problem hiding this comment.
This is the sharpest of the three, and you are right that my previous fix only closed half of it. Fixed in ae03235.
The thing that makes it worse than an ordinary bug is the shape of the failure: it is a silent success. Every check passes, darling.json is unchanged (because it was never touched), the service reaches Running, the script prints its whole happy path — and the real service was stopped for nothing, the build went into a directory nothing reads, and the service came back on its old binaries. The operator believes they upgraded. The next person is debugging a version that never shipped. That is strictly worse than the crash my last commit fixed, because a crash tells you something happened.
-InstallRoot is now cross-checked against the registered ImagePath before anything is stopped or copied, and unconditionally rather than only when the parameter was passed. When it was not passed the two are equal by construction, so it costs a registry read — and gating it on $PSBoundParameters would make the check absent for precisely the caller it exists to protect. There is a pin asserting PSBoundParameters appears nowhere in the file, for that reason. An ImagePath that cannot be parsed warns rather than refuses: the service is registered and the auto-resolve path would already have failed, so the honest statement is "this was not confirmed", not "this is wrong".
While doing it I collapsed path equality to one spelling, Test-DarlingSamePath, now used by all three destructive-step gates — self-overwrite, source-is-the-install-directory, and this one. Three hand-rolled comparisons deciding whether an upgrade may touch a tree is the same two-spellings-of-one-rule failure this PR is a case study in, and I would rather not ship the disease in the cure. It deliberately tolerates the ways people actually type paths (trailing separator, case, a . segment), because a registered ImagePath and a hand-typed -InstallRoot routinely differ by exactly those and a stricter comparison would refuse every correct upgrade — while a sibling directory sharing a prefix (...DarlingOld) is correctly not the same path. Table-driven, run against the shipped function.
| $service = Get-Service -Name $serviceName -ErrorAction SilentlyContinue | ||
| if ($service -and $service.Status -ne 'Stopped') { | ||
| Note "Stopping '$serviceName'..." | ||
| Stop-Service -Name $serviceName -Force | ||
| try { (Get-Service -Name $serviceName).WaitForStatus('Stopped', [TimeSpan]::FromMinutes(2)) } | ||
| catch { Fail "'$serviceName' did not reach Stopped within two minutes. Nothing has been copied. Check what it is waiting on and re-run." } | ||
| } |
There was a problem hiding this comment.
If -InstallRoot is passed explicitly for a directory that has the service exe but isn't backed by a currently-registered 'PerformanceMonitor Darling' service (e.g. pointed at a build staged outside the actual install, or the service was renamed/removed but binaries left behind), Get-Service here returns $null, this block is skipped, and the script proceeds through backup/prune/copy as if everything is fine. It then hits the unguarded Start-Service -Name $serviceName at line 655, which throws a raw terminating error instead of one of the script's usual friendly Fail messages — worth an explicit check (mirroring the "not installed" guard already done for the auto-resolve path around line 367) rather than relying on a late, unfriendly crash.
There was a problem hiding this comment.
Correct, and fixed in 55197c5. The auto-resolve path reads the install root out of the registered ImagePath so it cannot get here without a service, but -InstallRoot skips that check entirely — and the consequence is worse than an ugly error: the script would have taken a backup, pruned, and completed the copy before failing, so it changes the tree and then crashes uninterpretably.
There is now an explicit refusal before the stop guard, the stop and the copy. It sits after the -PruneOnly exit deliberately — reclaiming disk from a tree whose service is gone is a perfectly reasonable thing to want and copies nothing — and that placement is pinned by ordering against all four landmarks, since "the check exists" and "the check is early enough to have prevented anything" are different claims.
Related, while I was there: the service status is now re-read at the stop rather than carried down from the existence check. It is a snapshot, and between the two the service can legitimately have been stopped by someone else or died on its own.
Review summaryScope: Darling-only (a new Overall this is very well-built: the deploy-script/service naming convention is pinned by a single shared constant plus a test that runs the actual Two things worth a look, posted inline:
Neither is a blocker given how narrow the second one is and how explicitly documented the first one's tradeoffs are elsewhere in the script, but both seemed worth flagging given how much rigor the rest of the script applies to edge cases. |
…ay gap Two more findings. 1. An explicit -InstallRoot bypassed the only check that the service is registered - the auto-resolve path reads the install root out of the ImagePath, so it cannot get there without one, but -InstallRoot skips it. A tree holding the binaries of a service that was renamed or removed would pass the stop guard, take a backup, prune, complete the copy, and only fall over at Start-Service with a raw terminating error, having already changed the tree. Now refused up front, after the -PruneOnly exit (reclaiming disk from a tree whose service is gone is reasonable and copies nothing), and pinned by ordering against the prune exit, the stop guard, the stop and the copy. Service status is also re-read at the stop rather than carried down from the existence check: it is a snapshot, and between the two the service can legitimately stop on its own. 2. An in-place upgrade is an OVERLAY - Expand-Archive -Force deletes nothing - so a file the old build had and the new one dropped stays forever, and the layout report cannot see it because it walks top-level directories, not files inside directories we own. Not fixed here. The obvious repair needs an allowlist of everything that legitimately lives in the tree and never came from a zip (darling.json, the DPAPI blobs, the rollback backups, pg-runtime, operator files); getting it wrong in the loud direction warns about darling.json on every upgrade, which is this same issue with a new subject. Filed as #2529 with the options and the measurement that should decide - diff two consecutive release zips and find out whether we have ever actually removed a shipped file - and named in a comment at the copy step so the next reader finds it there. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
| # ============================ the service has to exist ============================ | ||
| # | ||
| # The auto-resolve path cannot reach here without a registered service - it reads the install root out of | ||
| # the ImagePath - but an explicit -InstallRoot skips that check entirely. A tree holding the binaries of a | ||
| # service that was renamed, removed, or never registered then sails through the stop guard, the backup, the | ||
| # prune and the copy, and falls over at Start-Service with a raw terminating error instead of one of this | ||
| # script's own messages. Failing HERE costs nothing and says what to do; failing there costs a completed | ||
| # copy, a stopped-that-was-never-running service, and an error nobody can interpret. | ||
| # | ||
| # Deliberately not applied to -PruneOnly, which exits above: reclaiming disk from a tree whose service is | ||
| # gone is a perfectly reasonable thing to want, and it copies nothing. | ||
| if (-not (Get-Service -Name $serviceName -ErrorAction SilentlyContinue)) { | ||
| Fail "The '$serviceName' service is not registered on this machine, so there is nothing for this copy to stop and start around it. NOTHING has been stopped or copied. If '$InstallRoot' is a staging tree rather than an install, you want install-darling.ps1; if you only meant to reclaim disk, re-run with -PruneOnly, which needs no service." | ||
| } |
There was a problem hiding this comment.
Correctness: explicit -InstallRoot is never checked against the registered service's actual ImagePath.
The comment above this block (lines 519-520) explicitly acknowledges the gap — "an explicit -InstallRoot skips that check entirely" — but the fix that follows only refuses when the service isn't registered at all. It never verifies that the registered service's ImagePath directory actually equals $InstallRoot.
Concrete failure: an operator passes -InstallRoot pointing at a stale/decommissioned copy of the tree (old runbook, copy-paste from another box, a leftover directory that still happens to contain PerformanceMonitor.Darling.Service.exe so the line-381 check passes too). The script will:
- Stop the real
PerformanceMonitor Darlingservice (a real outage window), becauseStop-Service/Start-Serviceact on the service by name, not by path. - Take a backup, prune, and lay the new build down in the wrong, unrelated directory.
- Restart the real service, which comes back up running the old, untouched binaries from its actual
ImagePath. - Report success end-to-end ("Service is Running",
darling.jsonunchanged, etc.) — because nothing it checked was ever wrong.
The operator sees a clean run and believes the upgrade happened; the service never changed, and there was an unnecessary stop/start of production monitoring. This is exactly the kind of silent-but-plausible failure the rest of this script goes out of its way to prevent (self-overwrite refusal, unregistered-service refusal, stop-guard, config hash check).
Suggest cross-checking Get-DarlingInstallRootFromService $serviceName against $InstallRoot (case-insensitive) whenever -InstallRoot was passed explicitly, and Failing before anything is stopped if they disagree — the same "fail before touching anything" pattern already used for the unregistered-service and self-overwrite cases just above/below this.
|
Reviewed the full diff (PowerShell script, both new C# files, test suites, csproj, README, CHANGELOG). Scope check: this PR is Darling-only (install-tree upgrade tooling + the layout report's rollback-backup recognition). Full Edition ( No T-SQL in this PR, so the T-SQL style section of CONTRIBUTING.md doesn't apply. The C# additions ( What I verified works correctly:
One correctness gap worth a look (left as an inline comment on Everything else — SHA256 verification, the self-overwrite refusal, the wildcard-free directory selection, the retry-twice-then-fail copy, the config-hash sanity check — held up under review. |
Third review round, and the sharpest finding of the three: checking that a service EXISTS is not checking that it runs from the directory about to be overwritten, and the gap between those two claims is a silent success. Stop-Service and Start-Service act on the service by NAME, never by path. So an -InstallRoot aimed at a stale copy of the tree - an old runbook, a paste from another box, a leftover directory that still holds the exe and therefore satisfied every other precondition - would stop the REAL service (a real outage on a monitoring host), lay the new build down in a directory nothing reads, restart the real service on its old untouched binaries, find darling.json unchanged because it was never touched, and report success from end to end. Every check passes. The operator believes they upgraded; nothing did, and the next person is debugging a version that never shipped. Now cross-checked against the registered ImagePath before anything is stopped or copied, and unconditionally rather than only when -InstallRoot was passed: when it was not the two are equal by construction, and a check gated on the caller's argument is absent for exactly the caller it exists to protect. An ImagePath that cannot be read warns rather than refuses - the service is registered, we just could not confirm the directory, and the operator should know that was unconfirmed rather than assume it was checked. Path equality now has ONE spelling, Test-DarlingSamePath, used by all three destructive-step gates (self-overwrite, source-is-the-install- directory, wrong-install-root). Two hand-rolled copies of the comparison that decides whether an upgrade may touch a tree is the same two-spellings-of-one-rule failure this PR is a case study in. It tolerates the ways people actually type paths - trailing separator, case, a dot segment - because a registered ImagePath and a hand-typed -InstallRoot routinely differ by exactly those, and a comparison that called them different would refuse every correct upgrade. Verified against a table locally, including that a sibling directory sharing a prefix is NOT the same path. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Review: #2525 rollback-backup retention + reportingWent through the full diff — No blocking correctness, security, or Lite/Darling parity issues found. A few things I specifically checked and are handled correctly:
Parity: this is Darling-only server-install tooling (a Windows service + in-place upgrade procedure). Lite is a standalone desktop app with no server-side install/deploy step, so there's no Lite counterpart for this feature and no drift to flag. No T-SQL was touched by this PR, so the collector/style conventions in CONTRIBUTING.md don't apply here. Nicely instrumented — the two live defects mentioned in the PR description (future-dated backup, log-line-in-try) are both correctly fixed in the diff, and the regression tests for both are meaningful (they exercise the shipped script via brace-extraction + real PowerShell execution, not just text assertions). |
Closes #2525.
The shape of it
A dogfood box was carrying 46
_rollback_manual_*directories, 5.48 GB, the oldest three weeks old — and the #2185 install-location report named every one of them, every start.Every one of those 46 warnings was true. That is what makes them worth fixing rather than filtering. A guard that fires 46 times for something our own procedure created has stopped guarding by being too loud — a real layout problem, a stray DLL or a half-extracted upgrade, arrives as warning 47 in a list of 46 identical ones and nobody will ever see it. It is the same failure as a pin that never bites, wearing the opposite clothes: in both cases the check is still there, still running, still green-lighting the thing it exists to catch.
So both halves changed, and neither is sufficient alone.
Where retention went, and why
The issue's framing was right: there was nothing to bolt pruning onto.
Darling/tools/holdsinstall-darling.ps1(registers a service, or updates an existing binPath) anduninstall-darling.ps1. Neither lays a new build over an old one. That step — stop, back up, copy, start, verify — has never had a script in this repo; it lived in people's heads and in ad-hoc SSM scripts, which is precisely why the part nobody remembers by hand (deleting the last twenty deploys' backups) is the part that accumulated 5.48 GB.Bolting retention onto the service was the other option and it is the wrong one: the service must not delete what it did not create. That absolutism is already load-bearing in
DarlingStoreUpgrade(#1775 reports hand-made store copies and removes none of them) and weakening it here would be worse than the disk cost.So this adds the missing step:
Darling/tools/upgrade-darling.ps1, shipped in the zip beside the exe by the same csproj mechanism as the install/uninstall scripts. It owns retention because it owns the backups.What it does beyond pruning, each because the documented procedure already required it or a past deploy paid for it:
-Sha256or aSHA256SUMS.txtbeside it, and refuses an unverified zip unless you say-SkipHashCheckout loud. This overwrites the binaries of a running monitoring host.-InstallRootthe registered service does not actually run from, and refuses when no service is registered at all — both before anything is stopped or copied. (See review round 3 below: without the first of these, a run against a stale copy of the tree reports complete success having upgraded nothing.)pg-runtimeunder that directory and a blanket sweep takes the store down as its first act. (The last time this guard fired in the field, what it caught was an operator's ownpsql.exe.) A test assertsStop-Process/taskkill/.Kill(appear nowhere in the file. It checks twice — once before the stop, filtered to what a service stop will not clear, and once after with no exclusions; see review round 1..ps1PowerShell is reading.-PruneOnlyis exempt: it copies nothing, and the installed copy is exactly the one the service's report names.pg-runtime, notviewer), prunes past-KeepRollbacksafter taking the new backup so the tree never has fewer rollback points than retention promises, and confirmsdarling.jsonis byte-identical afterwards.-BackupWindowMinutes(60) is reused, not replaced — otherwise a re-run after a failed extract backs up the half-extracted tree over the only good copy.-KeepRollbacksdefaults to 3: the release, the one before it, and the one before that. The fourth cannot roll you back to anything you want — on the field box, the 43rd could only have returned it to a build from three weeks earlier.Recognition
DarlingInstallDirectoryReportgains a third class. Not "the product's layout" (it isn't) and not "unaccounted for" (we know exactly what it is): a directory in the deploy procedure's namespace gets one line for the whole set — count, total size, the oldest, andupgrade-darling.ps1 -PruneOnly.Severity follows retention, not disk. At or under 3 it is
Information; past it, aWarningnaming the excess. Warning about the intended state of a box that has been upgraded three times is how this report would talk itself straight back into being ignored.Per-directory lines stay for directories the product genuinely cannot explain, because for those the path is the message — and they are readable again now that 46 backups are not sitting on top of them.
The field case, run against the real report:
And warning 47, which is the whole point — 46 backups plus one stray directory now reads:
This half matters even though retention landed, and that is not hedging: the boxes carrying the backlog today got it before any script pruned anything, and an upgrade does not remove a directory the product did not create.
One convention, two implementations, and a pin between them
The script's spelling and the service's matcher must agree or the whole thing silently reverts — the service goes back to 46 warnings and nobody notices, because each of them is true. Two independent literals is how #2525 happened in the first place.
So
DarlingRollbackBackupsowns the string, andTheDeployScriptAndTheService_AgreeOnWhatARollbackBackupIsCalledliftsTest-DarlingRollbackBackupNameout of the shipped.ps1by brace matching, runs it under Windows PowerShell over a 12-row case table, and compares it toDarlingRollbackBackups.IsRollbackBackup— and checks both against the table, so a mutual mistake cannot pass itself off as agreement. Same idiomDarlingInstallLocationTestsuses to keep the installer's and the service's install-location rules answering alike.The retention selection is executed the same way, against planted trees, in a fixture where the name ordering and the timestamp ordering deliberately disagree — a prune sorting on names would select the two directories the test requires it to keep.
What reading the existing tests turned up
I read the existing
DarlingInstallDirectoryReportTestsbefore changing anything. Two of them used_rollback_manual_as their foreign example, which is no longer foreign:Report_SevenFieldDirectories_ProduceOneSummaryAndSevenLines_AndAllSurvivewas really pinning three things: the summary appears once and not once per directory; no directory is silently dropped; nothing is deleted. All three still hold, and the test still asserts all three — its subject is now seven directories nobody can account for. What it looked like it was pinning, but was not, is that a_rollback_manual_directory gets a line of its own. That was the field's naming used as a realistic stand-in; Manual-deploy rollback directories are never pruned: 5.5 GB and 48 warnings on every start #2525 is the discovery that the name was never arbitrary. Renamed to..._SevenUnaccountedForDirectories_....Report_NamesForeignDirectories_WithTheirSizeandReport_WhenTheSizeProbeIsExhausted_StillReportsEveryDirectory: same substitution, same properties intact. The second one's real pin — an exhausted budget degrades a directory's size and never its presence — is untouched.Report_NeverCallsAProductDirectoryForeign, the satellite-resource pair, and the empty-directory case are all unchanged, and the last one is the reason a bare_rollback_manual_(no stamp — a name no procedure produces) is deliberately outside the convention and still reported individually.Proving the pins red
Both halves were run locally against the shipped artifacts, not copies.
The report: a throwaway console project referencing
PerformanceMonitor.Darling.Service.csproj(which targets plainnet10.0) namedDarling.Testsso the existingInternalsVisibleToapplies — so the realDarlingInstallDirectoryReportruns. 24 checks. With the fix: 24 pass. WithIsRollbackBackupforced toreturn false— the pre-#2525 behaviour — 18 fail, and the 6 that stay green are exactly the properties that were already true (survival, product-layout silence, the seven-unaccounted-for case, the bare-prefix edge).One pin was passing for the wrong reason and got tightened:
Assert.Contains("holding at least ")matched the unaccounted-for summary, which carries the same words, so it went green against a build with no rollback line at all. It now asserts on the rollback line specifically. A pin that passes for a reason other than the one it names is worse than no pin.The script:
Microsoft.PowerShell.SDKhosted from a console app (nopwshon this machine), parse-checking the file and executing the extracted functions against planted trees. 56 checks, all passing — and two real defects came out of running them rather than reading them:trynow wraps only the measure and the delete; reporting happens after it on a success flag, and a test pins thatNote (/Warn (/$removed++do not appear inside the guarded block.Neither was visible in the source. Both are the kind of thing an
Assert.Containson script text can never see.What review found (three rounds, five real defects)
Every one of these was in the new script, and every one is the same category: a guard that is wrong about when it runs, not about what it checks. Worth recording because it is the exact failure #2525 itself is about, found four more times in the fix for it.
Stop-Servicewith no exclusions, so it found the service's own exe — which lives in the install root and is running by definition on any install worth upgrading. The workaround is-SkipStopGuard, which is the worst outcome available: a guard that fails closed on the happy path teaches people to disable it, and then it guards nothing. Now it runs twice — before the stop, filtered to what the stop will not clear (catching an operator'spsql.exeor a left-open Viewer at the price of a re-run and no outage), and after the stop with no exclusions (catching a postmaster that outlived it, which is precisely what must never be killed). The Viewer is deliberately not excused: we ship it, but the service does not own it and stopping the service does not close it.viewer\/wwwroot\/runtimes\. Backing those up instead was rejected — it multiplies the retained disk this issue is about, to cover a case whose real fix is a zip the operator still has, and would still misspg-runtime. The message now names both halves, and the decision is written down where the backup is taken.-InstallRootwas never checked against the service's actualImagePath— the sharpest one, because the failure is a silent success.Stop-Service/Start-Serviceact by name, so pointing at a stale copy of the tree stops the real service (a real outage), writes the build where nothing reads it, restarts the real service on its old binaries, findsdarling.jsonunchanged because it was never touched, and reports success end to end. Now cross-checked before anything is stopped, and unconditionally — gating it on$PSBoundParameterswould make it absent for exactly the caller it protects, so a pin asserts that identifier appears nowhere in the file.Test-DarlingSamePath), used by all three destructive-step gates. Shipping two spellings of one rule inside the fix for a bug caused by two spellings of one rule seemed worth avoiding.The ordering pins are new and deliberate: review correctly noted that nothing pinned control flow, and control flow is what all of these were. "The check exists" and "the check is early enough to have prevented anything" are different claims, so the tests assert positions relative to the prune exit, the stop guard,
Stop-Serviceand the copy.Filed, not fixed: #2529. An in-place upgrade is an overlay —
Expand-Archive -Forcedeletes nothing — so a file the old build shipped and the new one dropped stays forever, and the layout report cannot see it because it walks top-level directories. The obvious repair needs an allowlist of everything that legitimately lives in the tree and never came from a zip (darling.json, the DPAPI blobs, the backups,pg-runtime, operator files); getting that wrong in the loud direction warns aboutdarling.jsonon every upgrade, which is this same issue with a new subject. The issue carries four options and the measurement that should decide — diff two consecutive release zips and find out whether we have ever actually removed a shipped file.What the issue got slightly wrong
%ProgramData%and has its own summary, or one additional non-rollback directory on that box. Worth knowing because it means there may be a genuine unaccounted-for directory sitting in that log already._rollback_manual_also exists in%ProgramData%, spelled<datadir>_rollback_manual_<stamp>— the prefix as a suffix, beside the store's data directory, holding a whole PostgreSQL cluster. Those are deliberately not covered here: one can be hundreds of gigabytes, they are identified structurally byPG_VERSIONrather than by name, and no script of ours creates or prunes them. Make the retained-copy sweep reach every sibling, and report the store copies that are not ours (#1770) #1775's report keeps naming them individually and should.Testing
23 new tests (5742 → 5765 in
Darling.Tests), and the delta was checked against the[Fact]count so none of them is a test that silently never ran.Darling.Testsbuilds clean on macOS withEnableWindowsTargeting. It cannot run here —net10.0-windows, noMicrosoft.WindowsDesktop.App— and nothing in this PR was tested on real Windows from this machine: no service was stopped, no zip extracted, no install tree overwritten. The PowerShell functions ran under PowerShell 7.4 via the SDK, and CI runs them under Windows PowerShell 5.1, which is the version the script targets. The xUnit pins are the arbiter and they run on the Windowsbuildjob.The end-to-end flow — elevation,
Stop-Service,Expand-Archiveover a live tree,Start-Service— is unexercised by any test and only a real upgrade on a real box will exercise it. That belongs on a dogfood box, with-ListRollbacksfirst and-PruneOnlysecond before anything copies a build.