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` 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") }