changelog_test.go
Rama main · réplica de lectura del repositorio original.
package main
import (
"context"
"flag"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"regexp"
"runtime"
"strconv"
"strings"
"testing"
"time"
"github.com/pingular/pingularity/internal/config"
"github.com/pingular/pingularity/internal/settings"
"github.com/pingular/pingularity/internal/store"
"github.com/pingular/pingularity/internal/web"
)
// What a release does to the installs already running is written down in three
// places. Until v0.100.4 two of them did not exist, and the third described one
// of the toolchain edges it had.
//
// - CHANGELOG.md. The GitHub release's notes are read by whoever upgrades that
// week. An operator who skipped four releases, or who is working backwards
// from a graph that stepped, has nowhere to look - and this release is the
// one that needed it. The "Auto location" setting was retired on purpose (an
// automatic test now either uses the server you pinned or finds one itself)
// and the retirement was named nowhere: speed_auto_loc and speed_auto_label
// left /api/settings and its defaults, speedtest_auto_label left
// /api/status, and a provisioning script that had pinned the scope kept
// POSTing the pair, kept being answered 200, and kept writing nothing.
//
// - The macOS floor, in prose. Go 1.27 binaries need macOS 13, and the cask
// declares it so brew refuses cleanly on Monterey - but the cask cannot help
// the reader taking the darwin archive off the releases page, so the floor
// also has to be a sentence they meet before they download. Sentences drift:
// the same toolchain bump that moved this one left the README's Go
// prerequisite behind (TestREADMEGoVersionMatchesGoMod is what that cost).
//
// - The URL rule. Moving go.mod from 1.25 to 1.27 unpinned five Go
// compatibility defaults, not the one the README documents. Three never
// surface here - nothing in this module hands a custom reader to
// crypto/..., nothing sets the goroutine labels a crash traceback now
// carries, and two more post-quantum key exchange groups are between the
// two ends of the handshake - but net/url now refuses a host whose colons
// are not a port, which is exactly what a webhook or heartbeat URL carrying
// an unbracketed IPv6 address looks like. The escape hatch, and its limit,
// were named nowhere.
//
// These hold the prose to the things that decide it: the cask, the parser this
// build links, and the settings controller an upgrade actually runs.
const changelogPath = "CHANGELOG.md"
// changelogVersionHeading matches one release section: "## v1.2.3", optionally
// with a prerelease suffix, anchored per line so a version named in a sentence
// cannot be mistaken for a heading.
var changelogVersionHeading = regexp.MustCompile(`(?m)^## v[0-9]+\.[0-9]+\.[0-9]+`)
// caskMacOSFloor matches the cask's own floor - the raw cask DSL goreleaser
// carries in custom_block, which is the line brew enforces on install. The
// trailing comment is allowed because that block is Ruby and the line beside it
// is where a maintainer would explain the codename; reading it as "no floor
// declared" would send them hunting for a line that is still there.
var caskMacOSFloor = regexp.MustCompile(`(?m)^\s*depends_on macos:\s*:([a-z]+)\s*(?:#.*)?$`)
// The runbook is followed by hand, so the rule about what earns a line has to
// live where the maintainer already reads - and it has to name the file, or the
// line goes into the GitHub release's notes and nowhere durable. These are the
// three shapes of change that reach a running install without anyone touching
// its settings, and they are what the auto-location retirement was.
var changelogRules = []string{
"removes a field from an API response",
"retires a setting",
"changes a default",
}
func TestChangelogIsWhereTheRunbookSendsIt(t *testing.T) {
// The runbook first: whether the rule is written down where it will be
// obeyed is a separate question from whether the file it points at exists,
// and reading the file first would report only the missing file.
runbook := mustReadRepoFile(t, releasingPath)
notes := sectionOf(runbook, "## Release notes")
if notes == "" {
t.Fatalf("%s no longer has a `## Release notes` section; the checklist this test holds to %s moved", releasingPath, changelogPath)
}
if !strings.Contains(notes, changelogPath) {
t.Errorf("%s's release-notes checklist never names %s, so a change that earns a line has nowhere durable to go - the notes on a GitHub release are read by whoever upgrades that week and by nobody after:\n%s", releasingPath, changelogPath, notes)
}
for _, rule := range changelogRules {
if !strings.Contains(notes, rule) {
t.Errorf("%s's release-notes checklist never says that a release which %q earns a line in %s. That is the shape of change this file exists for: the retirement of the auto-location setting removed two settings keys and a status field, answered every POST 200, and was written down nowhere.", releasingPath, rule, changelogPath)
}
}
changelog := mustReadRepoFile(t, changelogPath)
if !changelogVersionHeading.MatchString(changelog) {
t.Fatalf("%s has no `## v<version>` release section, so nothing in it is attached to a release:\n%s", changelogPath, changelog)
}
// The names themselves, because a consumer greps for the one that stopped
// answering rather than reading the section. These three are the whole of
// what v0.100.4 took off the API surface: a struct-tag diff over the tree
// finds no other removal, and no metric, endpoint or run flag went at all.
for _, gone := range []string{"speed_auto_loc", "speed_auto_label", "speedtest_auto_label"} {
if !strings.Contains(changelog, gone) {
t.Errorf("%s never names %s, which this release stopped answering. Someone whose script broke has that key and nothing else to search for.", changelogPath, gone)
}
}
// And it travels with the artefact. The archive is the only channel that
// carries prose at all - the packages and the images ship the licences and
// nothing else - and the reader unpacking a tarball over an install that is
// already running is the exact reader this file is written for.
archives := sectionOf(mustReadRepoFile(t, goreleaserPath), "archives:")
if archives == "" {
t.Fatalf("%s has no archives block; the release no longer publishes an archive for %s to ride in", goreleaserPath, changelogPath)
}
if !regexp.MustCompile(`(?m)^\s*-\s*CHANGELOG\*?\s*$`).MatchString(archives) {
t.Errorf("%s's archives.files does not list CHANGELOG*, so the tarball carries a README and docs but not the record of what this release changes for an install already running:\n%s", goreleaserPath, archives)
}
}
func TestMacOSFloorAgreesWithTheCaskThatEnforcesIt(t *testing.T) {
m := caskMacOSFloor.FindStringSubmatch(mustReadRepoFile(t, goreleaserPath))
if m == nil {
t.Fatalf("could not read a `depends_on macos:` floor out of %s's cask block; either the declaration moved or its shape changed, and until it is found this test cannot hold the prose to anything - on a prerelease-skipped channel that declaration is the only thing that refuses an install that cannot run", goreleaserPath)
}
floor := m[1] // "ventura"
// The cask stops `brew upgrade` on an older mac. It cannot stop anyone
// downloading the darwin archive or the raw binary, both of which are
// published for every release, so the floor has to be readable before the
// download too - in the install instructions, and in the record of what
// changed for an install already running.
for _, doc := range []string{"README.md", changelogPath} {
if !strings.Contains(strings.ToLower(mustReadRepoFile(t, doc)), floor) {
t.Errorf("the cask refuses to install below macOS :%s and %s never names it. brew's refusal is the only guard, and it does not cover the release archive: a Monterey user who downloads the tarball gets a binary that will not launch and a service that stays down.", floor, doc)
}
}
}
func TestBareIPv6WebhookRefusalIsDocumentedWithItsEscapeHatch(t *testing.T) {
// The parser this build links, not a claim about it. go.mod's directive is
// what decides the compatibility default, so if a later toolchain or a
// pinned GODEBUG puts the old behaviour back, the sentence below stops being
// true and has to change with it.
if _, err := url.Parse("http://fd00::1/hook"); err == nil {
t.Fatalf("url.Parse accepts an unbracketed IPv6 host again, so the documented refusal no longer happens; the docs describing it are now wrong in the other direction")
}
// The advice is only worth printing if it works.
u, err := url.Parse("http://[fd00::1]/hook")
if err != nil {
t.Fatalf("url.Parse rejects the bracketed form too (%v); the docs would be sending operators to a URL this build also refuses", err)
}
if u.Hostname() != "fd00::1" {
t.Fatalf("bracketed IPv6 parses to host %q, not the address that was written; the advice does not survive the parser", u.Hostname())
}
readme := mustReadRepoFile(t, "docs/install.md")
changelog := mustReadRepoFile(t, changelogPath)
if !strings.Contains(readme, "urlstrictcolons") {
t.Errorf("docs/install.md documents the certificate-store half of the Go 1.27 change and not this half: a webhook or heartbeat URL written with a bare IPv6 address now fails to parse, and GODEBUG=urlstrictcolons=0 - the escape hatch that restores the old parsing - is named nowhere in the repository. An operator whose alerts stopped has the error and nothing else.")
}
if !strings.Contains(strings.ToLower(changelog), "urlstrictcolons") {
t.Errorf("%s never mentions the URL parsing change, so an operator whose webhook stopped delivering on upgrade cannot find out from the record of what this release changed", changelogPath)
}
// Both documents have to say which of the two remedies actually delivers
// the webhook, or the escape hatch reads as an alternative to bracketing.
for _, doc := range []struct {
name, body string
}{{"docs/install.md", unwrapped(readme)}, {changelogPath, unwrapped(changelog)}} {
if !strings.Contains(doc.body, "[fd00::1]") {
t.Errorf("%s names the failure but not the bracketed address that fixes it, which is the only one of the two remedies that gets the webhook delivered", doc.name)
}
if !strings.Contains(doc.body, changelogGodebugLimit) {
t.Errorf("%s offers GODEBUG=urlstrictcolons=0 without saying %q. Restoring the old parsing does not deliver a bare-IPv6 webhook - the old parser read the address as the host fd00: and the port 1, and looked fd00: up as a name - so beside the brackets it reads as a second fix and is not one.", doc.name, changelogGodebugLimit)
}
}
// With a port, the same bare address is the one that actually stops
// working. The old parser took the last colon for the port and reached the
// address, so a webhook written that way was delivered by v0.70.1 and is
// refused by this build - and for this form both remedies bring it back. An
// entry that shows only the port-less form reads as though no webhook that
// used to arrive stops arriving.
const withPort = "http://fd00::1:8080/hook"
const withPortDelivered = "`http://fd00::1:8080/hook` was delivered by"
if _, err := url.Parse(withPort); err == nil {
t.Fatalf("url.Parse accepts %s again, so the documented refusal of a bare IPv6 host with a port no longer happens", withPort)
}
t.Setenv("GODEBUG", "urlstrictcolons=0")
old, err := url.Parse(withPort)
if err != nil || old.Hostname() != "fd00::1" || old.Port() != "8080" {
t.Fatalf("under GODEBUG=urlstrictcolons=0, %s is not read as the host fd00::1 and the port 8080 (err %v); the docs promise the old parsing delivers it", withPort, err)
}
for _, doc := range []struct {
name, body string
}{{"docs/install.md", unwrapped(readme)}, {changelogPath, unwrapped(changelog)}} {
for _, want := range []string{withPortDelivered, "`http://[fd00::1]:8080/hook`"} {
if !strings.Contains(doc.body, want) {
t.Errorf("%s never says %q. A bare IPv6 webhook with a port is the one v0.70.1 delivered and this build refuses, and bracketing it or restoring the old parsing brings it back.", doc.name, want)
}
}
}
}
// changelogGodebugLimit is the clause that keeps the escape hatch from reading
// as a second way to fix a bare-IPv6 webhook. Both documents carry it. The
// hatch restores the old reading of the address, and the old reading took the
// last colon for a port separator: host "fd00:", port "1", a name no resolver
// has ever answered. It is the fix for a URL the stricter parser now refuses,
// not for this one.
const changelogGodebugLimit = "does not deliver"
// The Ookla pair a v0.70.1 operator set and the iperf3 pair they never touched:
// on that build the second was filled in from the first at every load, so this
// is what the machine was doing when it was stopped for the upgrade.
const (
upgradeOoklaDirection = "down"
upgradeOoklaRetries = 3
)
// The two sentences the engine-split entry can carry, one per population. They
// are pinned as strings because the entry is the one with a bill attached:
// `both` measures upload as well as download, so a run that reverts to the
// shipped default moves about twice the data over a metered link.
const (
changelogIperfReset = "comes up with iperf3 at the shipped default"
changelogIperfRemedy = "set iperf3's direction and retries yourself"
changelogIperfKept = "keeps the iperf3 direction and retries it had"
// The population the carry cannot reach, named in the entry so its reader
// finds the remedy: a v0.70-born database the release candidate already started.
changelogIperfRCStarted = "already been started by v0.100.0-rc.1"
)
// TestChangelogSaysWhatBecomesOfTheIperfPair holds that entry to the controller
// that decides it rather than to anyone's reading of the code. Two databases,
// the two populations an upgrade from v0.70.1 arrives as: one wearing the birth
// marker v0.70 stamps on the stores it creates, one from before that marker
// existed. Both carry Ookla's pair and no iperf3 pair, which is what "I set the
// Ookla direction and never opened the iperf3 tab" leaves on disk - and which
// of the two sentences above is true of a database depends entirely on which of
// them the settings controller treats as already split.
func TestChangelogSaysWhatBecomesOfTheIperfPair(t *testing.T) {
// Wrapping collapsed: these are sentences in a paragraph that gets re-flowed
// whenever a word above them changes, and a line break landing in the middle
// of one is not the drift this test is here to catch.
changelog := unwrapped(mustReadRepoFile(t, changelogPath))
shipped := defaultSettings(config.Config{})
// The older store is seeded once, under iperf3's own names, so the machine
// goes on doing what it did. That has been true in every build with the
// split in it, and the changelog says so for that reader.
oldDir, oldRetries := iperfPairAfterUpgrade(t, false, false)
if oldDir != upgradeOoklaDirection || oldRetries != upgradeOoklaRetries {
t.Errorf("a database from before the birth marker came up with iperf3 at %s/%d, not the %s/%d it had been running: the pair is no longer carried across for anyone, and %s still tells that reader it is",
oldDir, oldRetries, upgradeOoklaDirection, upgradeOoklaRetries, changelogPath)
} else if !strings.Contains(changelog, changelogIperfKept) {
t.Errorf("%s never says %q. A database older than the birth marker keeps the iperf3 direction and retries it had, and that half of the entry is what tells a reader on an old install that nothing is about to change under them.", changelogPath, changelogIperfKept)
}
// The one population the carry cannot reach: a v0.70-born database the release
// candidate has already started. It recorded the split without carrying the
// pair, so iperf3 sits at the shipped default and nothing on disk says what it
// had been. Whatever the other two populations get, the entry owes this reader
// the values it now runs at and what to do about them - checked here and not
// below, where a carried v0.70 database returns before reaching those checks.
rcDir, rcRetries := iperfPairAfterUpgrade(t, true, true)
if rcDir == shipped.IperfDirection && rcRetries == shipped.IperfRetries {
if !strings.Contains(changelog, changelogIperfRCStarted) {
t.Errorf("a v0.70 database the release candidate already started comes up with iperf3 at the shipped %s/%d, and %s never names that population (%q), so its reader cannot tell the entry is about them", shipped.IperfDirection, shipped.IperfRetries, changelogPath, changelogIperfRCStarted)
}
if !strings.Contains(changelog, changelogIperfRemedy) {
t.Errorf("%s never tells a reader whose release-candidate database lost the iperf3 pair to %q, which is the whole of what they can do about it", changelogPath, changelogIperfRemedy)
}
// The values have to be in the sentence about this population: the same
// words elsewhere in the entry describe a different one.
rcEntry := changelog
if i := strings.Index(changelog, changelogIperfRCStarted); i >= 0 {
rcEntry = changelog[i:]
if j := strings.Index(rcEntry, changelogIperfRemedy); j >= 0 {
rcEntry = rcEntry[:j]
}
}
for _, want := range []string{"`" + shipped.IperfDirection + "`", retriesInWords(shipped.IperfRetries)} {
if !strings.Contains(rcEntry, want) {
t.Errorf("%s never names %s where it describes a v0.70 database the release candidate already started, which is what that database runs iperf3 at", changelogPath, want)
}
}
} else if strings.Contains(changelog, changelogIperfRCStarted) {
t.Errorf("a v0.70 database the release candidate already started now comes up with iperf3 at %s/%d, not the shipped default, and %s still says that population is left at the default", rcDir, rcRetries, changelogPath)
}
bornDir, bornRetries := iperfPairAfterUpgrade(t, true, false)
if bornDir == upgradeOoklaDirection && bornRetries == upgradeOoklaRetries {
// The pair is carried across for this population too now. The entry
// must stop saying it is reset, or it is telling most of the installs
// that will read it the opposite of what happens to them.
if strings.Contains(changelog, changelogIperfReset) {
t.Errorf("a database the birth marker calls v0.70-or-later now keeps iperf3 at %s/%d - the pair is carried across - but %s still says %q. Drop that half of the entry and let %q cover both populations.",
bornDir, bornRetries, changelogPath, changelogIperfReset, changelogIperfKept)
}
return
}
if bornDir != shipped.IperfDirection || bornRetries != shipped.IperfRetries {
t.Fatalf("a database the birth marker calls v0.70-or-later came up with iperf3 at %s/%d, which is neither what it had been running (%s/%d) nor the shipped default (%s/%d). %s can only describe one of those two outcomes; it now has to describe a third.",
bornDir, bornRetries, upgradeOoklaDirection, upgradeOoklaRetries, shipped.IperfDirection, shipped.IperfRetries, changelogPath)
}
// It is reset. Say so, say what it costs, and name the values it resets to.
if !strings.Contains(changelog, changelogIperfReset) {
t.Errorf("a database the birth marker calls v0.70-or-later comes up with iperf3 at the shipped %s/%d whatever Ookla was set to - on this store, %s/%d before the upgrade - and %s never says %q. That is the entry with a bill attached: %q measures upload as well as download, so an iperf3 run over a metered link moves about twice the data it did yesterday.",
shipped.IperfDirection, shipped.IperfRetries, upgradeOoklaDirection, upgradeOoklaRetries, changelogPath, changelogIperfReset, shipped.IperfDirection)
}
if !strings.Contains(changelog, changelogIperfRemedy) {
t.Errorf("%s says the iperf3 pair is reset but never tells the reader to %q, which is the whole of what they can do about it", changelogPath, changelogIperfRemedy)
}
for _, want := range []string{"`" + shipped.IperfDirection + "`", retriesInWords(shipped.IperfRetries)} {
if !strings.Contains(changelog, want) {
t.Errorf("%s never names %s, which is what an upgraded v0.70 database now runs iperf3 at. A reader who cannot see the new value from the entry cannot tell whether it is the one they wanted.", changelogPath, want)
}
}
}
// iperfPairAfterUpgrade builds the on-disk shape a v0.70.1 install leaves - the
// Ookla pair set, no iperf3 pair, the birth marker present or not - loads it the
// way the daemon's first start does, and answers with the iperf3 direction and
// retry count that install now runs at. rcStarted adds the split marker a
// release candidate that has already started the database left in it.
//
// File-backed, not :memory:: the load reads the table, seeds from it and writes
// the seed back, and a single-connection store hides what that costs.
func iperfPairAfterUpgrade(t *testing.T, born, rcStarted bool) (string, int) {
t.Helper()
ctx := context.Background()
st, err := store.Open(filepath.Join(t.TempDir(), "pingularity.db"))
if err != nil {
t.Fatalf("open the upgraded database: %v", err)
}
t.Cleanup(func() { st.Close() })
rows := map[string]string{
"speed_direction": upgradeOoklaDirection,
"speed_retries": strconv.Itoa(upgradeOoklaRetries),
"quick_setup_done": "1",
}
if born {
rows[settings.KeyInstallBornVersion] = "0.70.1"
}
if rcStarted {
rows["engine_split_done"] = "1"
}
for k, v := range rows {
if err := st.SetSetting(ctx, k, v); err != nil {
t.Fatalf("seed %s: %v", k, err)
}
}
set, err := settings.New(ctx, st, defaultSettings(config.Config{}))
if err != nil {
t.Fatalf("first load on the new build: %v", err)
}
// The premise, checked rather than assumed: the marker decides which
// population this is, and a load that stamped one on the older store would
// quietly turn this into the same case twice.
if _, split := settingsSnapshot(t, st)["engine_split_done"]; rcStarted && !split {
t.Fatal("seeded the split marker a release candidate leaves and the store no longer holds it; this fixture is no longer the population it names")
}
if _, marked := settingsSnapshot(t, st)[settings.KeyInstallBornVersion]; marked != born {
t.Fatalf("seeded the store with born=%v and the load left the birth marker present=%v; this fixture is no longer the population it names", born, marked)
}
return set.IperfDirection(), set.IperfRetries()
}
// unwrapped collapses every run of whitespace in a document to a single space,
// so a sentence this file pins can be re-wrapped in the markdown without the
// pin coming loose.
func unwrapped(doc string) string { return strings.Join(strings.Fields(doc), " ") }
// retriesInWords writes a retry count the way the prose does: the shipped one
// is spelled out, anything else is a number.
func retriesInWords(n int) string {
if n == 1 {
return "one retry"
}
return strconv.Itoa(n) + " retries"
}
// sectionOf returns one "## Heading" section of a markdown document, heading
// included, up to the next heading at the same level. The runbook's checklists
// are per-section, and an assertion that read the whole file would pass on a
// sentence sitting under a different one. A top-level YAML key ("archives:")
// has the same shape - the block runs to the next line in column zero - so the
// same walk reads one of those too.
func sectionOf(doc, heading string) string {
lines := strings.Split(doc, "\n")
sameLevel := "## "
if !strings.HasPrefix(heading, "#") {
sameLevel = ""
}
for i, l := range lines {
if strings.TrimSpace(l) != heading {
continue
}
for j := i + 1; j < len(lines); j++ {
if sameLevel == "" {
if lines[j] != "" && !strings.HasPrefix(lines[j], " ") && !strings.HasPrefix(lines[j], "\t") {
return strings.Join(lines[i:j], "\n")
}
continue
}
if strings.HasPrefix(lines[j], sameLevel) {
return strings.Join(lines[i:j], "\n")
}
}
return strings.Join(lines[i:], "\n")
}
return ""
}
// The two entries a sibling change moved out from under while this file was
// being written. The `-db` entry said a link was refused after the daemon had
// learned to follow one; the schedule entry said a no-day window was dropped
// after the drop had been taken back out. Both describe what an upgrade does to
// a machine that is already running, both were true of some build, and neither
// was held to anything - which is how a sentence outlives the behaviour it
// describes. These hold them to the store and the settings controller that
// decide them.
// changelogLinkFollowed is the clause the `-db` entry turns on: the link leads
// the daemon to the file it names, or it stops the daemon.
const changelogLinkFollowed = "link to a real file is followed once"
func TestChangelogSaysWhatADbSymlinkDoes(t *testing.T) {
ctx := context.Background()
dir := t.TempDir()
// The arrangement the entry is written for: the database moved to the
// bigger disk, a link left where the unit file still points.
target := filepath.Join(dir, "moved.db")
if err := os.WriteFile(target, nil, 0o600); err != nil {
t.Fatalf("place the database the link names: %v", err)
}
link := filepath.Join(dir, "pingularity.db")
if err := os.Symlink(target, link); err != nil {
t.Skipf("this filesystem will not make a symlink: %v", err)
}
changelog := unwrapped(mustReadRepoFile(t, changelogPath))
saysFollowed := strings.Contains(changelog, changelogLinkFollowed)
st, err := store.Open(link)
if err != nil {
if saysFollowed {
t.Fatalf("%s says a %q and this build refuses one: %v. Which of the two happens decides whether an install whose -db is a link comes back at all on the first restart after the upgrade, so the entry cannot be left saying the other one.", changelogPath, changelogLinkFollowed, err)
}
return
}
if err := st.SetSetting(ctx, "quick_setup_done", "1"); err != nil {
t.Fatalf("write to the store opened through the link: %v", err)
}
st.Close()
if !saysFollowed {
t.Errorf("this build opens a -db path that is a link to a real file, and %s never says so. That entry is what a reader checks before pointing -db at a link of their own.", changelogPath)
}
// "Followed once" means two things the entry promises separately: the file
// at the far end is the one that was written, and the link is still a link.
// A build that renamed or replaced the link instead is the split the
// resolve exists to remove.
fi, err := os.Lstat(link)
if err != nil {
t.Fatalf("the link is gone after the open (%v); %s promises it is never renamed or replaced", err, changelogPath)
}
if fi.Mode()&os.ModeSymlink == 0 {
t.Fatalf("the open replaced the link with a %s; %s promises the link itself is never renamed or replaced", fi.Mode().Type(), changelogPath)
}
far, err := store.Open(target)
if err != nil {
t.Fatalf("re-open the file the link names: %v", err)
}
defer far.Close()
if settingsSnapshot(t, far)["quick_setup_done"] != "1" {
t.Errorf("a write through the link did not reach the file the link names, so %s's promise that the file at the far end is the one opened is not what this build does - and a repair would be about a different file again", changelogPath)
}
// Where the key and the log go is the half of the entry an operator acts
// on. Pointed at the file the link names instead of at the link, -db finds
// no key beside it and makes a new one, and every saved iperf3 password
// stops decrypting - so the entry has to say which of the two they follow.
byLink := keyAndLogFollowTheDBPathAsTyped(t)
if says := strings.Contains(changelog, dbLinkKeyStays); byLink != says {
t.Errorf("pingularity.key and logs.txt stay beside a -db link = %v, and %s says %q = %v. An operator who reads the entry the other way points -db at the file the link names and the daemon makes a new key.", byLink, changelogPath, dbLinkKeyStays, says)
}
}
// changelogScheduleParked is the clause the schedule entry turns on: a window
// selecting no weekday parks what its schedule gates, or it is dropped and the
// feature runs around the clock instead.
const changelogScheduleParked = "parks what it gates"
func TestChangelogSaysWhatANoDayScheduleWindowDoes(t *testing.T) {
ctx := context.Background()
// File-backed: the load reads the table and writes back what it normalized,
// and a single-connection store hides what that costs.
st, err := store.Open(filepath.Join(t.TempDir(), "pingularity.db"))
if err != nil {
t.Fatalf("open the upgraded database: %v", err)
}
defer st.Close()
// What a provisioning script, a restored backup or a hand edit leaves: the
// latency schedule on, one window, every weekday off.
if err := st.SetSettings(ctx, map[string]string{
"sched_lat_enabled": "1",
"sched_lat_windows": `[{"days":"0000000","start":540,"end":1020}]`,
"quick_setup_done": "1",
}); err != nil {
t.Fatalf("seed the schedule: %v", err)
}
set, err := settings.New(ctx, st, defaultSettings(config.Config{}))
if err != nil {
t.Fatalf("first load on the new build: %v", err)
}
// Parked means parked all week, not merely outside office hours, so the
// whole week is asked rather than one convenient hour.
week := time.Date(2026, time.January, 5, 0, 0, 0, 0, time.UTC) // a Monday
allowed := ""
for h := 0; h < 7*24 && allowed == ""; h++ {
if at := week.Add(time.Duration(h) * time.Hour); set.LatencyAllowed(at) {
allowed = at.Format(time.RFC1123)
}
}
changelog := unwrapped(mustReadRepoFile(t, changelogPath))
if allowed != "" {
t.Fatalf("a latency schedule whose only window selects no weekday allows probing at %s, so the window no longer parks anything and %s still says it does. An install that was deliberately quiet starts probing round the clock on the first restart after the upgrade.", allowed, changelogPath)
}
if !strings.Contains(changelog, changelogScheduleParked) {
t.Errorf("a window selecting no weekday parks the feature its schedule gates, and %s never says %q - which is the whole of what an operator carrying such a schedule needs to know has not changed under them.", changelogPath, changelogScheduleParked)
}
// The flag is what the operator wrote down, and the entry says it is saved
// as written: a build that turned it off would be answering the settings
// API something other than what is on disk.
if !set.Snapshot().SchedLatEnabled {
t.Errorf("the load turned the latency schedule off, so the API answers off where the store says on; %s says such a window is saved as written", changelogPath)
}
// And the line the entry promises at every start, in place of the silence it
// describes.
got := set.NeverActiveSchedules()
if len(got) != 1 || got[0] != "latency" {
t.Errorf("the controller names %v as never able to be active, not [latency], so the boot line %s promises for a parked feature is not printed for this one", got, changelogPath)
} else if line := schedParkedLine(got[0]); !strings.Contains(line, "latency schedule") || !strings.Contains(changelog, changelogScheduleBootLine) {
t.Errorf("the daemon names this parked schedule at every start (%q), and %s never says %q - which is the whole of what changed for an install carrying one", line, changelogPath, changelogScheduleBootLine)
}
}
// changelogScheduleBootLine is what the schedule entry says the daemon now does
// about a parked feature.
const changelogScheduleBootLine = "prints one line per parked feature on stdout at every start"
// The Removed entry is the one a provisioning script's author reads when a run
// that used to pass starts failing, and every sentence in it is a behaviour:
// what a write gets back - the city the install already carries included - what
// a blank one gets, what a dashboard page loaded before the upgrade has to do,
// and which installs are told at boot. Hold each to the settings handler and to
// the boot path's own judgement, over a store shaped the way v0.70.1 left it.
const (
changelogRetiredWrite = "comes back `400`"
changelogRetiredEcho = "even when it sends the city this install already has"
changelogRetiredBlank = "blank is still `200`"
changelogRetiredStaleTab = "reload the page and save again"
changelogRetiredBootLine = "gets one line on stdout"
changelogRetiredOoklaOnly = "runs its speedtests on Ookla"
)
func TestChangelogSaysWhatBecomesOfARetiredCity(t *testing.T) {
ctx := context.Background()
changelog := unwrapped(mustReadRepoFile(t, changelogPath))
// File-backed: the settings POST writes through the controller and reads back
// what it stored, and a single-connection store hides what that costs.
upgraded := func(t *testing.T, extra map[string]string) (*store.Store, *settings.Controller) {
t.Helper()
st, err := store.Open(filepath.Join(t.TempDir(), "pingularity.db"))
if err != nil {
t.Fatalf("open the upgraded database: %v", err)
}
t.Cleanup(func() { st.Close() })
rows := map[string]string{
"speed_auto_loc": "49.2827,-123.1207",
"speed_auto_label": "Vancouver, BC",
"quick_setup_done": "1",
}
for k, v := range extra {
rows[k] = v
}
if _, err := st.SetSettingsDiff(ctx, rows); err != nil {
t.Fatalf("seed the rows v0.70.1 left: %v", err)
}
set, err := settings.New(ctx, st, defaultSettings(config.Config{}))
if err != nil {
t.Fatalf("first load on the new build: %v", err)
}
return st, set
}
post := func(st *store.Store, set *settings.Controller, body string) (int, string) {
h := web.New(st, nil, nil, set, nil, "test", slog.New(slog.DiscardHandler)).Handler()
req := httptest.NewRequest("POST", "/api/settings", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req.RemoteAddr = "127.0.0.1:54321" // local-only access is the default
req.Host = "127.0.0.1:9000" // and the DNS-rebinding guard refuses anything else
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
return rec.Code, rec.Body.String()
}
holds := func(behaves bool, clause, what string) {
t.Helper()
if says := strings.Contains(changelog, clause); behaves != says {
t.Errorf("%s = %v, and %s says %q = %v", what, behaves, changelogPath, clause, says)
}
}
st, set := upgraded(t, nil)
code, body := post(st, set, `{"speed_auto_loc":"48.8566,2.3522","speed_auto_label":"Paris, FR"}`)
holds(code == http.StatusBadRequest, changelogRetiredWrite, "a non-empty retired key is refused (HTTP "+strconv.Itoa(code)+")")
holds(strings.Contains(body, "reload it"), changelogRetiredStaleTab, "the refusal tells a dashboard page loaded before the upgrade to reload")
echo, _ := post(st, set, `{"speed_auto_loc":"49.2827,-123.1207","speed_auto_label":"Vancouver, BC"}`)
holds(echo == http.StatusBadRequest, changelogRetiredEcho, "the city this install already carries is refused too (HTTP "+strconv.Itoa(echo)+")")
blank, _ := post(st, set, `{"speed_auto_loc":"","speed_auto_label":""}`)
holds(blank == http.StatusOK, changelogRetiredBlank, "blank retired keys are accepted (HTTP "+strconv.Itoa(blank)+")")
holds(retiredCityScope(set) != "", changelogRetiredBootLine, "an install carrying the city and running Ookla is told at boot")
// An install whose speedtests run on iperf3 is not told: every run measures
// the server it names, which the city never chose. The stand-in iperf3 is a
// shell script.
if runtime.GOOS != "windows" {
withIperf := t.TempDir()
if err := os.WriteFile(filepath.Join(withIperf, "iperf3"), []byte("#!/bin/sh\nexit 0\n"), 0o755); err != nil {
t.Fatal(err)
}
t.Setenv("PATH", withIperf)
_, onIperf := upgraded(t, map[string]string{"speed_engine": "iperf3"})
holds(retiredCityScope(onIperf) == "", changelogRetiredOoklaOnly, "an install whose speedtests run on iperf3 is left out of the boot line")
}
}
// The package entry makes two promises an operator reads during an upgrade: the
// one line a handover that did not come back prints, quoted so it can be found
// in an apt or dnf log, and which units the upgrade hands over at all. Run the
// real script under the packaging tests' stubs and hold both to what it does.
const (
changelogUpgradeStoppedUnit = "stopped before the upgrade is not restarted and says nothing"
changelogUpgradeDisabledUnit = "disabled but still running is restarted onto the new binary and watched"
)
func TestChangelogSaysWhatAPackageUpgradePrints(t *testing.T) {
changelog := unwrapped(mustReadRepoFile(t, changelogPath))
_, out, _ := runPostinstall(t, postinstallEnv{states: []string{"active", "active", "activating"}, withDebHelper: true}, "configure", "0.70.1")
if line := strings.TrimSpace(out); line == "" || !strings.Contains(changelog, "`"+line+"`") {
t.Errorf("a deb upgrade whose daemon did not come back prints %q, and %s does not quote that line - the only sign there is, and what an operator searches an upgrade log for", line, changelogPath)
}
_, out, _ = runPostinstall(t, postinstallEnv{states: []string{"inactive"}, withDebHelper: true}, "configure", "0.70.1")
if quiet, says := strings.TrimSpace(out) == "", strings.Contains(changelog, changelogUpgradeStoppedUnit); quiet != says {
t.Errorf("an upgrade over a unit stopped beforehand prints nothing = %v (%q), and %s says %q = %v", quiet, out, changelogPath, changelogUpgradeStoppedUnit, says)
}
// Disabled governs boot, not now: the upgrade leaves the unit's links as the
// admin left them, and try-restart still acts on a unit that is running.
_, _, calls := runPostinstall(t, postinstallEnv{states: []string{"active"}, wasEnabledRC: "1", withDebHelper: true}, "configure", "0.70.1")
watched := countCalls(calls, "systemctl try-restart") == 1 && countCalls(calls, "systemctl is-active") > 1
if says := strings.Contains(changelog, changelogUpgradeDisabledUnit); watched != says {
t.Errorf("an upgrade over a disabled unit that is still running hands it over and watches it = %v, and %s says %q = %v:\n%s", watched, changelogPath, changelogUpgradeDisabledUnit, says, strings.Join(calls, "\n"))
}
}
// The entries an operator acts on when a database is damaged, held to the store
// and the server that decide them: that a start on a damaged database stops
// unless the rebuild was asked for, and that a rebuilt store says so on /readyz
// while /healthz stays up.
const (
changelogDamagedStops = "the daemon now stops instead of replacing the file"
changelogRebuildOnlyIf = "this build does that only under `-on-corrupt rebuild`"
changelogRebuiltReadyz = "`/readyz` answers `503` for as long as the start that rebuilt it runs"
changelogRebuiltHealthz = "while `/healthz` stays `200`"
// What ends the hold a rebuilt store keeps on network access: a password set
// from the machine counts only once the daemon restarts.
changelogHoldEndsAtRestart = "set on it from the machine itself and the daemon is restarted or reloaded"
)
func TestChangelogSaysWhatADamagedDatabaseDoesToAStart(t *testing.T) {
ctx := context.Background()
changelog := unwrapped(mustReadRepoFile(t, changelogPath))
holds := func(behaves bool, clause, what string) {
t.Helper()
if says := strings.Contains(changelog, clause); behaves != says {
t.Errorf("%s = %v, and %s says %q = %v", what, behaves, changelogPath, clause, says)
}
}
torn := func(t *testing.T) string {
t.Helper()
path := filepath.Join(t.TempDir(), "pingularity.db")
st, err := store.Open(path)
if err != nil {
t.Fatal(err)
}
if err := st.SetSetting(ctx, "quick_setup_done", "1"); err != nil {
t.Fatal(err)
}
st.Close()
tearTableRoot(t, path, "pauses")
return path
}
path := torn(t)
st, err := store.Open(path)
if err == nil {
st.Close()
}
setAside, _ := filepath.Glob(path + ".*.corrupt")
_, statErr := os.Stat(path)
refused := err != nil && strings.Contains(err.Error(), "is damaged") && len(setAside) == 0 && statErr == nil
holds(refused, changelogDamagedStops, "a start on a damaged database with nobody asking for a rebuild stops and leaves the file where it is")
holds(refused, changelogRebuildOnlyIf, "the damaged database is replaced only when -on-corrupt rebuild is given")
st, err = store.Open(torn(t), store.RebuildOnCorruption())
if err != nil {
t.Fatalf("a start armed with -on-corrupt rebuild refused the damaged database: %v", err)
}
defer st.Close()
set, err := settings.New(ctx, st, defaultSettings(config.Config{}))
if err != nil {
t.Fatalf("settings on the rebuilt store: %v", err)
}
status := func() web.LiveStatus { return web.LiveStatus{Online: true} }
h := web.New(st, status, nil, set, nil, "test", slog.New(slog.DiscardHandler)).Handler()
probe := func(path string) int {
req := httptest.NewRequest("GET", path, nil)
req.RemoteAddr = "127.0.0.1:54321"
req.Host = "127.0.0.1:9000"
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
return rec.Code
}
holds(probe("/readyz") == http.StatusServiceUnavailable, changelogRebuiltReadyz, "a store rebuilt after damage answers /readyz 503")
holds(probe("/healthz") == http.StatusOK, changelogRebuiltHealthz, "a store rebuilt after damage still answers /healthz 200")
if !strings.Contains(changelog, changelogHoldEndsAtRestart) {
t.Errorf("%s no longer says a password set on a rebuilt store ends the hold once the daemon restarts or reloads (%q): an operator who sets one and waits sees the network stay closed", changelogPath, changelogHoldEndsAtRestart)
}
}
// Rolling back is the one step of an upgrade whose failure the new binary never
// sees, so the entry has to carry both warnings itself: take -on-corrupt out of a
// unit before v0.70.1 reads it, and set a password before a held store goes back
// to a release that does not know the hold.
const (
changelogRollbackFlag = "take it out again before rolling back"
changelogRollbackPassword = "Set a password before you roll such a store back"
)
func TestChangelogSaysWhatRollingBackCosts(t *testing.T) {
changelog := unwrapped(mustReadRepoFile(t, changelogPath))
// A release that never defined -on-corrupt answers it the way the flag package
// answers any flag it was not given, and does not start. The entry quotes that
// answer, so the quote is held to the package that writes it.
older := flag.NewFlagSet("run", flag.ContinueOnError)
older.SetOutput(io.Discard)
err := older.Parse([]string{"-on-corrupt", "rebuild"})
if err == nil {
t.Fatal("a flag set without -on-corrupt accepted it")
}
if quote := "`" + err.Error() + "`"; !strings.Contains(changelog, quote) {
t.Errorf("a release without -on-corrupt refuses the flag with %q, and %s does not quote it - the line an operator rolling back finds in the unit's log", err, changelogPath)
}
for _, want := range []string{changelogRollbackFlag, changelogRollbackPassword} {
if !strings.Contains(changelog, want) {
t.Errorf("%s no longer says %q, which is what an operator has to do before rolling back and cannot learn from the release they roll back to", changelogPath, want)
}
}
}
// Two more promises about a -db link, both about damage: the refusal names the
// file the link leads to, so the recovered copy goes back where the link points;
// and the one link to nothing that is not refused is the one a rebuild cut short
// leaves, whose next start puts the finished store in place.
const (
changelogLinkRefusalNames = "what the refusal of a damaged database names"
readmeLinkRefusalNames = "what the refusal above names"
changelogLinkCutShort = "One link to nothing is not refused"
)
func TestChangelogSaysWhatDamageBehindADbLinkDoes(t *testing.T) {
changelog := unwrapped(mustReadRepoFile(t, changelogPath))
readme := unwrapped(mustReadRepoFile(t, "docs/install.md"))
holds := func(behaves bool, doc, name, clause, what string) {
t.Helper()
if says := strings.Contains(doc, clause); behaves != says {
t.Errorf("%s = %v, and %s says %q = %v", what, behaves, name, clause, says)
}
}
dir := t.TempDir()
if err := os.Mkdir(filepath.Join(dir, "moved"), 0o700); err != nil {
t.Fatal(err)
}
far := filepath.Join(dir, "moved", "elsewhere.db")
link := filepath.Join(dir, "pingularity.db")
if err := os.Symlink(far, link); err != nil {
t.Skipf("this filesystem will not make a symlink: %v", err)
}
st, err := store.Open(far)
if err != nil {
t.Fatal(err)
}
st.Close()
tearTableRoot(t, far, "pauses")
_, err = store.Open(link)
namesFar := err != nil && strings.Contains(err.Error(), filepath.Join("moved", "elsewhere.db")) && !strings.Contains(err.Error(), filepath.Base(link))
holds(namesFar, changelog, changelogPath, changelogLinkRefusalNames, "the refusal of a damaged database behind a -db link names the file the link leads to")
holds(namesFar, readme, "docs/install.md", readmeLinkRefusalNames, "the refusal of a damaged database behind a -db link names the file the link leads to")
// A rebuild cut short between its two renames: the file the link names has
// moved, and the store the rebuild finished sits beside where it was.
if err := os.Remove(far); err != nil {
t.Fatal(err)
}
st, err = store.Open(far)
if err != nil {
t.Fatal(err)
}
st.Close()
if err := os.Rename(far, far+".rebuilt"); err != nil {
t.Fatal(err)
}
st, err = store.Open(link)
if err == nil {
st.Close()
}
lfi, lerr := os.Lstat(link)
_, farErr := os.Stat(far)
_, builtErr := os.Lstat(far + ".rebuilt")
finished := err == nil && lerr == nil && lfi.Mode()&os.ModeSymlink != 0 && farErr == nil && os.IsNotExist(builtErr)
holds(finished, changelog, changelogPath, changelogLinkCutShort, "a link to nothing left by a cut-short rebuild is finished by the next start")
holds(finished, readme, "docs/install.md", changelogLinkCutShort, "a link to nothing left by a cut-short rebuild is finished by the next start")
}