From 2cf3cf1f43f1d9ba34f4b6968c5ffc4c48da489c Mon Sep 17 00:00:00 2001 From: Gary Date: Sat, 10 Oct 2026 23:35:18 +0200 Subject: [PATCH 1/8] feat(os): add ossupport, the strict identity and the dated end-of-support table Debian and Ubuntu LTS only, by an ID that is exactly debian or ubuntu (ID_LIKE is never read). Seven dated entries, each with its sources and the vendor's extended programme as information. Evaluate says supported, ending_soon (180 days), security_ended or unknown, and answers unknown for a release that is not in the table, a codename that does not match, a clock before the table was written and any day 18 months after it. --- pkg/ossupport/ossupport.go | 271 ++++++++++++++++++++ pkg/ossupport/ossupport_test.go | 438 ++++++++++++++++++++++++++++++++ 2 files changed, 709 insertions(+) create mode 100644 pkg/ossupport/ossupport.go create mode 100644 pkg/ossupport/ossupport_test.go diff --git a/pkg/ossupport/ossupport.go b/pkg/ossupport/ossupport.go new file mode 100644 index 00000000..6ef96af4 --- /dev/null +++ b/pkg/ossupport/ossupport.go @@ -0,0 +1,271 @@ +// Package ossupport says whether the operating system the core runs on still receives security +// updates, from a table of dates kept in this file. It only reads and reports: nothing here +// upgrades anything, and nothing here asks the network, apt or the box's own tables (the answer +// is the same on a box with a stale distro-info-data). +// +// Three rules keep it from saying something false: +// - the identity is strict: ID must be exactly "debian" or "ubuntu". ID_LIKE is never looked at, +// so Mint, Pop!_OS, Raspbian, DietPi and the rest are not known, and nothing is claimed; +// - a release that is not in the table (Debian 14, Ubuntu 26.10, any non-LTS Ubuntu, 18.04) is +// "unknown", not guessed; +// - eighteen months after the day the table was last checked (AsOf) every answer is "unknown": a +// table nobody has updated is no longer a source. +package ossupport + +import ( + "time" + "unicode" +) + +// The states of Support.State. +const ( + // Supported: the vendor's free security updates go on for more than 180 days. + Supported = "supported" + // EndingSoon: they end within 180 days, or today. + EndingSoon = "ending_soon" + // SecurityEnded: they ended yesterday or before. + SecurityEnded = "security_ended" + // Unknown: no claim is made. + Unknown = "unknown" +) + +// The precisions of Support.Ends: the sources agree on the day, or only on the month. +const ( + PrecisionDay = "day" + PrecisionMonth = "month" +) + +// StateReport is the State of a Next: it is a report, read-only. Nothing in this package, and +// nothing that reads it yet, upgrades the operating system. +const StateReport = "report" + +const ( + dateLayout = "2006-01-02" + // staleAfterMonths is how long after AsOf the table is still believed. + staleAfterMonths = 18 + // endingSoonDays is how long before the end the state is EndingSoon. + endingSoonDays = 180 +) + +// AsOf is the day the table below was last checked against its sources. A var so that a test can +// move it; nothing else does. +var AsOf = time.Date(2026, 10, 9, 0, 0, 0, 0, time.UTC) + +// Identity is the operating system as os-release says it, validated: a field that is not what it +// should look like is left empty, and absent from the JSON. +type Identity struct { + ID string `json:"id,omitempty"` + VersionID string `json:"version_id,omitempty"` + Codename string `json:"codename,omitempty"` + PrettyName string `json:"pretty_name,omitempty"` + Arch string `json:"arch,omitempty"` + // Known is true for ID "debian" or "ubuntu", exactly, and for nothing else. + Known bool `json:"known"` +} + +// Extended is the vendor's later, separate programme of security maintenance. Only its name and +// its last day are said: what it costs, and for whom, is the vendor's to say. +type Extended struct { + Name string `json:"name"` + Ends string `json:"ends"` +} + +// Next is the release after this one. State is StateReport: it is information only. +type Next struct { + ID string `json:"id"` + VersionID string `json:"version_id"` + Codename string `json:"codename"` + PrettyName string `json:"pretty_name"` + State string `json:"state"` +} + +// Support is how long the vendor's free security updates last for an Identity. +type Support struct { + State string `json:"state"` + // Ends is the last day of the free security updates, "2006-01-02", with Precision "day"; with + // "month" the sources do not agree on the day, and only the month is to be shown. + Ends string `json:"ends,omitempty"` + Precision string `json:"precision,omitempty"` + Extended *Extended `json:"extended,omitempty"` + // Source is the page that says it. + Source string `json:"source,omitempty"` + // Next is not part of the JSON of a Support: a Report carries it beside it. + Next *Next `json:"-"` +} + +// Report is what GET /v1/sys/os answers: who the box is, how long it is supported, and the next +// release when the table has one. +type Report struct { + Identity + Support Support `json:"support"` + Next *Next `json:"next,omitempty"` +} + +// entry is one row of the table. Dates are the day the free security updates end, the vendor's +// extended programme being a later, separate one. sources are the pages the dates come from; +// the first is the one the API gives. +type entry struct { + id, versionID, codename string + ends, precision string + extended *Extended + sources []string + next *Next +} + +// table is the whole of what is claimed, checked against its sources on AsOf. A box that is not +// in it is "unknown". +var table = []entry{ + { + id: "debian", versionID: "11", codename: "bullseye", + ends: "2026-08-31", precision: PrecisionDay, + extended: &Extended{Name: "Debian ELTS (Freexian)", Ends: "2031-06-30"}, + sources: []string{"https://www.debian.org/News/2026/20260831", "https://wiki.debian.org/LTS/Extended"}, + next: &Next{ID: "debian", VersionID: "12", Codename: "bookworm", PrettyName: "Debian 12 (bookworm)", State: StateReport}, + }, + { + // free security by Debian LTS, after the security team's own end (2026-07-11 on the releases + // page, 12 July in the news): LTS covers i386, amd64, armhf, arm64 and ppc64el only + id: "debian", versionID: "12", codename: "bookworm", + ends: "2028-06-30", precision: PrecisionDay, + sources: []string{"https://www.debian.org/News/2026/20260712", "https://wiki.debian.org/LTS"}, + next: &Next{ID: "debian", VersionID: "13", Codename: "trixie", PrettyName: "Debian 13 (trixie)", State: StateReport}, + }, + { + // free security by Debian LTS, after the security team's own end on 2028-08-09 + id: "debian", versionID: "13", codename: "trixie", + ends: "2030-06-30", precision: PrecisionDay, + sources: []string{"https://www.debian.org/releases/"}, + }, + { + // No upgrade path is offered: the installer never supported 20.04. The days of every Ubuntu + // row are distro-info-data's; the release-cycle page gives only months, and for the ESM + // of all four releases it says May where distro-info-data says April (the extended dates + // are shown as information and decide no state). + id: "ubuntu", versionID: "20.04", codename: "focal", + ends: "2025-05-29", precision: PrecisionDay, + extended: &Extended{Name: "Ubuntu Pro (ESM)", Ends: "2030-04-23"}, + sources: []string{"https://ubuntu.com/about/release-cycle", "https://git.launchpad.net/ubuntu/+source/distro-info-data/plain/ubuntu.csv?h=ubuntu/devel"}, + }, + { + // the sources say June 1st (distro-info-data) and "May 2027" (release-cycle page): only the + // month is claimed + id: "ubuntu", versionID: "22.04", codename: "jammy", + ends: "2027-06-01", precision: PrecisionMonth, + extended: &Extended{Name: "Ubuntu Pro (ESM)", Ends: "2032-04-21"}, + sources: []string{"https://ubuntu.com/about/release-cycle", "https://git.launchpad.net/ubuntu/+source/distro-info-data/plain/ubuntu.csv?h=ubuntu/devel"}, + next: &Next{ID: "ubuntu", VersionID: "24.04", Codename: "noble", PrettyName: "Ubuntu 24.04 LTS (noble)", State: StateReport}, + }, + { + id: "ubuntu", versionID: "24.04", codename: "noble", + ends: "2029-05-31", precision: PrecisionDay, + extended: &Extended{Name: "Ubuntu Pro (ESM)", Ends: "2034-04-25"}, + sources: []string{"https://ubuntu.com/about/release-cycle", "https://git.launchpad.net/ubuntu/+source/distro-info-data/plain/ubuntu.csv?h=ubuntu/devel"}, + next: &Next{ID: "ubuntu", VersionID: "26.04", Codename: "resolute", PrettyName: "Ubuntu 26.04 LTS (resolute)", State: StateReport}, + }, + { + id: "ubuntu", versionID: "26.04", codename: "resolute", + ends: "2031-05-29", precision: PrecisionDay, + extended: &Extended{Name: "Ubuntu Pro (ESM)", Ends: "2036-04-23"}, + sources: []string{"https://ubuntu.com/about/release-cycle", "https://git.launchpad.net/ubuntu/+source/distro-info-data/plain/ubuntu.csv?h=ubuntu/devel"}, + }, +} + +// Parse is the identity of the box from its os-release and its architecture. It is strict: only an +// ID of exactly "debian" or "ubuntu" is Known, and ID_LIKE is never looked at. Every field is +// checked for length and characters before it goes anywhere (a log, a page, an alert): one that +// fails is left empty. +func Parse(osRelease map[string]string, arch string) Identity { + id := osRelease["ID"] + return Identity{ + ID: token(id, 32), + VersionID: token(osRelease["VERSION_ID"], 32), + Codename: token(osRelease["VERSION_CODENAME"], 32), + PrettyName: prettyName(osRelease["PRETTY_NAME"]), + Arch: token(arch, 16), + Known: id == "debian" || id == "ubuntu", + } +} + +// token is s when it is 1 to limit characters of a-z, 0-9, ".", "_" and "-", and "" otherwise. +func token(s string, limit int) string { + if s == "" || len(s) > limit { + return "" + } + for _, c := range s { + if !(c >= 'a' && c <= 'z' || c >= '0' && c <= '9' || c == '.' || c == '_' || c == '-') { + return "" + } + } + return s +} + +// prettyName is s when it is 1 to 64 letters, digits, spaces and . , : ( ) / + _ - !, and "" +// otherwise: nothing a page could take for markup, and no control or direction character. +func prettyName(s string) string { + n := 0 + for _, c := range s { + n++ + if !(unicode.IsLetter(c) || unicode.IsDigit(c) || c == ' ' || c == '.' || c == ',' || c == ':' || + c == '(' || c == ')' || c == '/' || c == '+' || c == '_' || c == '-' || c == '!') { + return "" + } + } + if n == 0 || n > 64 { + return "" + } + return s +} + +// dayOf is the calendar day of t in UTC, at midnight. +func dayOf(t time.Time) time.Time { + y, m, d := t.UTC().Date() + return time.Date(y, m, d, 0, 0, 0, 0, time.UTC) +} + +// Evaluate is how long the free security updates of id last, on the day of now (UTC). The last day +// is a day of support: SecurityEnded begins the day after it, and EndingSoon the 180th day before +// it. A box that is not Known, a release the table does not have, a codename that is not the +// release's (a derivative, a testing system), any day more than 18 months after AsOf and any +// day before it are Unknown. +func Evaluate(id Identity, now time.Time) Support { + unknown := Support{State: Unknown} + today := dayOf(now) + // A day before AsOf is a clock that is wrong: the table was written on AsOf, so no box that + // runs this code is earlier than that. + if !id.Known || today.Before(AsOf) || today.After(AsOf.AddDate(0, staleAfterMonths, 0)) { + return unknown + } + for _, e := range table { + if e.id != id.ID || e.versionID != id.VersionID || (id.Codename != "" && id.Codename != e.codename) { + continue + } + end, err := time.Parse(dateLayout, e.ends) + if err != nil { + return unknown + } + support := Support{State: Supported, Ends: e.ends, Precision: e.precision, Source: e.sources[0]} + switch { + case today.After(end): + support.State = SecurityEnded + case end.Sub(today) <= endingSoonDays*24*time.Hour: + support.State = EndingSoon + } + if e.extended != nil { + extended := *e.extended + support.Extended = &extended + } + if e.next != nil { + next := *e.next + support.Next = &next + } + return support + } + return unknown +} + +// Describe is Parse and Evaluate together, as the API answers them. +func Describe(osRelease map[string]string, arch string, now time.Time) Report { + id := Parse(osRelease, arch) + support := Evaluate(id, now) + return Report{Identity: id, Support: support, Next: support.Next} +} diff --git a/pkg/ossupport/ossupport_test.go b/pkg/ossupport/ossupport_test.go new file mode 100644 index 00000000..ba97d4b3 --- /dev/null +++ b/pkg/ossupport/ossupport_test.go @@ -0,0 +1,438 @@ +package ossupport + +import ( + "encoding/json" + "strings" + "testing" + "time" +) + +// day is midnight UTC of a date written "2006-01-02". +func day(t *testing.T, s string) time.Time { + t.Helper() + d, err := time.Parse(dateLayout, s) + if err != nil { + t.Fatal(err) + } + return d +} + +// release is an os-release as the box would give it. +func release(id, versionID, codename, pretty string) map[string]string { + r := map[string]string{"ID": id, "PRETTY_NAME": pretty} + if versionID != "" { + r["VERSION_ID"] = versionID + } + if codename != "" { + r["VERSION_CODENAME"] = codename + } + return r +} + +// asOf moves the day the table was checked for the length of the test. +func asOf(t *testing.T, s string) { + t.Helper() + previous := AsOf + AsOf = day(t, s) + t.Cleanup(func() { AsOf = previous }) +} + +func TestOnlyDebianAndUbuntuAreKnown(t *testing.T) { + for name, r := range map[string]map[string]string{ + // Every look-alike has an ID_LIKE of debian or ubuntu, and the version of the release it is + // built on: none of them is known, and none is told a status. + "Linux Mint": {"ID": "linuxmint", "ID_LIKE": "ubuntu debian", "VERSION_ID": "21.3", "VERSION_CODENAME": "virginia"}, + "Mint on jammy": {"ID": "linuxmint", "ID_LIKE": "ubuntu debian", "VERSION_ID": "22.04", "VERSION_CODENAME": "jammy"}, + "Pop!_OS": {"ID": "pop", "ID_LIKE": "ubuntu debian", "VERSION_ID": "22.04", "VERSION_CODENAME": "jammy"}, + "Zorin": {"ID": "zorin", "ID_LIKE": "ubuntu debian", "VERSION_ID": "17", "VERSION_CODENAME": "jammy"}, + "elementary": {"ID": "elementary", "ID_LIKE": "ubuntu", "VERSION_ID": "7.1"}, + "Raspbian": {"ID": "raspbian", "ID_LIKE": "debian", "VERSION_ID": "11", "VERSION_CODENAME": "bullseye"}, + "DietPi": {"ID": "dietpi", "ID_LIKE": "debian", "VERSION_ID": "12", "VERSION_CODENAME": "bookworm"}, + "OpenMediaVault": {"ID": "openmediavault", "ID_LIKE": "debian", "VERSION_ID": "12", "VERSION_CODENAME": "bookworm"}, + "Proxmox-like": {"ID": "proxmox", "ID_LIKE": "debian", "VERSION_ID": "12", "VERSION_CODENAME": "bookworm"}, + "Kali": {"ID": "kali", "ID_LIKE": "debian", "VERSION_ID": "2024.1"}, + "Devuan": {"ID": "devuan", "ID_LIKE": "debian", "VERSION_ID": "5", "VERSION_CODENAME": "daedalus"}, + "Ubuntu Core": {"ID": "ubuntu-core", "ID_LIKE": "ubuntu", "VERSION_ID": "22"}, + "only an ID_LIKE": {"ID_LIKE": "debian", "VERSION_ID": "12", "VERSION_CODENAME": "bookworm"}, + "no ID at all": {"VERSION_ID": "12"}, + "Debian, loudly": {"ID": "Debian", "VERSION_ID": "12", "VERSION_CODENAME": "bookworm"}, + "DEBIAN": {"ID": "DEBIAN", "VERSION_ID": "12", "VERSION_CODENAME": "bookworm"}, + "debian and more": {"ID": "debian2", "VERSION_ID": "12", "VERSION_CODENAME": "bookworm"}, + "padded": {"ID": " debian", "VERSION_ID": "12", "VERSION_CODENAME": "bookworm"}, + "two IDs": {"ID": "debian ubuntu", "VERSION_ID": "12", "VERSION_CODENAME": "bookworm"}, + "a newline": {"ID": "debian\n", "VERSION_ID": "12", "VERSION_CODENAME": "bookworm"}, + "Ubuntu, loudly": {"ID": "Ubuntu", "VERSION_ID": "22.04", "VERSION_CODENAME": "jammy"}, + "nothing": {}, + } { + t.Run(name, func(t *testing.T) { + id := Parse(r, "amd64") + if id.Known { + t.Fatalf("Parse(%v) is known: %+v", r, id) + } + if got := Describe(r, "amd64", day(t, "2026-10-10")); got.Support.State != Unknown || got.Next != nil || got.Support.Ends != "" || got.Support.Extended != nil { + t.Fatalf("Describe(%v) claims %+v, want nothing", r, got) + } + }) + } + for _, c := range []struct{ id, want string }{{"debian", "debian"}, {"ubuntu", "ubuntu"}} { + if id := Parse(map[string]string{"ID": c.id, "ID_LIKE": "something else"}, "amd64"); !id.Known || id.ID != c.want { + t.Errorf("Parse(ID=%s) = %+v, want it known", c.id, id) + } + } + // ID_LIKE is not looked at, whatever it says and whatever ID is + if id := Parse(map[string]string{"ID": "debian", "ID_LIKE": "fedora"}, "amd64"); !id.Known { + t.Errorf("a Debian whose ID_LIKE is fedora is not known: %+v", id) + } +} + +func TestParseKeepsWhatIsValidAndDropsWhatIsNot(t *testing.T) { + good := Parse(release("debian", "11", "bullseye", "Debian GNU/Linux 11 (bullseye)"), "amd64") + if want := (Identity{ID: "debian", VersionID: "11", Codename: "bullseye", PrettyName: "Debian GNU/Linux 11 (bullseye)", Arch: "amd64", Known: true}); good != want { + t.Fatalf("Parse = %+v, want %+v", good, want) + } + if got := Parse(release("ubuntu", "22.04", "jammy", "Ubuntu 22.04.5 LTS"), "arm64"); got.PrettyName != "Ubuntu 22.04.5 LTS" || got.Arch != "arm64" { + t.Errorf("Parse = %+v", got) + } + + long := strings.Repeat("a", 33) + for name, c := range map[string]struct { + field string + value string + }{ + "a version id with a space": {"VERSION_ID", "11 "}, + "a version id with markup": {"VERSION_ID", "11"}, + "a version id with a newline": {"VERSION_ID", "11\n12"}, + "a version id that is too long": {"VERSION_ID", long}, + "a version id in capitals": {"VERSION_ID", "RC1"}, + "a codename with a quote": {"VERSION_CODENAME", `bull"seye`}, + "a codename with a slash": {"VERSION_CODENAME", "../etc"}, + "a codename that is too long": {"VERSION_CODENAME", long}, + "a pretty name with markup": {"PRETTY_NAME", "Debian "}, + "a pretty name with a quote": {"PRETTY_NAME", `Debian "11"`}, + "a pretty name with an ampersand": {"PRETTY_NAME", "Debian & co"}, + "a pretty name with a newline": {"PRETTY_NAME", "Debian\n11"}, + "a pretty name with an escape": {"PRETTY_NAME", "Debian \x1b[31m11"}, + "a pretty name that reads backwards": {"PRETTY_NAME", "Debian \u202e11"}, + "a pretty name that is too long": {"PRETTY_NAME", strings.Repeat("D", 65)}, + } { + t.Run(name, func(t *testing.T) { + r := release("debian", "11", "bullseye", "Debian GNU/Linux 11 (bullseye)") + r[c.field] = c.value + id := Parse(r, "amd64") + var got string + switch c.field { + case "VERSION_ID": + got = id.VersionID + case "VERSION_CODENAME": + got = id.Codename + case "PRETTY_NAME": + got = id.PrettyName + } + if got != "" { + t.Fatalf("%s %q was kept as %q", c.field, c.value, got) + } + if !id.Known || id.ID != "debian" || id.Arch != "amd64" { + t.Fatalf("the rest of the identity changed: %+v", id) + } + }) + } + // every character a page or a shell could take for something else is enough, alone, to drop a pretty name + for _, c := range "<>&\"'`\\;=${}[]|*?#@%^~\t\x00 ​" { + if got := Parse(release("debian", "11", "bullseye", "Debian "+string(c)+" 11"), "amd64"); got.PrettyName != "" { + t.Errorf("a pretty name with %q was kept: %q", c, got.PrettyName) + } + } + if got := Parse(release("debian", "11", "bullseye", ""), "amd64 \n"); got.Arch != "" { + t.Errorf("an architecture with a newline was kept: %q", got.Arch) + } + if got := Parse(release("debian", "11", "bullseye", strings.Repeat("D", 64)), ""); got.PrettyName == "" || got.Arch != "" { + t.Errorf("a pretty name of 64 characters was dropped, or an empty architecture kept: %+v", got) + } + if got := Parse(release(strings.Repeat("d", 33), "11", "bullseye", ""), "amd64"); got.ID != "" || got.Known { + t.Errorf("an ID that is too long was kept: %+v", got) + } + if got := Parse(nil, "amd64"); got.Known || got.ID != "" { + t.Errorf("Parse(nil) = %+v", got) + } +} + +// The table is exactly what the spec says, and nothing else: the dates are the claims. +func TestTheTableHoldsExactlyTheseEntries(t *testing.T) { + type row struct { + id, version, codename, ends, precision string + extended *Extended + next string // "id version", "" for none + source string + } + want := []row{ + {"debian", "11", "bullseye", "2026-08-31", "day", &Extended{"Debian ELTS (Freexian)", "2031-06-30"}, "debian 12", "https://www.debian.org/News/2026/20260831"}, + {"debian", "12", "bookworm", "2028-06-30", "day", nil, "debian 13", "https://www.debian.org/News/2026/20260712"}, + {"debian", "13", "trixie", "2030-06-30", "day", nil, "", "https://www.debian.org/releases/"}, + {"ubuntu", "20.04", "focal", "2025-05-29", "day", &Extended{"Ubuntu Pro (ESM)", "2030-04-23"}, "", "https://ubuntu.com/about/release-cycle"}, + {"ubuntu", "22.04", "jammy", "2027-06-01", "month", &Extended{"Ubuntu Pro (ESM)", "2032-04-21"}, "ubuntu 24.04", "https://ubuntu.com/about/release-cycle"}, + {"ubuntu", "24.04", "noble", "2029-05-31", "day", &Extended{"Ubuntu Pro (ESM)", "2034-04-25"}, "ubuntu 26.04", "https://ubuntu.com/about/release-cycle"}, + {"ubuntu", "26.04", "resolute", "2031-05-29", "day", &Extended{"Ubuntu Pro (ESM)", "2036-04-23"}, "", "https://ubuntu.com/about/release-cycle"}, + } + if len(table) != len(want) { + t.Fatalf("the table has %d entries, want %d", len(table), len(want)) + } + for i, w := range want { + e := table[i] + if e.id != w.id || e.versionID != w.version || e.codename != w.codename || e.ends != w.ends || e.precision != w.precision { + t.Errorf("entry %d is %s %s %s ends %s (%s), want %s %s %s ends %s (%s)", i, e.id, e.versionID, e.codename, e.ends, e.precision, w.id, w.version, w.codename, w.ends, w.precision) + } + if (e.extended == nil) != (w.extended == nil) || (w.extended != nil && *e.extended != *w.extended) { + t.Errorf("%s %s: extended = %+v, want %+v", w.id, w.version, e.extended, w.extended) + } + next := "" + if e.next != nil { + next = e.next.ID + " " + e.next.VersionID + if e.next.State != StateReport || e.next.PrettyName == "" || e.next.Codename == "" { + t.Errorf("%s %s: next = %+v", w.id, w.version, e.next) + } + } + if next != w.next { + t.Errorf("%s %s: next = %q, want %q", w.id, w.version, next, w.next) + } + if len(e.sources) == 0 || e.sources[0] != w.source { + t.Errorf("%s %s: sources = %v, want the first to be %s", w.id, w.version, e.sources, w.source) + } + for _, s := range e.sources { + if !strings.HasPrefix(s, "https://") { + t.Errorf("%s %s: source %q is not https", w.id, w.version, s) + } + } + // the dates are dates, and what is extended starts after what it extends + end := day(t, e.ends) + if e.extended != nil && !day(t, e.extended.Ends).After(end) { + t.Errorf("%s %s: extended ends %s, not after %s", w.id, w.version, e.extended.Ends, e.ends) + } + // a next is the next release of the table, or nothing + if e.next != nil { + found := false + for _, other := range table { + if other.id == e.next.ID && other.versionID == e.next.VersionID && other.codename == e.next.Codename { + found = true + if !day(t, other.ends).After(end) { + t.Errorf("%s %s: its next ends before it", w.id, w.version) + } + } + } + if !found { + t.Errorf("%s %s: next %+v is not in the table", w.id, w.version, e.next) + } + } + } + if got := AsOf.Format(dateLayout); got != "2026-10-09" { + t.Errorf("AsOf = %s, want 2026-10-09", got) + } +} + +// Around every end date: 181 days before, 180, the day before, the day, the day after, with the +// table made fresh for the test (the staleness has its own). +func TestEveryEntryChangesStateOnTheDaysThatMatter(t *testing.T) { + for _, e := range table { + t.Run(e.id+" "+e.versionID, func(t *testing.T) { + end := day(t, e.ends) + asOf(t, "2020-01-01") // restored after the test; each case below sets its own + for _, c := range []struct { + name string + now time.Time + want string + }{ + {"181 days before", end.AddDate(0, 0, -181), Supported}, + {"180 days before", end.AddDate(0, 0, -180), EndingSoon}, + {"179 days before", end.AddDate(0, 0, -179), EndingSoon}, + {"the day before", end.AddDate(0, 0, -1), EndingSoon}, + {"the day, at its first second", end, EndingSoon}, + {"the day, at its last second", end.Add(24*time.Hour - time.Second), EndingSoon}, + {"the day after, at its first second", end.AddDate(0, 0, 1), SecurityEnded}, + {"a year after", end.AddDate(1, 0, 0), SecurityEnded}, + } { + // a table checked a month before the day it is asked about: the staleness stays out of it + AsOf = c.now.AddDate(0, -1, 0) + id := Parse(release(e.id, e.versionID, e.codename, ""), "amd64") + if got := Evaluate(id, c.now); got.State != c.want { + t.Errorf("%s (%s): state = %s, want %s", c.name, c.now.Format(dateLayout), got.State, c.want) + } + } + }) + } +} + +// The day is the UTC day: a box in the Pacific that is still on the 31st is on the 1st for the table. +func TestTheDayIsTheUTCDay(t *testing.T) { + asOf(t, "2026-08-01") + id := Parse(release("debian", "11", "bullseye", ""), "amd64") + utc := time.Date(2026, 8, 31, 23, 59, 59, 0, time.UTC) + pacific := time.FixedZone("UTC-8", -8*3600) + if got := Evaluate(id, utc.In(pacific)).State; got != EndingSoon { + t.Errorf("the last second of 2026-08-31 UTC, written in UTC-8: %s", got) + } + if got := Evaluate(id, utc.Add(time.Second).In(pacific)).State; got != SecurityEnded { + t.Errorf("the first second of 2026-09-01 UTC, written in UTC-8 (still the 31st there): %s", got) + } +} + +// The answers of the real table, today and around the owner's boxes. +func TestTheAnswersOfTheTableOn20261010(t *testing.T) { + now := day(t, "2026-10-10") + for _, c := range []struct { + id, version, codename string + want string + ends, precision string + }{ + {"debian", "11", "bullseye", SecurityEnded, "2026-08-31", "day"}, + {"debian", "12", "bookworm", Supported, "2028-06-30", "day"}, + {"debian", "13", "trixie", Supported, "2030-06-30", "day"}, + {"ubuntu", "20.04", "focal", SecurityEnded, "2025-05-29", "day"}, + {"ubuntu", "22.04", "jammy", Supported, "2027-06-01", "month"}, + {"ubuntu", "24.04", "noble", Supported, "2029-05-31", "day"}, + {"ubuntu", "26.04", "resolute", Supported, "2031-05-29", "day"}, + // a codename left out is not a reason to doubt the version + {"debian", "11", "", SecurityEnded, "2026-08-31", "day"}, + } { + got := Evaluate(Parse(release(c.id, c.version, c.codename, ""), "amd64"), now) + if got.State != c.want || got.Ends != c.ends || got.Precision != c.precision || got.Source == "" { + t.Errorf("%s %s: %+v, want %s ending %s (%s)", c.id, c.version, got, c.want, c.ends, c.precision) + } + } + // Ubuntu 22.04 turns to "ending soon" 180 days before 2027-06-01 + jammy := Parse(release("ubuntu", "22.04", "jammy", ""), "amd64") + if got := Evaluate(jammy, day(t, "2026-12-02")).State; got != Supported { + t.Errorf("22.04 on 2026-12-02: %s", got) + } + if got := Evaluate(jammy, day(t, "2026-12-03")).State; got != EndingSoon { + t.Errorf("22.04 on 2026-12-03: %s", got) + } +} + +// What is not in the table is not claimed. +func TestWhatIsNotInTheTableIsUnknown(t *testing.T) { + now := day(t, "2026-10-10") + for name, r := range map[string]map[string]string{ + "Debian 14": release("debian", "14", "forky", ""), + "Debian 10": release("debian", "10", "buster", ""), + "Debian testing": release("debian", "", "", "Debian GNU/Linux trixie/sid"), + "Ubuntu 26.10": release("ubuntu", "26.10", "stonking", ""), + "Ubuntu 25.10": release("ubuntu", "25.10", "questing", ""), + "Ubuntu 18.04": release("ubuntu", "18.04", "bionic", ""), + "Ubuntu 24.04.1": release("ubuntu", "24.04.1", "noble", ""), + "Ubuntu 22.04 as noble": release("ubuntu", "22.04", "noble", ""), + "Debian 11 as trixie": release("debian", "11", "trixie", ""), + "Debian 12 as 11": release("debian", "012", "bookworm", ""), + } { + got := Describe(r, "amd64", now) + if got.Support.State != Unknown || got.Next != nil || got.Support.Ends != "" || got.Support.Source != "" { + t.Errorf("%s: %+v, want unknown and nothing else", name, got.Support) + } + } + // a Debian that is known is still known, and says so + if got := Describe(release("debian", "14", "forky", ""), "amd64", now); !got.Known { + t.Errorf("Debian 14 is Debian: %+v", got) + } +} + +// 18 months after the day the table was checked, it is "unknown" rather than possibly false. +func TestTheTableGoesStaleAfterEighteenMonths(t *testing.T) { + bullseye := Parse(release("debian", "11", "bullseye", ""), "amd64") + bookworm := Parse(release("debian", "12", "bookworm", ""), "amd64") + for _, c := range []struct { + name string + now time.Time + want string // for bullseye + }{ + {"the day it was checked", day(t, "2026-10-09"), SecurityEnded}, + {"a year after", day(t, "2027-10-09"), SecurityEnded}, + {"18 months after, at its first second", day(t, "2028-04-09"), SecurityEnded}, + {"18 months after, at its last second", day(t, "2028-04-09").Add(24*time.Hour - time.Second), SecurityEnded}, + {"18 months and a day after", day(t, "2028-04-10"), Unknown}, + {"two years after", day(t, "2028-10-09"), Unknown}, + {"far after", day(t, "2040-01-01"), Unknown}, + {"the day before it was checked: a clock that is wrong", day(t, "2026-10-08").Add(23 * time.Hour), Unknown}, + {"the epoch", time.Unix(0, 0), Unknown}, + } { + if got := Evaluate(bullseye, c.now); got.State != c.want { + t.Errorf("%s: bullseye is %s, want %s", c.name, got.State, c.want) + } + if c.want == Unknown { + if got := Evaluate(bookworm, c.now); got.State != Unknown || got.Ends != "" || got.Next != nil || got.Extended != nil || got.Source != "" { + t.Errorf("%s: bookworm is %+v, want unknown and nothing else", c.name, got) + } + } + } + // the date it goes stale on moves with AsOf: it is a rule of the table, not a date of the code + asOf(t, "2026-12-30") + if got := Evaluate(bullseye, day(t, "2028-06-30")); got.State != SecurityEnded { + t.Errorf("with the table checked on 2026-12-30, 2028-06-30 is %s", got.State) + } + if got := Evaluate(bullseye, day(t, "2028-07-01")); got.State != Unknown { + t.Errorf("with the table checked on 2026-12-30, 2028-07-01 is %s", got.State) + } +} + +func TestAnAnswerIsACopyOfTheTable(t *testing.T) { + id := Parse(release("debian", "11", "bullseye", ""), "amd64") + first := Evaluate(id, day(t, "2026-10-10")) + first.Extended.Name, first.Extended.Ends = "changed", "1999-01-01" + first.Next.PrettyName = "changed" + second := Evaluate(id, day(t, "2026-10-10")) + if second.Extended.Name != "Debian ELTS (Freexian)" || second.Extended.Ends != "2031-06-30" || second.Next.PrettyName != "Debian 12 (bookworm)" { + t.Fatalf("a caller changed the table: %+v %+v", second.Extended, second.Next) + } +} + +// Exact JSON, for three boxes: the owner's Debian 11, an Ubuntu that is ending in month precision, +// and a Mint, which is not known. +func TestTheReportsAsJSON(t *testing.T) { + for _, c := range []struct { + name string + release map[string]string + arch string + now string + want string + }{ + { + "the owner's Debian 11", + map[string]string{"ID": "debian", "VERSION_ID": "11", "VERSION_CODENAME": "bullseye", "PRETTY_NAME": "Debian GNU/Linux 11 (bullseye)"}, + "amd64", "2026-10-10", + `{"id":"debian","version_id":"11","codename":"bullseye","pretty_name":"Debian GNU/Linux 11 (bullseye)","arch":"amd64","known":true,` + + `"support":{"state":"security_ended","ends":"2026-08-31","precision":"day","extended":{"name":"Debian ELTS (Freexian)","ends":"2031-06-30"},"source":"https://www.debian.org/News/2026/20260831"},` + + `"next":{"id":"debian","version_id":"12","codename":"bookworm","pretty_name":"Debian 12 (bookworm)","state":"report"}}`, + }, + { + "an Ubuntu 22.04 that ends soon, in month precision", + map[string]string{"ID": "ubuntu", "ID_LIKE": "debian", "VERSION_ID": "22.04", "VERSION_CODENAME": "jammy", "PRETTY_NAME": "Ubuntu 22.04.5 LTS"}, + "amd64", "2027-01-15", + `{"id":"ubuntu","version_id":"22.04","codename":"jammy","pretty_name":"Ubuntu 22.04.5 LTS","arch":"amd64","known":true,` + + `"support":{"state":"ending_soon","ends":"2027-06-01","precision":"month","extended":{"name":"Ubuntu Pro (ESM)","ends":"2032-04-21"},"source":"https://ubuntu.com/about/release-cycle"},` + + `"next":{"id":"ubuntu","version_id":"24.04","codename":"noble","pretty_name":"Ubuntu 24.04 LTS (noble)","state":"report"}}`, + }, + { + "a Linux Mint", + map[string]string{"ID": "linuxmint", "ID_LIKE": "ubuntu debian", "VERSION_ID": "21.3", "VERSION_CODENAME": "virginia", "PRETTY_NAME": "Linux Mint 21.3"}, + "amd64", "2026-10-10", + `{"id":"linuxmint","version_id":"21.3","codename":"virginia","pretty_name":"Linux Mint 21.3","arch":"amd64","known":false,"support":{"state":"unknown"}}`, + }, + { + "a Debian 13, supported, with no next release", + map[string]string{"ID": "debian", "VERSION_ID": "13", "VERSION_CODENAME": "trixie", "PRETTY_NAME": "Debian GNU/Linux 13 (trixie)"}, + "arm64", "2026-10-10", + `{"id":"debian","version_id":"13","codename":"trixie","pretty_name":"Debian GNU/Linux 13 (trixie)","arch":"arm64","known":true,` + + `"support":{"state":"supported","ends":"2030-06-30","precision":"day","source":"https://www.debian.org/releases/"}}`, + }, + { + "nothing readable", + nil, "amd64", "2026-10-10", + `{"arch":"amd64","known":false,"support":{"state":"unknown"}}`, + }, + } { + got, err := json.Marshal(Describe(c.release, c.arch, day(t, c.now))) + if err != nil { + t.Fatal(err) + } + if string(got) != c.want { + t.Errorf("%s:\n got %s\nwant %s", c.name, got, c.want) + } + } +} From 4f746da13efb9c8f5463cfc9a0353ef14c6b40bd Mon Sep 17 00:00:00 2001 From: Gary Date: Sat, 10 Oct 2026 23:35:18 +0200 Subject: [PATCH 2/8] feat(os): add GET /v1/sys/os, the operating system and its security support Read-only: /etc/os-release and the architecture, no apt, no network, no lock. Behind the token with the other /v1/sys routes. --- route/v1.go | 2 + route/v1/system.go | 14 ++++++ route/v1/system_os_test.go | 96 ++++++++++++++++++++++++++++++++++++++ route/v1_test.go | 25 ++++++++++ service/system.go | 3 ++ service/system_os.go | 25 ++++++++++ service/system_os_test.go | 61 ++++++++++++++++++++++++ 7 files changed, 226 insertions(+) create mode 100644 route/v1/system_os_test.go create mode 100644 service/system_os.go create mode 100644 service/system_os_test.go diff --git a/route/v1.go b/route/v1.go index 896832e5..4dbc8c66 100644 --- a/route/v1.go +++ b/route/v1.go @@ -107,6 +107,8 @@ func InitV1Router() http.Handler { // It sat outside the group, readable by anyone on the network. v1SysGroup.GET("/debug", v1.GetSystemConfigDebug) v1SysGroup.GET("/version", v1.GetSystemCheckVersion) // version/check + // The operating system and how long its security updates last: it only reads. + v1SysGroup.GET("/os", v1.GetSystemOS) v1SysGroup.POST("/update", v1.SystemUpdate) v1SysGroup.GET("/packages", v1.GetSystemPackageUpdates) diff --git a/route/v1/system.go b/route/v1/system.go index 061d56a0..220b061d 100644 --- a/route/v1/system.go +++ b/route/v1/system.go @@ -243,6 +243,20 @@ func GetDockerUpdateStatus(ctx echo.Context) error { }) } +// @Summary the operating system, and how long its security updates last +// @Produce application/json +// @Tags sys +// @Security ApiKeyAuth +// @Success 200 {object} model.Result +// @Router /sys/os [get] +func GetSystemOS(ctx echo.Context) error { + return ctx.JSON(http.StatusOK, model.Result{ + Success: common_err.SUCCESS, + Message: common_err.GetMsg(common_err.SUCCESS), + Data: service.MyService.System().GetSystemOS(), + }) +} + // @Summary get logs // @Produce application/json // @Accept application/json diff --git a/route/v1/system_os_test.go b/route/v1/system_os_test.go new file mode 100644 index 00000000..0081fcb6 --- /dev/null +++ b/route/v1/system_os_test.go @@ -0,0 +1,96 @@ +package v1 + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/ReCasaOS/CasaOS/pkg/ossupport" + "github.com/ReCasaOS/CasaOS/service" + "github.com/labstack/echo/v4" +) + +// fakeOSService is a box with the os-release and the clock of the test, the rest of the service being +// the real answer of ossupport. +type fakeOSService struct { + service.SystemService + release map[string]string + arch string + now time.Time +} + +func (f *fakeOSService) GetSystemOS() ossupport.Report { + return ossupport.Describe(f.release, f.arch, f.now) +} + +// The envelope is the other handlers', and the data is exactly what the spec shows, for three boxes: +// the owner's Debian 11, an Ubuntu that is ending (to the month), and a Mint, which is not known. +func TestGetSystemOSAnswersWithTheEnvelopeAndTheExactData(t *testing.T) { + original := service.MyService + t.Cleanup(func() { service.MyService = original }) + + for _, tc := range []struct { + name string + box *fakeOSService + want string + }{ + { + "Debian 11", + &fakeOSService{ + release: map[string]string{"ID": "debian", "VERSION_ID": "11", "VERSION_CODENAME": "bullseye", "PRETTY_NAME": "Debian GNU/Linux 11 (bullseye)"}, + arch: "amd64", now: time.Date(2026, 10, 10, 9, 30, 0, 0, time.UTC), + }, + `{"id":"debian","version_id":"11","codename":"bullseye","pretty_name":"Debian GNU/Linux 11 (bullseye)","arch":"amd64","known":true,` + + `"support":{"state":"security_ended","ends":"2026-08-31","precision":"day","extended":{"name":"Debian ELTS (Freexian)","ends":"2031-06-30"},"source":"https://www.debian.org/News/2026/20260831"},` + + `"next":{"id":"debian","version_id":"12","codename":"bookworm","pretty_name":"Debian 12 (bookworm)","state":"report"}}`, + }, + { + "Ubuntu 22.04, ending", + &fakeOSService{ + release: map[string]string{"ID": "ubuntu", "ID_LIKE": "debian", "VERSION_ID": "22.04", "VERSION_CODENAME": "jammy", "PRETTY_NAME": "Ubuntu 22.04.5 LTS"}, + arch: "amd64", now: time.Date(2027, 1, 15, 9, 30, 0, 0, time.UTC), + }, + `{"id":"ubuntu","version_id":"22.04","codename":"jammy","pretty_name":"Ubuntu 22.04.5 LTS","arch":"amd64","known":true,` + + `"support":{"state":"ending_soon","ends":"2027-06-01","precision":"month","extended":{"name":"Ubuntu Pro (ESM)","ends":"2032-04-21"},"source":"https://ubuntu.com/about/release-cycle"},` + + `"next":{"id":"ubuntu","version_id":"24.04","codename":"noble","pretty_name":"Ubuntu 24.04 LTS (noble)","state":"report"}}`, + }, + { + "a Linux Mint", + &fakeOSService{ + release: map[string]string{"ID": "linuxmint", "ID_LIKE": "ubuntu debian", "VERSION_ID": "21.3", "VERSION_CODENAME": "virginia", "PRETTY_NAME": "Linux Mint 21.3"}, + arch: "amd64", now: time.Date(2026, 10, 10, 9, 30, 0, 0, time.UTC), + }, + `{"id":"linuxmint","version_id":"21.3","codename":"virginia","pretty_name":"Linux Mint 21.3","arch":"amd64","known":false,"support":{"state":"unknown"}}`, + }, + } { + t.Run(tc.name, func(t *testing.T) { + service.MyService = fakeSystemPackageRepository{system: tc.box} + recorder := httptest.NewRecorder() + request := httptest.NewRequest(http.MethodGet, "/v1/sys/os", nil) + + if err := GetSystemOS(echo.New().NewContext(request, recorder)); err != nil { + t.Fatalf("GetSystemOS() error = %v", err) + } + + if recorder.Code != http.StatusOK { + t.Fatalf("status = %d", recorder.Code) + } + var envelope struct { + Success int `json:"success"` + Message string `json:"message"` + Data json.RawMessage `json:"data"` + } + if err := json.Unmarshal(recorder.Body.Bytes(), &envelope); err != nil { + t.Fatalf("decode %q: %v", recorder.Body.String(), err) + } + if envelope.Success != http.StatusOK || envelope.Message != "ok" { + t.Fatalf("envelope = %d %q, want 200 \"ok\"", envelope.Success, envelope.Message) + } + if string(envelope.Data) != tc.want { + t.Fatalf("data:\n got %s\nwant %s", envelope.Data, tc.want) + } + }) + } +} diff --git a/route/v1_test.go b/route/v1_test.go index dff43ed0..718d5f10 100644 --- a/route/v1_test.go +++ b/route/v1_test.go @@ -83,6 +83,31 @@ func TestSkipAccessLog(t *testing.T) { } } +func TestTheOSRouteIsASysRouteBehindTheToken(t *testing.T) { + e, ok := InitV1Router().(*echo.Echo) + if !ok { + t.Fatal("InitV1Router() is not an *echo.Echo") + } + registered := false + for _, r := range e.Routes() { + registered = registered || r.Method+" "+r.Path == "GET /v1/sys/os" + } + if !registered { + t.Error("GET /v1/sys/os is not registered") + } + recorder := httptest.NewRecorder() + e.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/v1/sys/os", nil)) + if recorder.Code != http.StatusUnauthorized { + t.Errorf("GET /v1/sys/os without a token = %d, want 401", recorder.Code) + } + // the route only reads: nothing else answers on the path + recorder = httptest.NewRecorder() + e.ServeHTTP(recorder, httptest.NewRequest(http.MethodPost, "/v1/sys/os", nil)) + if recorder.Code == http.StatusOK { + t.Errorf("POST /v1/sys/os = %d", recorder.Code) + } +} + func TestTheTelemetryAutoUpdateAndAlertsRoutesAreSysRoutesBehindTheToken(t *testing.T) { e, ok := InitV1Router().(*echo.Echo) if !ok { diff --git a/service/system.go b/service/system.go index cff6fc16..10098cab 100644 --- a/service/system.go +++ b/service/system.go @@ -23,6 +23,7 @@ import ( "github.com/ReCasaOS/CasaOS/common" "github.com/ReCasaOS/CasaOS/model" "github.com/ReCasaOS/CasaOS/pkg/config" + "github.com/ReCasaOS/CasaOS/pkg/ossupport" "github.com/ReCasaOS/CasaOS/pkg/utils/common_err" "github.com/ReCasaOS/CasaOS/pkg/utils/httper" "github.com/ReCasaOS/CasaOS/pkg/utils/ip_helper" @@ -62,6 +63,8 @@ type SystemService interface { // OnDockerUpdateQueued sets what is called when a run of the Docker update has its log: it // must return at once. OnDockerUpdateQueued(queued func()) + // GetSystemOS is the operating system and how long its security updates last: it only reads. + GetSystemOS() ossupport.Report GetSystemConfigDebug() []string GetCasaOSLogs(lineNumber int) string UpdateAssist() diff --git a/service/system_os.go b/service/system_os.go new file mode 100644 index 00000000..205e98e5 --- /dev/null +++ b/service/system_os.go @@ -0,0 +1,25 @@ +package service + +import ( + "runtime" + "time" + + commonfile "github.com/ReCasaOS/CasaOS-Common/utils/file" + "github.com/ReCasaOS/CasaOS/pkg/ossupport" +) + +// GetSystemOS is the operating system the box runs and how long its security updates last. It reads +// /etc/os-release and nothing else: no apt, no network, no lock. +func (s *systemService) GetSystemOS() ossupport.Report { + return systemOS(commonfile.ReadOSRelease, runtime.GOARCH, time.Now()) +} + +// systemOS is GetSystemOS with what it reads and what day it is given. A file that cannot be read +// is an operating system that is not known: the answer says so, and is not an error. +func systemOS(readOSRelease func() (map[string]string, error), arch string, now time.Time) ossupport.Report { + release, err := readOSRelease() + if err != nil { + release = nil + } + return ossupport.Describe(release, arch, now) +} diff --git a/service/system_os_test.go b/service/system_os_test.go new file mode 100644 index 00000000..45364860 --- /dev/null +++ b/service/system_os_test.go @@ -0,0 +1,61 @@ +package service + +import ( + "encoding/json" + "errors" + "runtime" + "strings" + "testing" + "time" + + "github.com/ReCasaOS/CasaOS/pkg/ossupport" +) + +func TestSystemOSReadsTheReleaseAndTheDay(t *testing.T) { + now := time.Date(2026, 10, 10, 12, 0, 0, 0, time.UTC) + read := func() (map[string]string, error) { + return map[string]string{"ID": "debian", "VERSION_ID": "11", "VERSION_CODENAME": "bullseye", "PRETTY_NAME": "Debian GNU/Linux 11 (bullseye)"}, nil + } + + got := systemOS(read, "amd64", now) + + if !got.Known || got.ID != "debian" || got.VersionID != "11" || got.Arch != "amd64" || got.Support.State != ossupport.SecurityEnded || got.Next == nil || got.Next.VersionID != "12" { + t.Fatalf("systemOS = %+v", got) + } + // the day is the one it was given, and a clock from before the table was written is not believed + if got := systemOS(read, "amd64", time.Date(2026, 8, 30, 0, 0, 0, 0, time.UTC)); got.Support.State != ossupport.Unknown { + t.Errorf("before the table was written: %s", got.Support.State) + } +} + +func TestSystemOSOfAFileThatCannotBeReadIsNotKnown(t *testing.T) { + read := func() (map[string]string, error) { + // a reader that fails may still hand back a half-read map: it is not to be believed + return map[string]string{"ID": "debian", "VERSION_ID": "11"}, errors.New("read /etc/os-release: permission denied") + } + + got := systemOS(read, "amd64", time.Date(2026, 10, 10, 0, 0, 0, 0, time.UTC)) + + body, err := json.Marshal(got) + if err != nil { + t.Fatal(err) + } + if want := `{"arch":"amd64","known":false,"support":{"state":"unknown"}}`; string(body) != want { + t.Fatalf("systemOS = %s, want %s", body, want) + } +} + +// The method itself reads the real file of the machine the test runs on: whatever it is, the answer +// is whole, and the architecture is Go's. +func TestGetSystemOSAnswersForThisMachine(t *testing.T) { + got := (&systemService{}).GetSystemOS() + if got.Arch != runtime.GOARCH { + t.Errorf("arch = %q, want %q", got.Arch, runtime.GOARCH) + } + if got.Support.State == "" { + t.Errorf("no state: %+v", got) + } + if got.Known && !strings.Contains("debian ubuntu", got.ID) { + t.Errorf("known with an ID of %q", got.ID) + } +} From 4f83097a11acf3858f34167cc55767327518c52d Mon Sep 17 00:00:00 2001 From: Gary Date: Sat, 10 Oct 2026 23:35:19 +0200 Subject: [PATCH 3/8] feat(alerts): tell once when the operating system's security updates end One message per release and state (ending_soon, security_ended), looked at five minutes after the start and every day. The state is written to os-support.notified, atomically, before the message is raised: a restart can lose a message and never repeats one. --- main.go | 4 + pkg/alerts/alerts.go | 20 +- pkg/alerts/os_support.go | 113 ++++++++ pkg/alerts/os_support_test.go | 521 ++++++++++++++++++++++++++++++++++ 4 files changed, 655 insertions(+), 3 deletions(-) create mode 100644 pkg/alerts/os_support.go create mode 100644 pkg/alerts/os_support_test.go diff --git a/main.go b/main.go index 5baae1e1..88503a70 100644 --- a/main.go +++ b/main.go @@ -134,6 +134,10 @@ func main() { alerts.Default.Muted = service.MyService.System().DockerUpdateMuted alerts.Default.LastDockerRun = lastDockerRun service.MyService.System().OnDockerUpdateQueued(alerts.Default.WatchDockerUpdate) + // An operating system whose security updates are about to end, or have ended, is told once for + // each state: the alerts look at what the dashboard's badge reads, five minutes after the start + // and every day. + alerts.Default.OSSupport = service.MyService.System().GetSystemOS go alerts.Default.Run(context.Background()) // Automatic updates start the button's own update, read the button's own // version.json, and settle an update that restarted the core before checking. diff --git a/pkg/alerts/alerts.go b/pkg/alerts/alerts.go index f606be97..090d84bd 100644 --- a/pkg/alerts/alerts.go +++ b/pkg/alerts/alerts.go @@ -17,6 +17,10 @@ // raise and resolve alike. And when it ends the owner is told once, whether or // not the dashboard is open, even across a restart of the core: the nonce of // the last run told is kept in docker-update.notified. +// +// And when the operating system's security updates are about to end, or have ended, +// the owner is told once for each state of each release, from a look every day: the +// last one told is kept in os-support.notified. package alerts import ( @@ -32,6 +36,7 @@ import ( "github.com/ReCasaOS/CasaOS-Common/utils/logger" "github.com/ReCasaOS/CasaOS/pkg/config" + "github.com/ReCasaOS/CasaOS/pkg/ossupport" "github.com/ReCasaOS/CasaOS/pkg/utils/version" "github.com/nicholas-fedor/shoutrrr/pkg/router" "github.com/nicholas-fedor/shoutrrr/pkg/types" @@ -99,6 +104,9 @@ type Hub struct { Muted func() bool // LastDockerRun is the last run of the Docker update, from the run's log and systemd. LastDockerRun func() DockerRun + // OSSupport is the operating system and how long its security updates last, as the service says + // it: only the last two states that end it are told. + OSSupport func() ossupport.Report modifying sync.Mutex // serialises Update's read-modify-writes of alerts.json mu sync.Mutex // guards sent and lastFailure @@ -108,6 +116,9 @@ type Hub struct { dockerMu sync.Mutex // serialises dockerEnded dockerNotified string // the nonce of the last Docker update told, guarded by dockerMu + osMu sync.Mutex // serialises checkOSSupport + osNotified string // the last state of the OS's support told, guarded by osMu + // Read and written by Run's goroutine only. seen []disk // the disks of the previous poll, nil before the first releaseChecked time.Time // the last look for a newer release @@ -120,7 +131,7 @@ type Hub struct { var Default = New("/") // New keeps alerts.json under root and sends through Shoutrrr. Address, -// Latest, AutoUpdates, Muted and LastDockerRun are left to the caller. +// Latest, AutoUpdates, Muted, LastDockerRun and OSSupport are left to the caller. func New(root string) *Hub { return &Hub{ Root: root, @@ -134,16 +145,19 @@ func New(root string) *Hub { AutoUpdates: func() bool { return true }, Muted: func() bool { return false }, LastDockerRun: func() DockerRun { return DockerRun{} }, + OSSupport: func() ossupport.Report { return ossupport.Report{} }, sent: map[string]*record{}, } } // Run follows the message bus until ctx is done, tells a Docker update that ended -// or is going on, and looks at the disks and for a newer release five minutes -// after the start, then every hour. +// or is going on, tells the operating system's support when it is ending or ended +// (five minutes after the start, then every day), and looks at the disks and for a +// newer release five minutes after the start, then every hour. func (h *Hub) Run(ctx context.Context) { go h.followBus(ctx) go h.watchDocker(ctx) + go h.watchOSSupport(ctx) timer := time.NewTimer(5 * time.Minute) defer timer.Stop() for { diff --git a/pkg/alerts/os_support.go b/pkg/alerts/os_support.go new file mode 100644 index 00000000..d9554d5b --- /dev/null +++ b/pkg/alerts/os_support.go @@ -0,0 +1,113 @@ +package alerts + +import ( + "context" + "os" + "path/filepath" + "strings" + "time" + + "github.com/ReCasaOS/CasaOS-Common/utils/logger" + "github.com/ReCasaOS/CasaOS/pkg/ossupport" + "github.com/ReCasaOS/CasaOS/pkg/utils/file" + "go.uber.org/zap" +) + +// OSSupportNotifiedFile holds the last state of the operating system's support that was told to the +// owner, "--". It is the core's only memory of it: a restart changes nothing. +const OSSupportNotifiedFile = "/var/lib/casaos/os-support.notified" + +var ( + // osSupportFirst is how long after the start the first look comes: the same five minutes as the + // disks', so that the network is up when a message that is told once is sent. osSupportEvery + // is how often it looks again, so that a box that crosses its date is told. Vars, so tests can + // shorten them. + osSupportFirst = 5 * time.Minute + osSupportEvery = 24 * time.Hour +) + +// watchOSSupport tells the owner when the operating system's security support is ending or has +// ended, once for each state of each release: it looks five minutes after the start, then every day. +func (h *Hub) watchOSSupport(ctx context.Context) { + timer := time.NewTimer(osSupportFirst) + defer timer.Stop() + for { + select { + case <-timer.C: + case <-ctx.Done(): + return + } + h.checkOSSupport() + timer.Reset(osSupportEvery) + } +} + +// checkOSSupport sends the one message of a state of support that is not told yet, and nothing for +// "supported" or "unknown". The state is told once: the last one told is in memory and in the marker +// file, and the file is written before the message is sent, so that no restart, however badly +// timed, tells a state twice (and one that is lost is not told again: the dashboard shows it). It is +// written whether or not there is a channel to tell, as the Docker update's is. Any number of +// looks may run at once: one of them tells it. +func (h *Hub) checkOSSupport() { + report := h.OSSupport() + state := report.Support.State + if state != ossupport.EndingSoon && state != ossupport.SecurityEnded { + return + } + key := report.ID + "-" + report.VersionID + "-" + state + + h.osMu.Lock() + defer h.osMu.Unlock() + if key == h.osNotified { + return + } + path := filepath.Join(h.Root, OSSupportNotifiedFile) + if data, err := os.ReadFile(path); err == nil && strings.TrimSpace(string(data)) == key { + h.osNotified = key + return + } + // ponytail: without the file (an unwritable state directory) a restart tells the state again. + h.osNotified = key + if err := file.WriteFileAtomic(path, []byte(key+"\n")); err != nil { + logger.Error("alerts: cannot remember that the support of the operating system was told", zap.Error(err)) + } + // one key per state: the six hours an alert stays quiet are for repeats, not for the next state + h.raise(alert{key: "os-support:" + key, category: Updates, sentence: osSupportSentence(report)}) +} + +// osSupportSentence is what the owner reads: what ends or ended and when, then what the product +// does not do yet. +func osSupportSentence(report ossupport.Report) string { + name, kind := "", "" + switch report.ID { + case "debian": + name, kind = "Debian", "Debian" + case "ubuntu": + name, kind = "Ubuntu", "standard" + } + date := osSupportDate(report.Support) + subject := name + " " + report.VersionID + var sentence string + switch { + case report.Support.State == ossupport.SecurityEnded: + sentence = subject + " no longer receives " + kind + " security updates since " + date + "." + case report.Support.Precision == ossupport.PrecisionMonth: + sentence = subject + " stops receiving " + kind + " security updates " + date + "." + default: + sentence = subject + " stops receiving " + kind + " security updates on " + date + "." + } + return sentence + " ReCasaOS cannot upgrade the operating system for you yet: see the dashboard." +} + +// osSupportDate is the day in words, "31 August 2026", or the month with "around" for a date the +// sources do not agree on the day of: "around June 2027". +func osSupportDate(support ossupport.Support) string { + end, err := time.Parse("2006-01-02", support.Ends) + if err != nil { + return support.Ends + } + if support.Precision == ossupport.PrecisionMonth { + return "around " + end.Format("January 2006") + } + return end.Format("2 January 2006") +} diff --git a/pkg/alerts/os_support_test.go b/pkg/alerts/os_support_test.go new file mode 100644 index 00000000..675901fc --- /dev/null +++ b/pkg/alerts/os_support_test.go @@ -0,0 +1,521 @@ +package alerts + +import ( + "context" + "os" + "path/filepath" + "strings" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/ReCasaOS/CasaOS/pkg/ossupport" +) + +const handoff = " ReCasaOS cannot upgrade the operating system for you yet: see the dashboard." + +var ( + debian11 = map[string]string{"ID": "debian", "VERSION_ID": "11", "VERSION_CODENAME": "bullseye"} + debian12 = map[string]string{"ID": "debian", "VERSION_ID": "12", "VERSION_CODENAME": "bookworm"} + ubuntu20 = map[string]string{"ID": "ubuntu", "VERSION_ID": "20.04", "VERSION_CODENAME": "focal"} + ubuntu22 = map[string]string{"ID": "ubuntu", "VERSION_ID": "22.04", "VERSION_CODENAME": "jammy"} + mint = map[string]string{"ID": "linuxmint", "ID_LIKE": "ubuntu debian", "VERSION_ID": "21.3", "VERSION_CODENAME": "virginia"} +) + +// osFrom is the box of a release at the day of the hub's clock: the answer of the service, with the +// real table. +func osFrom(h *Hub, release map[string]string, now *time.Time) { + h.OSSupport = func() ossupport.Report { return ossupport.Describe(release, "amd64", *now) } +} + +// osMarker is what the marker file holds, "" when there is none. +func osMarker(h *Hub) string { + data, err := os.ReadFile(filepath.Join(h.Root, OSSupportNotifiedFile)) + if err != nil { + return "" + } + return string(data) +} + +// onDay is the hub's clock at midnight of a day. +func onDay(t *testing.T, now *time.Time, day string) { + t.Helper() + d, err := time.Parse("2006-01-02", day) + if err != nil { + t.Fatal(err) + } + *now = d +} + +func TestTheSupportOfTheOperatingSystemIsToldInWords(t *testing.T) { + for name, tc := range map[string]struct { + release map[string]string + day string + want string + }{ + "Debian 11 ended": {debian11, "2026-10-10", + "Debian 11 no longer receives Debian security updates since 31 August 2026." + handoff}, + "Debian 11 ended, a year later": {debian11, "2027-10-10", + "Debian 11 no longer receives Debian security updates since 31 August 2026." + handoff}, + "Debian 12 ending": {debian12, "2028-01-15", + "Debian 12 stops receiving Debian security updates on 30 June 2028." + handoff}, + "Ubuntu 22.04 ending, to the month": {ubuntu22, "2027-01-15", + "Ubuntu 22.04 stops receiving standard security updates around June 2027." + handoff}, + "Ubuntu 22.04 ended, to the month": {ubuntu22, "2027-06-02", + "Ubuntu 22.04 no longer receives standard security updates since around June 2027." + handoff}, + "Ubuntu 20.04 ended": {ubuntu20, "2026-10-10", + "Ubuntu 20.04 no longer receives standard security updates since 29 May 2025." + handoff}, + } { + t.Run(name, func(t *testing.T) { + h, o, now := newHub(t) + osFrom(h, tc.release, now) + onDay(t, now, tc.day) + + h.checkOSSupport() + h.sending.Wait() + + if got := o.texts(phone); len(got) != 1 || got[0] != tc.want+"\nhttp://192.168.1.20" { + t.Fatalf("sent %q, want %q", got, tc.want) + } + if o.sent[0].title != "ReCasaOS · box · updates" { + t.Fatalf("title = %q", o.sent[0].title) + } + }) + } +} + +// Nothing for a release that is supported, for one that is not known, and for a hub that was never +// told how to look. +func TestNothingIsToldForASupportedOrUnknownSystem(t *testing.T) { + for name, tc := range map[string]struct { + release map[string]string + day string + }{ + "Debian 12, supported": {debian12, "2026-10-10"}, + "Ubuntu 22.04, supported": {ubuntu22, "2026-12-02"}, + "a Mint, which is not known": {mint, "2026-10-10"}, + "nothing read": {nil, "2026-10-10"}, + "a table gone stale, a Debian 11": {debian11, "2028-04-10"}, + } { + t.Run(name, func(t *testing.T) { + h, o, now := newHub(t) + osFrom(h, tc.release, now) + onDay(t, now, tc.day) + + h.checkOSSupport() + h.sending.Wait() + + if len(o.sent) != 0 || len(h.sent) != 0 || osMarker(h) != "" { + t.Fatalf("sent %+v, on record %v, marker %q; want nothing", o.sent, h.sent, osMarker(h)) + } + }) + } + h, o, _ := newHub(t) + h.checkOSSupport() // the hub New made, with no one to ask + h.sending.Wait() + if len(o.sent) != 0 { + t.Fatalf("sent %+v", o.sent) + } +} + +// One message, to each channel, for a state, however often it is looked at. +func TestAStateIsToldOnce(t *testing.T) { + h, o, now := newHub(t) + osFrom(h, debian11, now) + onDay(t, now, "2026-10-10") + + for range 5 { + h.checkOSSupport() + } + h.sending.Wait() + + if len(o.texts(phone)) != 1 || len(o.texts(mail)) != 1 { + t.Fatalf("sent %+v, want one message for each of the two channels", o.sent) + } + if got := osMarker(h); got != "debian-11-security_ended\n" { + t.Fatalf("marker = %q", got) + } + if _, kept := h.sent["os-support:debian-11-security_ended"]; !kept { + t.Fatalf("not on record under os-support:debian-11-security_ended: %v", h.sent) + } +} + +// Told again only when the state changes: Ubuntu 22.04 goes from supported, to ending, to ended, a +// day, and the six quiet hours of an alert already sent do not hide the second state. +func TestAStateIsToldAgainOnlyWhenItChanges(t *testing.T) { + h, o, now := newHub(t) + osFrom(h, ubuntu22, now) + + step := func(day string) { + onDay(t, now, day) + h.checkOSSupport() + h.sending.Wait() + } + step("2026-10-10") + if len(o.sent) != 0 { + t.Fatalf("supported: sent %+v", o.sent) + } + step("2026-12-03") + step("2026-12-04") + step("2027-05-31") + if got := o.texts(phone); len(got) != 1 || !strings.HasPrefix(got[0], "Ubuntu 22.04 stops receiving standard security updates around June 2027.") { + t.Fatalf("ending: sent %q", got) + } + if got := osMarker(h); got != "ubuntu-22.04-ending_soon\n" { + t.Fatalf("marker = %q", got) + } + + // the day after, a few hours later in the same six: the state is new + step("2027-06-02") + step("2027-06-03") + got := o.texts(phone) + if len(got) != 2 || !strings.HasPrefix(got[1], "Ubuntu 22.04 no longer receives standard security updates since around June 2027.") { + t.Fatalf("ended: sent %q", got) + } + if got := osMarker(h); got != "ubuntu-22.04-security_ended\n" { + t.Fatalf("marker = %q", got) + } +} + +// A box that was told, restarted, is not told again; a state that came while it was down is told once. +func TestAStateIsToldOnceAcrossARestart(t *testing.T) { + h, o, now := newHub(t) + osFrom(h, ubuntu22, now) + onDay(t, now, "2027-01-15") + h.checkOSSupport() + h.sending.Wait() + if len(o.texts(phone)) != 1 { + t.Fatalf("sent %q", o.texts(phone)) + } + if entries, _ := os.ReadDir(filepath.Dir(filepath.Join(h.Root, OSSupportNotifiedFile))); len(entries) != 2 { + t.Errorf("the state directory holds %v, want alerts.json and the marker only: the write leaves nothing behind", entries) + } + + again := restarted(h, o) + osFrom(again, ubuntu22, now) + again.checkOSSupport() + again.sending.Wait() + if got := o.texts(phone); len(got) != 1 { + t.Fatalf("after the restart the same state was told again: %q", got) + } + + // it ended while the core was down + onDay(t, now, "2027-06-10") + again = restarted(h, o) + osFrom(again, ubuntu22, now) + again.checkOSSupport() + again.sending.Wait() + if got := o.texts(phone); len(got) != 2 || !strings.Contains(got[1], "no longer receives") { + t.Fatalf("sent %q, want the new state told once", got) + } + + // and another restart tells nothing + third := restarted(h, o) + osFrom(third, ubuntu22, now) + third.checkOSSupport() + third.sending.Wait() + if got := o.texts(phone); len(got) != 2 { + t.Fatalf("sent %q", got) + } +} + +// What was told stays told when the box is upgraded: a Debian 12 that was ending is told again when +// it is a Debian 13 that is ending, because that is another release. +func TestAnotherReleaseIsAnotherMessage(t *testing.T) { + h, o, now := newHub(t) + osFrom(h, debian12, now) + onDay(t, now, "2028-02-01") + h.checkOSSupport() + h.sending.Wait() + // a Debian 13 would be told ending in 2030; the keys are what differ + h.OSSupport = func() ossupport.Report { + return ossupport.Report{ + Identity: ossupport.Identity{ID: "debian", VersionID: "13", Known: true}, + Support: ossupport.Support{State: ossupport.EndingSoon, Ends: "2030-06-30", Precision: ossupport.PrecisionDay}, + } + } + h.checkOSSupport() + h.sending.Wait() + got := o.texts(phone) + if len(got) != 2 || !strings.HasPrefix(got[0], "Debian 12 stops") || !strings.HasPrefix(got[1], "Debian 13 stops receiving Debian security updates on 30 June 2030.") { + t.Fatalf("sent %q", got) + } +} + +// A marker file that is garbage is no state: it is told, and the file is mended. +func TestAMarkerThatIsGarbageDoesNotSilenceAState(t *testing.T) { + h, o, now := newHub(t) + osFrom(h, debian11, now) + onDay(t, now, "2026-10-10") + if err := os.WriteFile(filepath.Join(h.Root, OSSupportNotifiedFile), []byte("\x00\x00 not a state"), 0o600); err != nil { + t.Fatal(err) + } + + h.checkOSSupport() + h.sending.Wait() + + if len(o.texts(phone)) != 1 || osMarker(h) != "debian-11-security_ended\n" { + t.Fatalf("sent %q, marker %q", o.texts(phone), osMarker(h)) + } +} + +// The marker is written before the message is raised: a restart in between loses the message and +// never repeats it. The clock is read by raise before it sends, and looks for the marker. +func TestTheMarkerIsWrittenBeforeTheMessageIsRaised(t *testing.T) { + h, o, now := newHub(t) + osFrom(h, debian11, now) + onDay(t, now, "2026-10-10") + clock := *now + var seen []string + h.Now = func() time.Time { + seen = append(seen, osMarker(h)) + return clock + } + + h.checkOSSupport() + h.sending.Wait() + + if len(o.texts(phone)) != 1 || len(seen) == 0 { + t.Fatalf("sent %q, the clock was read %d times", o.texts(phone), len(seen)) + } + for _, marker := range seen { + if marker != "debian-11-security_ended\n" { + t.Fatalf("when the alert was raised the marker was %q, want it written already", marker) + } + } +} + +// The marker is replaced, not written over: the old file, which anything holding it open still has, +// is whole until the new one is renamed over it. A second name of the old file (a hard link) shows +// it: it keeps the old state, and the path has the new one. +func TestTheMarkerIsReplacedAtomically(t *testing.T) { + h, _, now := newHub(t) + osFrom(h, ubuntu22, now) + onDay(t, now, "2027-01-15") + h.checkOSSupport() + h.sending.Wait() + + path := filepath.Join(h.Root, OSSupportNotifiedFile) + old := path + ".old" + if err := os.Link(path, old); err != nil { + t.Skipf("no hard links here: %v", err) + } + onDay(t, now, "2027-06-10") + h.checkOSSupport() + h.sending.Wait() + + if data, _ := os.ReadFile(old); string(data) != "ubuntu-22.04-ending_soon\n" { + t.Fatalf("the old file now holds %q: the marker was written in place, not replaced", data) + } + if got := osMarker(h); got != "ubuntu-22.04-security_ended\n" { + t.Fatalf("marker = %q", got) + } +} + +// Two looks at once (the daily one, and whoever else) must not both tell a state. The clock jumps a +// day at each look, so that only the state stands between them and a second message. +func TestRacingLooksSendOnce(t *testing.T) { + h, o, _ := newHub(t) + h.OSSupport = func() ossupport.Report { + return ossupport.Describe(debian11, "amd64", time.Date(2026, 10, 10, 0, 0, 0, 0, time.UTC)) + } + var looks atomic.Int64 + base := time.Date(2026, 9, 25, 6, 0, 0, 0, time.UTC) + h.Now = func() time.Time { return base.Add(time.Duration(looks.Add(1)) * 24 * time.Hour) } + start := make(chan struct{}) + var all sync.WaitGroup + for range 32 { + all.Add(1) + go func() { + defer all.Done() + <-start + h.checkOSSupport() + }() + } + close(start) + all.Wait() + h.sending.Wait() + + if len(o.texts(phone)) != 1 || len(o.texts(mail)) != 1 { + t.Fatalf("sent %+v, want the state told once to each channel", o.sent) + } +} + +// The state is on record whether or not there is a channel: the owner who adds one next week is not +// sent the news of this one. +func TestAStateIsRecordedWithNoChannelToTell(t *testing.T) { + h, o, now := newHub(t) + saveConfig(t, h, Config{Categories: map[string]bool{Backups: true, Disks: true, Updates: true, Apps: true}, DiskThreshold: 90}) + osFrom(h, debian11, now) + onDay(t, now, "2026-10-10") + + h.checkOSSupport() + h.sending.Wait() + saveConfig(t, h, Config{ + Channels: []Channel{{ID: "a1", Name: "Phone", URL: phone}}, + Categories: map[string]bool{Backups: true, Disks: true, Updates: true, Apps: true}, + DiskThreshold: 90, + }) + h.checkOSSupport() + h.sending.Wait() + + if len(o.sent) != 0 || osMarker(h) != "debian-11-security_ended\n" { + t.Fatalf("sent %+v, marker %q", o.sent, osMarker(h)) + } +} + +// With a state directory that cannot be written the state is still told, once while the core runs. +func TestAStateIsToldOnceEvenIfTheMarkerCannotBeWritten(t *testing.T) { + h, o, now := newHub(t) + // the marker is a directory: it can be neither read nor replaced + if err := os.MkdirAll(filepath.Join(h.Root, OSSupportNotifiedFile), 0o755); err != nil { + t.Fatal(err) + } + osFrom(h, debian11, now) + onDay(t, now, "2026-10-10") + // the hub's clock jumps a day at each look, so that only what the watcher remembers of the state + // stands between it and a second message: not the six quiet hours of an alert already sent + var looks atomic.Int64 + base := *now + h.Now = func() time.Time { return base.Add(time.Duration(looks.Add(1)) * 24 * time.Hour) } + + h.checkOSSupport() + h.checkOSSupport() + h.sending.Wait() + + if len(o.texts(phone)) != 1 { + t.Fatalf("sent %q, want the state told once", o.texts(phone)) + } +} + +// An alert already sent stays quiet for six hours, but under its own key: the next state of the same +// release, a moment later, is not a repeat of the last. +func TestTheSixQuietHoursDoNotHideTheNextState(t *testing.T) { + h, o, _ := newHub(t) // the hub's clock stays at 06:00 on 2026-09-25 + day := time.Date(2027, 5, 31, 0, 0, 0, 0, time.UTC) + h.OSSupport = func() ossupport.Report { return ossupport.Describe(ubuntu22, "amd64", day) } + + h.checkOSSupport() + day = day.AddDate(0, 0, 2) + h.checkOSSupport() + h.sending.Wait() + + got := o.texts(phone) + if len(got) != 2 || !strings.Contains(got[0], "stops receiving") || !strings.Contains(got[1], "no longer receives") { + t.Fatalf("sent %q, want the two states told, a moment apart", got) + } +} + +// The daily look: the first one waits, the next ones come every day, a state that changes between +// two of them is told, and the goroutine ends with its context. +func TestTheDailyLookTellsWhatChangedAndStopsWithItsContext(t *testing.T) { + first, every := osSupportFirst, osSupportEvery + osSupportFirst, osSupportEvery = time.Millisecond, time.Millisecond + t.Cleanup(func() { osSupportFirst, osSupportEvery = first, every }) + + h, o, _ := newHub(t) + var looks atomic.Int32 + h.OSSupport = func() ossupport.Report { + n := looks.Add(1) + switch { + case n < 3: + return ossupport.Describe(ubuntu22, "amd64", time.Date(2026, 10, 10, 0, 0, 0, 0, time.UTC)) + case n < 6: + return ossupport.Describe(ubuntu22, "amd64", time.Date(2027, 1, 15, 0, 0, 0, 0, time.UTC)) + } + return ossupport.Describe(ubuntu22, "amd64", time.Date(2027, 6, 10, 0, 0, 0, 0, time.UTC)) + } + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan struct{}) + go func() { + h.watchOSSupport(ctx) + close(done) + }() + deadline := time.After(10 * time.Second) + for looks.Load() < 9 { + select { + case <-deadline: + t.Fatalf("only %d looks", looks.Load()) + case <-time.After(time.Millisecond): + } + } + cancel() + select { + case <-done: + case <-time.After(10 * time.Second): + t.Fatal("the watcher did not stop with its context") + } + h.sending.Wait() + + got := o.texts(phone) + if len(got) != 2 || !strings.Contains(got[0], "stops receiving") || !strings.Contains(got[1], "no longer receives") { + t.Fatalf("sent %q, want the two states told once each across the looks", got) + } +} + +func TestTheFirstLookWaitsAndCanBeCancelled(t *testing.T) { + first := osSupportFirst + osSupportFirst = time.Hour + t.Cleanup(func() { osSupportFirst = first }) + + h, _, _ := newHub(t) + var looks atomic.Int32 + h.OSSupport = func() ossupport.Report { looks.Add(1); return ossupport.Report{} } + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan struct{}) + go func() { + h.watchOSSupport(ctx) + close(done) + }() + time.Sleep(20 * time.Millisecond) + cancel() + select { + case <-done: + case <-time.After(10 * time.Second): + t.Fatal("the watcher did not stop with its context") + } + if looks.Load() != 0 { + t.Fatalf("looked %d times before the first delay was over", looks.Load()) + } +} + +// The hub's own Run is what starts the daily look: a box whose Debian is out of support is told +// shortly after the core starts. +func TestRunStartsTheDailyLook(t *testing.T) { + first := osSupportFirst + osSupportFirst = time.Millisecond + t.Cleanup(func() { osSupportFirst = first }) + + h, o, now := newHub(t) + osFrom(h, debian11, now) + onDay(t, now, "2026-10-10") + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan struct{}) + go func() { + h.Run(ctx) + close(done) + }() + deadline := time.After(10 * time.Second) + for { + o.mu.Lock() + told := len(o.sent) + o.mu.Unlock() + if told > 0 { + break + } + select { + case <-deadline: + cancel() + t.Fatal("nothing was told") + case <-time.After(time.Millisecond): + } + } + cancel() + <-done + h.sending.Wait() + + if got := o.texts(phone); len(got) != 1 || !strings.HasPrefix(got[0], "Debian 11 no longer receives Debian security updates since 31 August 2026.") { + t.Fatalf("sent %q", got) + } +} From f4919cedb4b64c2b6d38d3ee1ec5a86b16bb73d8 Mon Sep 17 00:00:00 2001 From: Gary Date: Sun, 11 Oct 2026 02:25:50 +0200 Subject: [PATCH 4/8] fix(alerts): tell the operating system's support only once a message went out The marker was written before the send, so a box with no channel (the default), with the Updates category off, or whose first send failed spent the one message of a state that lasts for months. raise now has a waiting form that says whether a channel took the message, and checkOSSupport writes the marker only then: otherwise the next daily look tries again. A crash between the send and the marker may tell a state once more. --- pkg/alerts/alerts.go | 59 ++++++++++++---- pkg/alerts/os_support.go | 15 ++-- pkg/alerts/os_support_test.go | 125 +++++++++++++++++++++++++++------- 3 files changed, 157 insertions(+), 42 deletions(-) diff --git a/pkg/alerts/alerts.go b/pkg/alerts/alerts.go index 090d84bd..f957a1d2 100644 --- a/pkg/alerts/alerts.go +++ b/pkg/alerts/alerts.go @@ -20,7 +20,8 @@ // // And when the operating system's security updates are about to end, or have ended, // the owner is told once for each state of each release, from a look every day: the -// last one told is kept in os-support.notified. +// last one told is kept in os-support.notified, written once a message has gone out, +// so that a state is looked at again, every day, until a channel has taken it. package alerts import ( @@ -32,6 +33,7 @@ import ( "slices" "strings" "sync" + "sync/atomic" "time" "github.com/ReCasaOS/CasaOS-Common/utils/logger" @@ -177,6 +179,14 @@ func (h *Hub) Run(ctx context.Context) { // the next message says how often it came back. It reports whether a is now // on record as sent. func (h *Hub) raise(a alert) bool { + return h.raiseAs(a, false) +} + +// raiseAs is raise, and with wait it sends now and waits for the channels, and then +// reports whether one of them took the message: when none did, a is not on record, so +// that the caller can try again. A caller that tells a condition once, for good, needs +// that; the others fire and forget. +func (h *Hub) raiseAs(a alert, wait bool) bool { c := h.load() if len(c.Channels) == 0 || !c.Categories[a.category] { return false @@ -195,8 +205,21 @@ func (h *Hub) raise(a alert) bool { if previous != nil && previous.repeats > 0 { sentence += fmt.Sprintf(" It happened %d times since %s.", previous.repeats+1, previous.at.Format("15:04")) } - h.deliver(c.Channels, a.category, sentence) - return true + if !wait { + h.deliver(c.Channels, a.category, sentence) + return true + } + if h.deliverAll(c.Channels, a.category, sentence) { + return true + } + h.mu.Lock() + if previous == nil { + delete(h.sent, a.key) + } else { + h.sent[a.key] = previous + } + h.mu.Unlock() + return false } // resolve sends one "resolved" message for key, only if its alert was sent, @@ -223,19 +246,29 @@ func (h *Hub) deliver(channels []Channel, category, sentence string) { h.sending.Add(1) go func() { defer h.sending.Done() - title, message := h.compose(category, sentence) - var each sync.WaitGroup - for _, channel := range channels { - each.Add(1) - go func() { - defer each.Done() - _ = h.sendTo(channel, title, message) - }() - } - each.Wait() + h.deliverAll(channels, category, sentence) }() } +// deliverAll sends a message to every channel, each on its own, and waits for them: it +// reports whether at least one took it. +func (h *Hub) deliverAll(channels []Channel, category, sentence string) bool { + title, message := h.compose(category, sentence) + var each sync.WaitGroup + var accepted atomic.Bool + for _, channel := range channels { + each.Add(1) + go func() { + defer each.Done() + if h.sendTo(channel, title, message) == nil { + accepted.Store(true) + } + }() + } + each.Wait() + return accepted.Load() +} + // compose is the title, "ReCasaOS · · ", and the message: // the sentence, then the dashboard's address when the core knows it. func (h *Hub) compose(category, sentence string) (title, message string) { diff --git a/pkg/alerts/os_support.go b/pkg/alerts/os_support.go index d9554d5b..08b47a29 100644 --- a/pkg/alerts/os_support.go +++ b/pkg/alerts/os_support.go @@ -44,10 +44,11 @@ func (h *Hub) watchOSSupport(ctx context.Context) { // checkOSSupport sends the one message of a state of support that is not told yet, and nothing for // "supported" or "unknown". The state is told once: the last one told is in memory and in the marker -// file, and the file is written before the message is sent, so that no restart, however badly -// timed, tells a state twice (and one that is lost is not told again: the dashboard shows it). It is -// written whether or not there is a channel to tell, as the Docker update's is. Any number of -// looks may run at once: one of them tells it. +// file. Unlike a Docker run, which is over, a state of support lasts for months, and a warning that +// was lost is worse than one that comes twice: so the marker is written only after a message went +// out. With no channel, with the Updates category off, or when every channel failed, nothing is +// written and the next look (a day later) tries again. A crash between the send and the marker +// may tell the state once more. Any number of looks may run at once: one of them tells it. func (h *Hub) checkOSSupport() { report := h.OSSupport() state := report.Support.State @@ -66,13 +67,15 @@ func (h *Hub) checkOSSupport() { h.osNotified = key return } + // one key per state: the six hours an alert stays quiet are for repeats, not for the next state + if !h.raiseAs(alert{key: "os-support:" + key, category: Updates, sentence: osSupportSentence(report)}, true) { + return + } // ponytail: without the file (an unwritable state directory) a restart tells the state again. h.osNotified = key if err := file.WriteFileAtomic(path, []byte(key+"\n")); err != nil { logger.Error("alerts: cannot remember that the support of the operating system was told", zap.Error(err)) } - // one key per state: the six hours an alert stays quiet are for repeats, not for the next state - h.raise(alert{key: "os-support:" + key, category: Updates, sentence: osSupportSentence(report)}) } // osSupportSentence is what the owner reads: what ends or ended and when, then what the product diff --git a/pkg/alerts/os_support_test.go b/pkg/alerts/os_support_test.go index 675901fc..82ead5fd 100644 --- a/pkg/alerts/os_support_test.go +++ b/pkg/alerts/os_support_test.go @@ -2,6 +2,7 @@ package alerts import ( "context" + "errors" "os" "path/filepath" "strings" @@ -260,30 +261,32 @@ func TestAMarkerThatIsGarbageDoesNotSilenceAState(t *testing.T) { } } -// The marker is written before the message is raised: a restart in between loses the message and -// never repeats it. The clock is read by raise before it sends, and looks for the marker. -func TestTheMarkerIsWrittenBeforeTheMessageIsRaised(t *testing.T) { +// The marker is written once the message went out, and not before: a state that lasts for months +// must not be spent on a message that never left. Each send looks for the marker. +func TestTheMarkerIsWrittenOnceTheMessageWentOut(t *testing.T) { h, o, now := newHub(t) osFrom(h, debian11, now) onDay(t, now, "2026-10-10") - clock := *now var seen []string - h.Now = func() time.Time { + h.Send = func(rawURL, title, text string) error { seen = append(seen, osMarker(h)) - return clock + return o.send(rawURL, title, text) } h.checkOSSupport() h.sending.Wait() - if len(o.texts(phone)) != 1 || len(seen) == 0 { - t.Fatalf("sent %q, the clock was read %d times", o.texts(phone), len(seen)) + if len(o.texts(phone)) != 1 || len(seen) != 2 { + t.Fatalf("sent %q, the channels were sent to %d times", o.texts(phone), len(seen)) } for _, marker := range seen { - if marker != "debian-11-security_ended\n" { - t.Fatalf("when the alert was raised the marker was %q, want it written already", marker) + if marker != "" { + t.Fatalf("while the message was sent the marker was %q, want it not written yet", marker) } } + if got := osMarker(h); got != "debian-11-security_ended\n" { + t.Fatalf("marker after the send = %q", got) + } } // The marker is replaced, not written over: the old file, which anything holding it open still has, @@ -342,26 +345,102 @@ func TestRacingLooksSendOnce(t *testing.T) { } } -// The state is on record whether or not there is a channel: the owner who adds one next week is not -// sent the news of this one. -func TestAStateIsRecordedWithNoChannelToTell(t *testing.T) { - h, o, now := newHub(t) - saveConfig(t, h, Config{Categories: map[string]bool{Backups: true, Disks: true, Updates: true, Apps: true}, DiskThreshold: 90}) +// A state that lasts is not spent on a box that cannot tell it. With no channel (the default), or +// with the Updates category off, nothing is sent and nothing is written; the owner who adds a +// channel, or turns the category on, next week is told on the next look. +func TestAStateIsNotSpentWithNoChannelOrWithUpdatesOff(t *testing.T) { + everything := map[string]bool{Backups: true, Disks: true, Updates: true, Apps: true} + noUpdates := map[string]bool{Backups: true, Disks: true, Updates: false, Apps: true} + for name, tc := range map[string]struct { + before Config + after Config + }{ + "no channel, then one": { + Config{Categories: everything, DiskThreshold: 90}, + Config{Channels: []Channel{{ID: "a1", Name: "Phone", URL: phone}}, Categories: everything, DiskThreshold: 90}, + }, + "a channel, Updates off, then on": { + Config{Channels: []Channel{{ID: "a1", Name: "Phone", URL: phone}}, Categories: noUpdates, DiskThreshold: 90}, + Config{Channels: []Channel{{ID: "a1", Name: "Phone", URL: phone}}, Categories: everything, DiskThreshold: 90}, + }, + } { + t.Run(name, func(t *testing.T) { + h, o, now := newHub(t) + osFrom(h, debian11, now) + onDay(t, now, "2026-10-10") + saveConfig(t, h, tc.before) + + h.checkOSSupport() + h.sending.Wait() + if len(o.sent) != 0 || osMarker(h) != "" || len(h.sent) != 0 { + t.Fatalf("sent %+v, marker %q, on record %v: want nothing spent", o.sent, osMarker(h), h.sent) + } + onDay(t, now, "2026-10-11") + h.checkOSSupport() + h.sending.Wait() + if len(o.sent) != 0 || osMarker(h) != "" { + t.Fatalf("a day later, still nothing to tell with: sent %+v, marker %q", o.sent, osMarker(h)) + } + + saveConfig(t, h, tc.after) + onDay(t, now, "2026-10-12") + h.checkOSSupport() + h.sending.Wait() + if got := o.texts(phone); len(got) != 1 || !strings.HasPrefix(got[0], "Debian 11 no longer receives Debian security updates since 31 August 2026.") { + t.Fatalf("sent %q, want the state told now that it can be", got) + } + if got := osMarker(h); got != "debian-11-security_ended\n" { + t.Fatalf("marker = %q", got) + } + // and then it is told + onDay(t, now, "2026-10-13") + h.checkOSSupport() + h.sending.Wait() + if got := o.texts(phone); len(got) != 1 { + t.Fatalf("sent %q, want it told once", got) + } + }) + } +} + +// A message that fails to leave does not spend the state either: with every channel failing the +// marker is not written, and the next look, with the network back, tells it, even when it comes +// inside the six quiet hours of an alert already sent. One channel taking it is enough. +func TestAStateIsNotSpentOnAFailedSend(t *testing.T) { + h, o, now := newHub(t) // the hub's clock stays at 06:00 on 2026-09-25 osFrom(h, debian11, now) onDay(t, now, "2026-10-10") + clock := *now + h.Now = func() time.Time { return clock } + o.fail[phone] = errors.New("network is unreachable") + o.fail[mail] = errors.New("network is unreachable") h.checkOSSupport() h.sending.Wait() - saveConfig(t, h, Config{ - Channels: []Channel{{ID: "a1", Name: "Phone", URL: phone}}, - Categories: map[string]bool{Backups: true, Disks: true, Updates: true, Apps: true}, - DiskThreshold: 90, - }) + if len(o.sent) != 0 || osMarker(h) != "" || len(h.sent) != 0 { + t.Fatalf("sent %+v, marker %q, on record %v: want nothing spent", o.sent, osMarker(h), h.sent) + } + if h.lastFailure == nil { + t.Fatal("the failure was not kept for the dashboard") + } + + // one look later, a minute on, the phone's server is back and the mail's is not + clock = clock.Add(time.Minute) + delete(o.fail, phone) h.checkOSSupport() h.sending.Wait() - - if len(o.sent) != 0 || osMarker(h) != "debian-11-security_ended\n" { - t.Fatalf("sent %+v, marker %q", o.sent, osMarker(h)) + if len(o.texts(phone)) != 1 || len(o.texts(mail)) != 0 { + t.Fatalf("sent %+v, want the one channel that works to tell it", o.sent) + } + if got := osMarker(h); got != "debian-11-security_ended\n" { + t.Fatalf("marker = %q: one channel took the message", got) + } + clock = clock.Add(24 * time.Hour) + delete(o.fail, mail) + h.checkOSSupport() + h.sending.Wait() + if len(o.sent) != 1 { + t.Fatalf("sent %+v, want it told once", o.sent) } } From 3b302ae42fd7f437e3fb5089998c5706293d961f Mon Sep 17 00:00:00 2001 From: Gary Date: Sun, 11 Oct 2026 02:30:13 +0200 Subject: [PATCH 5/8] fix(ossupport): a support state that has ended stays ended when the table is old Eighteen months after AsOf the table made every answer unknown, so a Debian 11 or Ubuntu 20.04 box that is still unpatched lost its badge, and its alert, at the moment it was oldest. Staleness now turns only supported and ending_soon into unknown, which a vendor can still move; security_ended stays, with its end day and source and without the extended programme and the next release, which are claims about a time the table has not been looked at for. The wrong clock guard (before AsOf) is unchanged. Also makes the Run test wait for the marker, which is now the last thing a look does. --- pkg/alerts/os_support_test.go | 9 +++- pkg/ossupport/ossupport.go | 24 ++++++++--- pkg/ossupport/ossupport_test.go | 75 +++++++++++++++++++++++---------- 3 files changed, 77 insertions(+), 31 deletions(-) diff --git a/pkg/alerts/os_support_test.go b/pkg/alerts/os_support_test.go index 82ead5fd..2011e165 100644 --- a/pkg/alerts/os_support_test.go +++ b/pkg/alerts/os_support_test.go @@ -59,6 +59,9 @@ func TestTheSupportOfTheOperatingSystemIsToldInWords(t *testing.T) { "Debian 11 no longer receives Debian security updates since 31 August 2026." + handoff}, "Debian 11 ended, a year later": {debian11, "2027-10-10", "Debian 11 no longer receives Debian security updates since 31 August 2026." + handoff}, + // what ended stays ended when the table is old: the owner is still told + "Debian 11 ended, the table stale": {debian11, "2028-05-01", + "Debian 11 no longer receives Debian security updates since 31 August 2026." + handoff}, "Debian 12 ending": {debian12, "2028-01-15", "Debian 12 stops receiving Debian security updates on 30 June 2028." + handoff}, "Ubuntu 22.04 ending, to the month": {ubuntu22, "2027-01-15", @@ -97,7 +100,7 @@ func TestNothingIsToldForASupportedOrUnknownSystem(t *testing.T) { "Ubuntu 22.04, supported": {ubuntu22, "2026-12-02"}, "a Mint, which is not known": {mint, "2026-10-10"}, "nothing read": {nil, "2026-10-10"}, - "a table gone stale, a Debian 11": {debian11, "2028-04-10"}, + "a table gone stale, a Debian 12": {debian12, "2028-05-01"}, } { t.Run(name, func(t *testing.T) { h, o, now := newHub(t) @@ -580,7 +583,9 @@ func TestRunStartsTheDailyLook(t *testing.T) { o.mu.Lock() told := len(o.sent) o.mu.Unlock() - if told > 0 { + // the marker is the last thing the look does, after the send: the state directory is not + // removed under it + if told > 0 && osMarker(h) != "" { break } select { diff --git a/pkg/ossupport/ossupport.go b/pkg/ossupport/ossupport.go index 6ef96af4..381c373b 100644 --- a/pkg/ossupport/ossupport.go +++ b/pkg/ossupport/ossupport.go @@ -5,11 +5,13 @@ // // Three rules keep it from saying something false: // - the identity is strict: ID must be exactly "debian" or "ubuntu". ID_LIKE is never looked at, -// so Mint, Pop!_OS, Raspbian, DietPi and the rest are not known, and nothing is claimed; +// so Mint, Pop!_OS, Raspbian (32-bit, ID=raspbian) and the rest are not known, and nothing is +// claimed; // - a release that is not in the table (Debian 14, Ubuntu 26.10, any non-LTS Ubuntu, 18.04) is // "unknown", not guessed; -// - eighteen months after the day the table was last checked (AsOf) every answer is "unknown": a -// table nobody has updated is no longer a source. +// - eighteen months after the day the table was last checked (AsOf) what is still to come is +// "unknown": a table nobody has updated is no longer a source for it. What has ended stays +// ended, since an end that has passed cannot un-end. package ossupport import ( @@ -225,16 +227,20 @@ func dayOf(t time.Time) time.Time { // Evaluate is how long the free security updates of id last, on the day of now (UTC). The last day // is a day of support: SecurityEnded begins the day after it, and EndingSoon the 180th day before // it. A box that is not Known, a release the table does not have, a codename that is not the -// release's (a derivative, a testing system), any day more than 18 months after AsOf and any -// day before it are Unknown. +// release's (a derivative, a testing system) and any day before AsOf are Unknown. More than 18 +// months after AsOf the table is stale: Supported and EndingSoon, which a vendor can still move, +// are Unknown then, and SecurityEnded stays SecurityEnded, with only the day it ended and its +// source: what the vendor offers after it, and the next release, are claims about a time the table +// has not been looked at for. func Evaluate(id Identity, now time.Time) Support { unknown := Support{State: Unknown} today := dayOf(now) // A day before AsOf is a clock that is wrong: the table was written on AsOf, so no box that // runs this code is earlier than that. - if !id.Known || today.Before(AsOf) || today.After(AsOf.AddDate(0, staleAfterMonths, 0)) { + if !id.Known || today.Before(AsOf) { return unknown } + stale := today.After(AsOf.AddDate(0, staleAfterMonths, 0)) for _, e := range table { if e.id != id.ID || e.versionID != id.VersionID || (id.Codename != "" && id.Codename != e.codename) { continue @@ -250,6 +256,12 @@ func Evaluate(id Identity, now time.Time) Support { case end.Sub(today) <= endingSoonDays*24*time.Hour: support.State = EndingSoon } + if stale { + if support.State != SecurityEnded { + return unknown + } + return support + } if e.extended != nil { extended := *e.extended support.Extended = &extended diff --git a/pkg/ossupport/ossupport_test.go b/pkg/ossupport/ossupport_test.go index ba97d4b3..41663824 100644 --- a/pkg/ossupport/ossupport_test.go +++ b/pkg/ossupport/ossupport_test.go @@ -333,41 +333,70 @@ func TestWhatIsNotInTheTableIsUnknown(t *testing.T) { } } -// 18 months after the day the table was checked, it is "unknown" rather than possibly false. +// 18 months after the day the table was checked it is stale: what a vendor can still move +// (supported, ending soon) is "unknown" rather than possibly false, and what has ended stays ended, +// since an end that has passed cannot un-end. A stale ended answer says the day it ended and its +// source, and nothing about what comes after it. func TestTheTableGoesStaleAfterEighteenMonths(t *testing.T) { bullseye := Parse(release("debian", "11", "bullseye", ""), "amd64") bookworm := Parse(release("debian", "12", "bookworm", ""), "amd64") for _, c := range []struct { - name string - now time.Time - want string // for bullseye + name string + now time.Time + bullseye string + bookworm string + stale bool // extended and next are no longer said }{ - {"the day it was checked", day(t, "2026-10-09"), SecurityEnded}, - {"a year after", day(t, "2027-10-09"), SecurityEnded}, - {"18 months after, at its first second", day(t, "2028-04-09"), SecurityEnded}, - {"18 months after, at its last second", day(t, "2028-04-09").Add(24*time.Hour - time.Second), SecurityEnded}, - {"18 months and a day after", day(t, "2028-04-10"), Unknown}, - {"two years after", day(t, "2028-10-09"), Unknown}, - {"far after", day(t, "2040-01-01"), Unknown}, - {"the day before it was checked: a clock that is wrong", day(t, "2026-10-08").Add(23 * time.Hour), Unknown}, - {"the epoch", time.Unix(0, 0), Unknown}, + {"the day it was checked", day(t, "2026-10-09"), SecurityEnded, Supported, false}, + {"a year after", day(t, "2027-10-09"), SecurityEnded, Supported, false}, + {"18 months after, at its first second", day(t, "2028-04-09"), SecurityEnded, EndingSoon, false}, + {"18 months after, at its last second", day(t, "2028-04-09").Add(24*time.Hour - time.Second), SecurityEnded, EndingSoon, false}, + // stale: what is to come is unknown, what ended is not + {"18 months and a day after", day(t, "2028-04-10"), SecurityEnded, Unknown, true}, + {"bookworm's last day, stale", day(t, "2028-06-30"), SecurityEnded, Unknown, true}, + // the table says it ended: a stale table says so too + {"the day after bookworm's, stale", day(t, "2028-07-01"), SecurityEnded, SecurityEnded, true}, + {"two years after", day(t, "2028-10-09"), SecurityEnded, SecurityEnded, true}, + {"far after", day(t, "2040-01-01"), SecurityEnded, SecurityEnded, true}, + // a clock that is wrong is not believed, whatever the state + {"the day before it was checked", day(t, "2026-10-08").Add(23 * time.Hour), Unknown, Unknown, true}, + {"the epoch", time.Unix(0, 0), Unknown, Unknown, true}, } { - if got := Evaluate(bullseye, c.now); got.State != c.want { - t.Errorf("%s: bullseye is %s, want %s", c.name, got.State, c.want) - } - if c.want == Unknown { - if got := Evaluate(bookworm, c.now); got.State != Unknown || got.Ends != "" || got.Next != nil || got.Extended != nil || got.Source != "" { - t.Errorf("%s: bookworm is %+v, want unknown and nothing else", c.name, got) + for _, box := range []struct { + name string + id Identity + want string + ended string + }{{"bullseye", bullseye, c.bullseye, "2026-08-31"}, {"bookworm", bookworm, c.bookworm, "2028-06-30"}} { + got := Evaluate(box.id, c.now) + if got.State != box.want { + t.Errorf("%s: %s is %s, want %s", c.name, box.name, got.State, box.want) + continue + } + switch { + case got.State == Unknown: + if got.Ends != "" || got.Precision != "" || got.Next != nil || got.Extended != nil || got.Source != "" { + t.Errorf("%s: %s is %+v, want unknown and nothing else", c.name, box.name, got) + } + case got.Ends != box.ended || got.Source == "": + t.Errorf("%s: %s says %+v, want the day it ends and its source", c.name, box.name, got) + case c.stale && (got.Extended != nil || got.Next != nil): + t.Errorf("%s: %s says extended %+v and next %+v of a table that is stale", c.name, box.name, got.Extended, got.Next) + case !c.stale && got.Next == nil: + t.Errorf("%s: %s lost its next release before the table is stale", c.name, box.name) } } } // the date it goes stale on moves with AsOf: it is a rule of the table, not a date of the code asOf(t, "2026-12-30") - if got := Evaluate(bullseye, day(t, "2028-06-30")); got.State != SecurityEnded { - t.Errorf("with the table checked on 2026-12-30, 2028-06-30 is %s", got.State) + if got := Evaluate(bookworm, day(t, "2028-06-30")); got.State != EndingSoon || got.Next == nil { + t.Errorf("with the table checked on 2026-12-30, bookworm on 2028-06-30 is %+v, want it ending soon and not stale yet", got) + } + if got := Evaluate(bookworm, day(t, "2028-07-01")); got.State != SecurityEnded || got.Next != nil { + t.Errorf("with the table checked on 2026-12-30, bookworm on 2028-07-01 is %+v, want it ended and stale", got) } - if got := Evaluate(bullseye, day(t, "2028-07-01")); got.State != Unknown { - t.Errorf("with the table checked on 2026-12-30, 2028-07-01 is %s", got.State) + if got := Evaluate(bullseye, day(t, "2028-07-01")); got.State != SecurityEnded || got.Next != nil { + t.Errorf("with the table checked on 2026-12-30, bullseye on 2028-07-01 is %+v", got) } } From 3297b862b4df5de9ca1bcfbb3dc639822812678c Mon Sep 17 00:00:00 2001 From: Gary Date: Sun, 11 Oct 2026 02:34:48 +0200 Subject: [PATCH 6/8] fix(ossupport): say the month where Canonical says the month Every Ubuntu date, the standard ones and the ESM ones, now has precision month, and Extended carries its own precision (day for Debian ELTS). The page the API cites (ubuntu.com/about/release-cycle) gives only a month for each of them, and Canonical's other pages disagree on the day and sometimes on the month; where they differ the earlier month is kept, the safe side for a warning. Ubuntu 22.04's end is now 2027-05-31 (the day before distro-info's 2027-06-01), so it reads 'around May 2027' as the cited page does, and its alert comes a day earlier. The other days are distro-info-data's, unchanged. --- pkg/alerts/os_support.go | 2 +- pkg/alerts/os_support_test.go | 12 +++--- pkg/ossupport/ossupport.go | 64 ++++++++++++++++++----------- pkg/ossupport/ossupport_test.go | 73 +++++++++++++++++++++++++-------- route/v1/system_os_test.go | 4 +- 5 files changed, 103 insertions(+), 52 deletions(-) diff --git a/pkg/alerts/os_support.go b/pkg/alerts/os_support.go index 08b47a29..1ce4fcb8 100644 --- a/pkg/alerts/os_support.go +++ b/pkg/alerts/os_support.go @@ -103,7 +103,7 @@ func osSupportSentence(report ossupport.Report) string { } // osSupportDate is the day in words, "31 August 2026", or the month with "around" for a date the -// sources do not agree on the day of: "around June 2027". +// vendor gives only the month of: "around May 2027". func osSupportDate(support ossupport.Support) string { end, err := time.Parse("2006-01-02", support.Ends) if err != nil { diff --git a/pkg/alerts/os_support_test.go b/pkg/alerts/os_support_test.go index 2011e165..2ac6e695 100644 --- a/pkg/alerts/os_support_test.go +++ b/pkg/alerts/os_support_test.go @@ -65,11 +65,11 @@ func TestTheSupportOfTheOperatingSystemIsToldInWords(t *testing.T) { "Debian 12 ending": {debian12, "2028-01-15", "Debian 12 stops receiving Debian security updates on 30 June 2028." + handoff}, "Ubuntu 22.04 ending, to the month": {ubuntu22, "2027-01-15", - "Ubuntu 22.04 stops receiving standard security updates around June 2027." + handoff}, + "Ubuntu 22.04 stops receiving standard security updates around May 2027." + handoff}, "Ubuntu 22.04 ended, to the month": {ubuntu22, "2027-06-02", - "Ubuntu 22.04 no longer receives standard security updates since around June 2027." + handoff}, + "Ubuntu 22.04 no longer receives standard security updates since around May 2027." + handoff}, "Ubuntu 20.04 ended": {ubuntu20, "2026-10-10", - "Ubuntu 20.04 no longer receives standard security updates since 29 May 2025." + handoff}, + "Ubuntu 20.04 no longer receives standard security updates since around May 2025." + handoff}, } { t.Run(name, func(t *testing.T) { h, o, now := newHub(t) @@ -97,7 +97,7 @@ func TestNothingIsToldForASupportedOrUnknownSystem(t *testing.T) { day string }{ "Debian 12, supported": {debian12, "2026-10-10"}, - "Ubuntu 22.04, supported": {ubuntu22, "2026-12-02"}, + "Ubuntu 22.04, supported": {ubuntu22, "2026-12-01"}, "a Mint, which is not known": {mint, "2026-10-10"}, "nothing read": {nil, "2026-10-10"}, "a table gone stale, a Debian 12": {debian12, "2028-05-01"}, @@ -163,7 +163,7 @@ func TestAStateIsToldAgainOnlyWhenItChanges(t *testing.T) { step("2026-12-03") step("2026-12-04") step("2027-05-31") - if got := o.texts(phone); len(got) != 1 || !strings.HasPrefix(got[0], "Ubuntu 22.04 stops receiving standard security updates around June 2027.") { + if got := o.texts(phone); len(got) != 1 || !strings.HasPrefix(got[0], "Ubuntu 22.04 stops receiving standard security updates around May 2027.") { t.Fatalf("ending: sent %q", got) } if got := osMarker(h); got != "ubuntu-22.04-ending_soon\n" { @@ -174,7 +174,7 @@ func TestAStateIsToldAgainOnlyWhenItChanges(t *testing.T) { step("2027-06-02") step("2027-06-03") got := o.texts(phone) - if len(got) != 2 || !strings.HasPrefix(got[1], "Ubuntu 22.04 no longer receives standard security updates since around June 2027.") { + if len(got) != 2 || !strings.HasPrefix(got[1], "Ubuntu 22.04 no longer receives standard security updates since around May 2027.") { t.Fatalf("ended: sent %q", got) } if got := osMarker(h); got != "ubuntu-22.04-security_ended\n" { diff --git a/pkg/ossupport/ossupport.go b/pkg/ossupport/ossupport.go index 381c373b..ee1de4ca 100644 --- a/pkg/ossupport/ossupport.go +++ b/pkg/ossupport/ossupport.go @@ -31,7 +31,9 @@ const ( Unknown = "unknown" ) -// The precisions of Support.Ends: the sources agree on the day, or only on the month. +// The precisions of Support.Ends: the vendor says the day, or only the month. A date the vendor's +// page gives as a month ("May 2027") is PrecisionMonth, whatever day another source puts in it, and +// is shown as the month. const ( PrecisionDay = "day" PrecisionMonth = "month" @@ -66,10 +68,12 @@ type Identity struct { } // Extended is the vendor's later, separate programme of security maintenance. Only its name and -// its last day are said: what it costs, and for whom, is the vendor's to say. +// its last day are said: what it costs, and for whom, is the vendor's to say. Precision is that of +// Ends, as for a Support. type Extended struct { - Name string `json:"name"` - Ends string `json:"ends"` + Name string `json:"name"` + Ends string `json:"ends"` + Precision string `json:"precision"` } // Next is the release after this one. State is StateReport: it is information only. @@ -85,7 +89,7 @@ type Next struct { type Support struct { State string `json:"state"` // Ends is the last day of the free security updates, "2006-01-02", with Precision "day"; with - // "month" the sources do not agree on the day, and only the month is to be shown. + // "month" the vendor says only the month, and only the month is to be shown. Ends string `json:"ends,omitempty"` Precision string `json:"precision,omitempty"` Extended *Extended `json:"extended,omitempty"` @@ -116,11 +120,22 @@ type entry struct { // table is the whole of what is claimed, checked against its sources on AsOf. A box that is not // in it is "unknown". +// +// The Debian rows say the day, as the Debian pages do. The Ubuntu rows say only the month, because +// the page they cite (ubuntu.com/about/release-cycle) gives only a month for every end, the +// standard one and the extended one ("May 2027", "May 2032"), and the other pages disagree on the +// day and sometimes on the month. Where Canonical's pages differ (checked on 2026-10-11) the earlier +// month is the one kept, which is the side a warning is safe on: the release team's list says +// "Jun 2027" and "Jun 2029" for 22.04 and 24.04 where the release-cycle page says May, and +// distro-info-data and that list say April for the ESM of 20.04 where the release-cycle page and +// the 20.04 post say May 2030 (distro-info-data: April for all four). The day of an Ubuntu row is +// the one that carries that month: distro-info-data's, except for 22.04, whose 2027-06-01 is the day +// after its last (the release-cycle page gives 2027-05-31). var table = []entry{ { id: "debian", versionID: "11", codename: "bullseye", ends: "2026-08-31", precision: PrecisionDay, - extended: &Extended{Name: "Debian ELTS (Freexian)", Ends: "2031-06-30"}, + extended: &Extended{Name: "Debian ELTS (Freexian)", Ends: "2031-06-30", Precision: PrecisionDay}, sources: []string{"https://www.debian.org/News/2026/20260831", "https://wiki.debian.org/LTS/Extended"}, next: &Next{ID: "debian", VersionID: "12", Codename: "bookworm", PrettyName: "Debian 12 (bookworm)", State: StateReport}, }, @@ -139,35 +154,34 @@ var table = []entry{ sources: []string{"https://www.debian.org/releases/"}, }, { - // No upgrade path is offered: the installer never supported 20.04. The days of every Ubuntu - // row are distro-info-data's; the release-cycle page gives only months, and for the ESM - // of all four releases it says May where distro-info-data says April (the extended dates - // are shown as information and decide no state). + // No upgrade path is offered: the installer never supported 20.04. Its standard support ended + // "May 2025": the release team's post says 29 May, Canonical's blog 31 May. id: "ubuntu", versionID: "20.04", codename: "focal", - ends: "2025-05-29", precision: PrecisionDay, - extended: &Extended{Name: "Ubuntu Pro (ESM)", Ends: "2030-04-23"}, - sources: []string{"https://ubuntu.com/about/release-cycle", "https://git.launchpad.net/ubuntu/+source/distro-info-data/plain/ubuntu.csv?h=ubuntu/devel"}, + ends: "2025-05-29", precision: PrecisionMonth, + extended: &Extended{Name: "Ubuntu Pro (ESM)", Ends: "2030-04-23", Precision: PrecisionMonth}, + sources: []string{"https://ubuntu.com/about/release-cycle", "https://ubuntu.com/blog/20.04-end-of-standard-support", + "https://git.launchpad.net/ubuntu/+source/distro-info-data/plain/ubuntu.csv?h=ubuntu/devel"}, }, { - // the sources say June 1st (distro-info-data) and "May 2027" (release-cycle page): only the - // month is claimed id: "ubuntu", versionID: "22.04", codename: "jammy", - ends: "2027-06-01", precision: PrecisionMonth, - extended: &Extended{Name: "Ubuntu Pro (ESM)", Ends: "2032-04-21"}, - sources: []string{"https://ubuntu.com/about/release-cycle", "https://git.launchpad.net/ubuntu/+source/distro-info-data/plain/ubuntu.csv?h=ubuntu/devel"}, - next: &Next{ID: "ubuntu", VersionID: "24.04", Codename: "noble", PrettyName: "Ubuntu 24.04 LTS (noble)", State: StateReport}, + ends: "2027-05-31", precision: PrecisionMonth, + extended: &Extended{Name: "Ubuntu Pro (ESM)", Ends: "2032-04-21", Precision: PrecisionMonth}, + sources: []string{"https://ubuntu.com/about/release-cycle", "https://ubuntu.com/project/docs/release-team/list-of-releases/", + "https://git.launchpad.net/ubuntu/+source/distro-info-data/plain/ubuntu.csv?h=ubuntu/devel"}, + next: &Next{ID: "ubuntu", VersionID: "24.04", Codename: "noble", PrettyName: "Ubuntu 24.04 LTS (noble)", State: StateReport}, }, { id: "ubuntu", versionID: "24.04", codename: "noble", - ends: "2029-05-31", precision: PrecisionDay, - extended: &Extended{Name: "Ubuntu Pro (ESM)", Ends: "2034-04-25"}, - sources: []string{"https://ubuntu.com/about/release-cycle", "https://git.launchpad.net/ubuntu/+source/distro-info-data/plain/ubuntu.csv?h=ubuntu/devel"}, - next: &Next{ID: "ubuntu", VersionID: "26.04", Codename: "resolute", PrettyName: "Ubuntu 26.04 LTS (resolute)", State: StateReport}, + ends: "2029-05-31", precision: PrecisionMonth, + extended: &Extended{Name: "Ubuntu Pro (ESM)", Ends: "2034-04-25", Precision: PrecisionMonth}, + sources: []string{"https://ubuntu.com/about/release-cycle", "https://ubuntu.com/project/docs/release-team/list-of-releases/", + "https://git.launchpad.net/ubuntu/+source/distro-info-data/plain/ubuntu.csv?h=ubuntu/devel"}, + next: &Next{ID: "ubuntu", VersionID: "26.04", Codename: "resolute", PrettyName: "Ubuntu 26.04 LTS (resolute)", State: StateReport}, }, { id: "ubuntu", versionID: "26.04", codename: "resolute", - ends: "2031-05-29", precision: PrecisionDay, - extended: &Extended{Name: "Ubuntu Pro (ESM)", Ends: "2036-04-23"}, + ends: "2031-05-29", precision: PrecisionMonth, + extended: &Extended{Name: "Ubuntu Pro (ESM)", Ends: "2036-04-23", Precision: PrecisionMonth}, sources: []string{"https://ubuntu.com/about/release-cycle", "https://git.launchpad.net/ubuntu/+source/distro-info-data/plain/ubuntu.csv?h=ubuntu/devel"}, }, } diff --git a/pkg/ossupport/ossupport_test.go b/pkg/ossupport/ossupport_test.go index 41663824..96f2e256 100644 --- a/pkg/ossupport/ossupport_test.go +++ b/pkg/ossupport/ossupport_test.go @@ -165,13 +165,15 @@ func TestTheTableHoldsExactlyTheseEntries(t *testing.T) { source string } want := []row{ - {"debian", "11", "bullseye", "2026-08-31", "day", &Extended{"Debian ELTS (Freexian)", "2031-06-30"}, "debian 12", "https://www.debian.org/News/2026/20260831"}, + {"debian", "11", "bullseye", "2026-08-31", "day", &Extended{"Debian ELTS (Freexian)", "2031-06-30", "day"}, "debian 12", "https://www.debian.org/News/2026/20260831"}, {"debian", "12", "bookworm", "2028-06-30", "day", nil, "debian 13", "https://www.debian.org/News/2026/20260712"}, {"debian", "13", "trixie", "2030-06-30", "day", nil, "", "https://www.debian.org/releases/"}, - {"ubuntu", "20.04", "focal", "2025-05-29", "day", &Extended{"Ubuntu Pro (ESM)", "2030-04-23"}, "", "https://ubuntu.com/about/release-cycle"}, - {"ubuntu", "22.04", "jammy", "2027-06-01", "month", &Extended{"Ubuntu Pro (ESM)", "2032-04-21"}, "ubuntu 24.04", "https://ubuntu.com/about/release-cycle"}, - {"ubuntu", "24.04", "noble", "2029-05-31", "day", &Extended{"Ubuntu Pro (ESM)", "2034-04-25"}, "ubuntu 26.04", "https://ubuntu.com/about/release-cycle"}, - {"ubuntu", "26.04", "resolute", "2031-05-29", "day", &Extended{"Ubuntu Pro (ESM)", "2036-04-23"}, "", "https://ubuntu.com/about/release-cycle"}, + // Ubuntu says the month, for every end (see the table): the month shown is the earlier of + // the months Canonical's pages give + {"ubuntu", "20.04", "focal", "2025-05-29", "month", &Extended{"Ubuntu Pro (ESM)", "2030-04-23", "month"}, "", "https://ubuntu.com/about/release-cycle"}, + {"ubuntu", "22.04", "jammy", "2027-05-31", "month", &Extended{"Ubuntu Pro (ESM)", "2032-04-21", "month"}, "ubuntu 24.04", "https://ubuntu.com/about/release-cycle"}, + {"ubuntu", "24.04", "noble", "2029-05-31", "month", &Extended{"Ubuntu Pro (ESM)", "2034-04-25", "month"}, "ubuntu 26.04", "https://ubuntu.com/about/release-cycle"}, + {"ubuntu", "26.04", "resolute", "2031-05-29", "month", &Extended{"Ubuntu Pro (ESM)", "2036-04-23", "month"}, "", "https://ubuntu.com/about/release-cycle"}, } if len(table) != len(want) { t.Fatalf("the table has %d entries, want %d", len(table), len(want)) @@ -285,10 +287,10 @@ func TestTheAnswersOfTheTableOn20261010(t *testing.T) { {"debian", "11", "bullseye", SecurityEnded, "2026-08-31", "day"}, {"debian", "12", "bookworm", Supported, "2028-06-30", "day"}, {"debian", "13", "trixie", Supported, "2030-06-30", "day"}, - {"ubuntu", "20.04", "focal", SecurityEnded, "2025-05-29", "day"}, - {"ubuntu", "22.04", "jammy", Supported, "2027-06-01", "month"}, - {"ubuntu", "24.04", "noble", Supported, "2029-05-31", "day"}, - {"ubuntu", "26.04", "resolute", Supported, "2031-05-29", "day"}, + {"ubuntu", "20.04", "focal", SecurityEnded, "2025-05-29", "month"}, + {"ubuntu", "22.04", "jammy", Supported, "2027-05-31", "month"}, + {"ubuntu", "24.04", "noble", Supported, "2029-05-31", "month"}, + {"ubuntu", "26.04", "resolute", Supported, "2031-05-29", "month"}, // a codename left out is not a reason to doubt the version {"debian", "11", "", SecurityEnded, "2026-08-31", "day"}, } { @@ -297,13 +299,41 @@ func TestTheAnswersOfTheTableOn20261010(t *testing.T) { t.Errorf("%s %s: %+v, want %s ending %s (%s)", c.id, c.version, got, c.want, c.ends, c.precision) } } - // Ubuntu 22.04 turns to "ending soon" 180 days before 2027-06-01 + // Ubuntu 22.04 turns to "ending soon" 180 days before 2027-05-31, and to "ended" on 2027-06-01 jammy := Parse(release("ubuntu", "22.04", "jammy", ""), "amd64") - if got := Evaluate(jammy, day(t, "2026-12-02")).State; got != Supported { - t.Errorf("22.04 on 2026-12-02: %s", got) + for _, c := range []struct{ day, want string }{ + {"2026-12-01", Supported}, + {"2026-12-02", EndingSoon}, + {"2027-05-31", EndingSoon}, + {"2027-06-01", SecurityEnded}, + } { + if got := Evaluate(jammy, day(t, c.day)).State; got != c.want { + t.Errorf("22.04 on %s: %s, want %s", c.day, got, c.want) + } } - if got := Evaluate(jammy, day(t, "2026-12-03")).State; got != EndingSoon { - t.Errorf("22.04 on 2026-12-03: %s", got) +} + +// Every Ubuntu date is a month and every Debian date a day, the extended ones as the end they extend: +// the page Ubuntu's rows cite gives no day, and Debian's give one. A month-precision date is never +// shown as a day: its Extended says so too. +func TestEveryUbuntuDateIsAMonthAndEveryDebianDateADay(t *testing.T) { + asOf(t, "2020-01-01") + for _, e := range table { + want := PrecisionDay + if e.id == "ubuntu" { + want = PrecisionMonth + } + if e.precision != want { + t.Errorf("%s %s: precision %q, want %q", e.id, e.versionID, e.precision, want) + } + if e.extended != nil && e.extended.Precision != want { + t.Errorf("%s %s: extended precision %q, want %q", e.id, e.versionID, e.extended.Precision, want) + } + // the answer carries them + got := Evaluate(Parse(release(e.id, e.versionID, e.codename, ""), "amd64"), day(t, "2020-01-02")) + if got.Precision != want || (got.Extended != nil && got.Extended.Precision != want) { + t.Errorf("%s %s: answered %+v, want precision %s all through", e.id, e.versionID, got, want) + } } } @@ -403,10 +433,10 @@ func TestTheTableGoesStaleAfterEighteenMonths(t *testing.T) { func TestAnAnswerIsACopyOfTheTable(t *testing.T) { id := Parse(release("debian", "11", "bullseye", ""), "amd64") first := Evaluate(id, day(t, "2026-10-10")) - first.Extended.Name, first.Extended.Ends = "changed", "1999-01-01" + first.Extended.Name, first.Extended.Ends, first.Extended.Precision = "changed", "1999-01-01", "month" first.Next.PrettyName = "changed" second := Evaluate(id, day(t, "2026-10-10")) - if second.Extended.Name != "Debian ELTS (Freexian)" || second.Extended.Ends != "2031-06-30" || second.Next.PrettyName != "Debian 12 (bookworm)" { + if second.Extended.Name != "Debian ELTS (Freexian)" || second.Extended.Ends != "2031-06-30" || second.Extended.Precision != "day" || second.Next.PrettyName != "Debian 12 (bookworm)" { t.Fatalf("a caller changed the table: %+v %+v", second.Extended, second.Next) } } @@ -426,15 +456,22 @@ func TestTheReportsAsJSON(t *testing.T) { map[string]string{"ID": "debian", "VERSION_ID": "11", "VERSION_CODENAME": "bullseye", "PRETTY_NAME": "Debian GNU/Linux 11 (bullseye)"}, "amd64", "2026-10-10", `{"id":"debian","version_id":"11","codename":"bullseye","pretty_name":"Debian GNU/Linux 11 (bullseye)","arch":"amd64","known":true,` + - `"support":{"state":"security_ended","ends":"2026-08-31","precision":"day","extended":{"name":"Debian ELTS (Freexian)","ends":"2031-06-30"},"source":"https://www.debian.org/News/2026/20260831"},` + + `"support":{"state":"security_ended","ends":"2026-08-31","precision":"day","extended":{"name":"Debian ELTS (Freexian)","ends":"2031-06-30","precision":"day"},"source":"https://www.debian.org/News/2026/20260831"},` + `"next":{"id":"debian","version_id":"12","codename":"bookworm","pretty_name":"Debian 12 (bookworm)","state":"report"}}`, }, + { + "the same Debian 11 with a table that is stale: it has still ended, and nothing about what comes after", + map[string]string{"ID": "debian", "VERSION_ID": "11", "VERSION_CODENAME": "bullseye", "PRETTY_NAME": "Debian GNU/Linux 11 (bullseye)"}, + "amd64", "2028-05-01", + `{"id":"debian","version_id":"11","codename":"bullseye","pretty_name":"Debian GNU/Linux 11 (bullseye)","arch":"amd64","known":true,` + + `"support":{"state":"security_ended","ends":"2026-08-31","precision":"day","source":"https://www.debian.org/News/2026/20260831"}}`, + }, { "an Ubuntu 22.04 that ends soon, in month precision", map[string]string{"ID": "ubuntu", "ID_LIKE": "debian", "VERSION_ID": "22.04", "VERSION_CODENAME": "jammy", "PRETTY_NAME": "Ubuntu 22.04.5 LTS"}, "amd64", "2027-01-15", `{"id":"ubuntu","version_id":"22.04","codename":"jammy","pretty_name":"Ubuntu 22.04.5 LTS","arch":"amd64","known":true,` + - `"support":{"state":"ending_soon","ends":"2027-06-01","precision":"month","extended":{"name":"Ubuntu Pro (ESM)","ends":"2032-04-21"},"source":"https://ubuntu.com/about/release-cycle"},` + + `"support":{"state":"ending_soon","ends":"2027-05-31","precision":"month","extended":{"name":"Ubuntu Pro (ESM)","ends":"2032-04-21","precision":"month"},"source":"https://ubuntu.com/about/release-cycle"},` + `"next":{"id":"ubuntu","version_id":"24.04","codename":"noble","pretty_name":"Ubuntu 24.04 LTS (noble)","state":"report"}}`, }, { diff --git a/route/v1/system_os_test.go b/route/v1/system_os_test.go index 0081fcb6..aaf60764 100644 --- a/route/v1/system_os_test.go +++ b/route/v1/system_os_test.go @@ -43,7 +43,7 @@ func TestGetSystemOSAnswersWithTheEnvelopeAndTheExactData(t *testing.T) { arch: "amd64", now: time.Date(2026, 10, 10, 9, 30, 0, 0, time.UTC), }, `{"id":"debian","version_id":"11","codename":"bullseye","pretty_name":"Debian GNU/Linux 11 (bullseye)","arch":"amd64","known":true,` + - `"support":{"state":"security_ended","ends":"2026-08-31","precision":"day","extended":{"name":"Debian ELTS (Freexian)","ends":"2031-06-30"},"source":"https://www.debian.org/News/2026/20260831"},` + + `"support":{"state":"security_ended","ends":"2026-08-31","precision":"day","extended":{"name":"Debian ELTS (Freexian)","ends":"2031-06-30","precision":"day"},"source":"https://www.debian.org/News/2026/20260831"},` + `"next":{"id":"debian","version_id":"12","codename":"bookworm","pretty_name":"Debian 12 (bookworm)","state":"report"}}`, }, { @@ -53,7 +53,7 @@ func TestGetSystemOSAnswersWithTheEnvelopeAndTheExactData(t *testing.T) { arch: "amd64", now: time.Date(2027, 1, 15, 9, 30, 0, 0, time.UTC), }, `{"id":"ubuntu","version_id":"22.04","codename":"jammy","pretty_name":"Ubuntu 22.04.5 LTS","arch":"amd64","known":true,` + - `"support":{"state":"ending_soon","ends":"2027-06-01","precision":"month","extended":{"name":"Ubuntu Pro (ESM)","ends":"2032-04-21"},"source":"https://ubuntu.com/about/release-cycle"},` + + `"support":{"state":"ending_soon","ends":"2027-05-31","precision":"month","extended":{"name":"Ubuntu Pro (ESM)","ends":"2032-04-21","precision":"month"},"source":"https://ubuntu.com/about/release-cycle"},` + `"next":{"id":"ubuntu","version_id":"24.04","codename":"noble","pretty_name":"Ubuntu 24.04 LTS (noble)","state":"report"}}`, }, { From 04f4e49948ec1199c77c3be8e52d7a0844bd972f Mon Sep 17 00:00:00 2001 From: Gary Date: Sun, 11 Oct 2026 02:42:40 +0200 Subject: [PATCH 7/8] fix(ossupport): do not claim the systems that keep Debian's os-release Raspberry Pi OS (64-bit, and Bookworm and later), Armbian, DietPi, Proxmox VE and OpenMediaVault say ID=debian and have no ID_LIKE, so the strict ID rule could not tell them from a plain Debian: they were known, badged, offered 'Debian 12 is the next release' and a Debian guide, and alerted. Describe now takes a way to look for files (the seam is service's markerExists, os.Stat on the box) and finds them by /etc/rpi-issue, /etc/armbian-release, /boot/dietpi.txt or /etc/dietpi, /usr/bin/pveversion or /etc/pve, and /etc/openmediavault or /usr/sbin/omv-confdbadm; Armbian is also told by its os-release PRETTY_NAME. A variant is known:false, with a variant field, no support, no next and no alert. The package comment, which said ID_LIKE made the identity strict, and the invented DietPi, OpenMediaVault and Proxmox fixtures are replaced by what the real files say. --- pkg/alerts/os_support_test.go | 36 ++++++-- pkg/ossupport/ossupport.go | 78 ++++++++++++++-- pkg/ossupport/ossupport_test.go | 157 +++++++++++++++++++++++++++++--- route/v1/system_os_test.go | 32 ++++++- service/system_os.go | 25 ++++- service/system_os_test.go | 82 ++++++++++++++++- 6 files changed, 374 insertions(+), 36 deletions(-) diff --git a/pkg/alerts/os_support_test.go b/pkg/alerts/os_support_test.go index 2ac6e695..58995bfd 100644 --- a/pkg/alerts/os_support_test.go +++ b/pkg/alerts/os_support_test.go @@ -27,7 +27,7 @@ var ( // osFrom is the box of a release at the day of the hub's clock: the answer of the service, with the // real table. func osFrom(h *Hub, release map[string]string, now *time.Time) { - h.OSSupport = func() ossupport.Report { return ossupport.Describe(release, "amd64", *now) } + h.OSSupport = func() ossupport.Report { return ossupport.Describe(release, "amd64", *now, nil) } } // osMarker is what the marker file holds, "" when there is none. @@ -123,6 +123,30 @@ func TestNothingIsToldForASupportedOrUnknownSystem(t *testing.T) { } } +// A Raspberry Pi OS, a Proxmox and the like say Debian 11, whose security updates have ended, and are +// told nothing: they are not known, and Debian's dates are not theirs to be told. +func TestNothingIsToldForAVariant(t *testing.T) { + for variant, path := range map[string]string{ + "raspberrypi": "/etc/rpi-issue", "armbian": "/etc/armbian-release", "dietpi": "/boot/dietpi.txt", + "proxmox": "/usr/bin/pveversion", "openmediavault": "/etc/openmediavault", + } { + t.Run(variant, func(t *testing.T) { + h, o, now := newHub(t) + onDay(t, now, "2026-10-10") + h.OSSupport = func() ossupport.Report { + return ossupport.Describe(debian11, "arm64", *now, func(p string) bool { return p == path }) + } + + h.checkOSSupport() + h.sending.Wait() + + if len(o.sent) != 0 || osMarker(h) != "" { + t.Fatalf("sent %+v, marker %q; want nothing", o.sent, osMarker(h)) + } + }) + } +} + // One message, to each channel, for a state, however often it is looked at. func TestAStateIsToldOnce(t *testing.T) { h, o, now := newHub(t) @@ -324,7 +348,7 @@ func TestTheMarkerIsReplacedAtomically(t *testing.T) { func TestRacingLooksSendOnce(t *testing.T) { h, o, _ := newHub(t) h.OSSupport = func() ossupport.Report { - return ossupport.Describe(debian11, "amd64", time.Date(2026, 10, 10, 0, 0, 0, 0, time.UTC)) + return ossupport.Describe(debian11, "amd64", time.Date(2026, 10, 10, 0, 0, 0, 0, time.UTC), nil) } var looks atomic.Int64 base := time.Date(2026, 9, 25, 6, 0, 0, 0, time.UTC) @@ -476,7 +500,7 @@ func TestAStateIsToldOnceEvenIfTheMarkerCannotBeWritten(t *testing.T) { func TestTheSixQuietHoursDoNotHideTheNextState(t *testing.T) { h, o, _ := newHub(t) // the hub's clock stays at 06:00 on 2026-09-25 day := time.Date(2027, 5, 31, 0, 0, 0, 0, time.UTC) - h.OSSupport = func() ossupport.Report { return ossupport.Describe(ubuntu22, "amd64", day) } + h.OSSupport = func() ossupport.Report { return ossupport.Describe(ubuntu22, "amd64", day, nil) } h.checkOSSupport() day = day.AddDate(0, 0, 2) @@ -502,11 +526,11 @@ func TestTheDailyLookTellsWhatChangedAndStopsWithItsContext(t *testing.T) { n := looks.Add(1) switch { case n < 3: - return ossupport.Describe(ubuntu22, "amd64", time.Date(2026, 10, 10, 0, 0, 0, 0, time.UTC)) + return ossupport.Describe(ubuntu22, "amd64", time.Date(2026, 10, 10, 0, 0, 0, 0, time.UTC), nil) case n < 6: - return ossupport.Describe(ubuntu22, "amd64", time.Date(2027, 1, 15, 0, 0, 0, 0, time.UTC)) + return ossupport.Describe(ubuntu22, "amd64", time.Date(2027, 1, 15, 0, 0, 0, 0, time.UTC), nil) } - return ossupport.Describe(ubuntu22, "amd64", time.Date(2027, 6, 10, 0, 0, 0, 0, time.UTC)) + return ossupport.Describe(ubuntu22, "amd64", time.Date(2027, 6, 10, 0, 0, 0, 0, time.UTC), nil) } ctx, cancel := context.WithCancel(context.Background()) done := make(chan struct{}) diff --git a/pkg/ossupport/ossupport.go b/pkg/ossupport/ossupport.go index ee1de4ca..2c62c739 100644 --- a/pkg/ossupport/ossupport.go +++ b/pkg/ossupport/ossupport.go @@ -3,10 +3,15 @@ // upgrades anything, and nothing here asks the network, apt or the box's own tables (the answer // is the same on a box with a stale distro-info-data). // -// Three rules keep it from saying something false: +// Four rules keep it from saying something false: // - the identity is strict: ID must be exactly "debian" or "ubuntu". ID_LIKE is never looked at, // so Mint, Pop!_OS, Raspbian (32-bit, ID=raspbian) and the rest are not known, and nothing is // claimed; +// - but ID cannot tell a plain Debian or Ubuntu from the systems that keep its own os-release +// (ID=debian, no ID_LIKE): Raspberry Pi OS (64-bit, and Bookworm and later), Armbian, DietPi, +// Proxmox VE and OpenMediaVault. Those are found by marker files, read through the exists seam +// of Describe, and are not known either: the answer says which Variant it is, claims nothing, +// and offers no next release, because each of them upgrades by its own guide, or not in place; // - a release that is not in the table (Debian 14, Ubuntu 26.10, any non-LTS Ubuntu, 18.04) is // "unknown", not guessed; // - eighteen months after the day the table was last checked (AsOf) what is still to come is @@ -15,6 +20,7 @@ package ossupport import ( + "strings" "time" "unicode" ) @@ -63,8 +69,39 @@ type Identity struct { Codename string `json:"codename,omitempty"` PrettyName string `json:"pretty_name,omitempty"` Arch string `json:"arch,omitempty"` - // Known is true for ID "debian" or "ubuntu", exactly, and for nothing else. + // Known is true for ID "debian" or "ubuntu", exactly, when it is not a Variant, and for nothing + // else. Known bool `json:"known"` + // Variant is what a box that says ID "debian" or "ubuntu" really is, when it is not the plain + // distribution: one of the Variant constants. Such a box is not Known. + Variant string `json:"variant,omitempty"` +} + +// The variants, the values of Identity.Variant. +const ( + VariantRaspberryPi = "raspberrypi" + VariantArmbian = "armbian" + VariantDietPi = "dietpi" + VariantProxmox = "proxmox" + VariantOpenMediaVault = "openmediavault" +) + +// variantMarkers are the files that say a box is a variant, the first one found deciding: Raspberry +// Pi OS writes /etc/rpi-issue (its os-release is Debian's own, 64-bit and from Bookworm); Armbian +// /etc/armbian-release; DietPi /boot/dietpi.txt and /etc/dietpi; Proxmox VE has /usr/bin/pveversion +// and mounts /etc/pve; OpenMediaVault has /etc/openmediavault and /usr/sbin/omv-confdbadm. +// Sources: Raspberry Pi forum t=329094, Proxmox forum "we re-use the /etc/os-release file from +// Debian", the DietPi forum, docs.armbian.com/build-framework/switches/branding, and the packages' +// own file lists. +var variantMarkers = []struct { + variant string + paths []string +}{ + {VariantRaspberryPi, []string{"/etc/rpi-issue"}}, + {VariantArmbian, []string{"/etc/armbian-release"}}, + {VariantDietPi, []string{"/boot/dietpi.txt", "/etc/dietpi"}}, + {VariantProxmox, []string{"/usr/bin/pveversion", "/etc/pve"}}, + {VariantOpenMediaVault, []string{"/etc/openmediavault", "/usr/sbin/omv-confdbadm"}}, } // Extended is the vendor's later, separate programme of security maintenance. Only its name and @@ -189,10 +226,12 @@ var table = []entry{ // Parse is the identity of the box from its os-release and its architecture. It is strict: only an // ID of exactly "debian" or "ubuntu" is Known, and ID_LIKE is never looked at. Every field is // checked for length and characters before it goes anywhere (a log, a page, an alert): one that -// fails is left empty. +// fails is left empty. The only variant Parse can tell is Armbian, whose os-release names it (its +// PRETTY_NAME is " ", "Armbian-unofficial" when the vendor is not set); +// the others are told by their files, in Describe. func Parse(osRelease map[string]string, arch string) Identity { id := osRelease["ID"] - return Identity{ + identity := Identity{ ID: token(id, 32), VersionID: token(osRelease["VERSION_ID"], 32), Codename: token(osRelease["VERSION_CODENAME"], 32), @@ -200,6 +239,26 @@ func Parse(osRelease map[string]string, arch string) Identity { Arch: token(arch, 16), Known: id == "debian" || id == "ubuntu", } + if identity.Known && strings.HasPrefix(osRelease["PRETTY_NAME"], "Armbian") { + identity.Known, identity.Variant = false, VariantArmbian + } + return identity +} + +// variantOf is the variant whose marker file exists, or "". exists says whether a path exists on the +// box; nil means no file does. +func variantOf(exists func(path string) bool) string { + if exists == nil { + return "" + } + for _, m := range variantMarkers { + for _, path := range m.paths { + if exists(path) { + return m.variant + } + } + } + return "" } // token is s when it is 1 to limit characters of a-z, 0-9, ".", "_" and "-", and "" otherwise. @@ -289,9 +348,16 @@ func Evaluate(id Identity, now time.Time) Support { return unknown } -// Describe is Parse and Evaluate together, as the API answers them. -func Describe(osRelease map[string]string, arch string, now time.Time) Report { +// Describe is Parse, the look for a variant and Evaluate together, as the API answers them. exists +// says whether a path exists on the box (nil: none does); it is asked only when the box says +// "debian" or "ubuntu", and the variant it finds is not Known and has no support to claim. +func Describe(osRelease map[string]string, arch string, now time.Time, exists func(path string) bool) Report { id := Parse(osRelease, arch) + if id.Known { + if variant := variantOf(exists); variant != "" { + id.Known, id.Variant = false, variant + } + } support := Evaluate(id, now) return Report{Identity: id, Support: support, Next: support.Next} } diff --git a/pkg/ossupport/ossupport_test.go b/pkg/ossupport/ossupport_test.go index 96f2e256..3ad2330c 100644 --- a/pkg/ossupport/ossupport_test.go +++ b/pkg/ossupport/ossupport_test.go @@ -2,6 +2,7 @@ package ossupport import ( "encoding/json" + "slices" "strings" "testing" "time" @@ -40,16 +41,17 @@ func asOf(t *testing.T, s string) { func TestOnlyDebianAndUbuntuAreKnown(t *testing.T) { for name, r := range map[string]map[string]string{ // Every look-alike has an ID_LIKE of debian or ubuntu, and the version of the release it is - // built on: none of them is known, and none is told a status. + // built on: none of them is known, and none is told a status. (Raspberry Pi OS 64-bit, + // Armbian, DietPi, Proxmox VE and OpenMediaVault are not here: they keep Debian's own + // os-release, ID=debian and no ID_LIKE, and are told by their files, below.) "Linux Mint": {"ID": "linuxmint", "ID_LIKE": "ubuntu debian", "VERSION_ID": "21.3", "VERSION_CODENAME": "virginia"}, "Mint on jammy": {"ID": "linuxmint", "ID_LIKE": "ubuntu debian", "VERSION_ID": "22.04", "VERSION_CODENAME": "jammy"}, "Pop!_OS": {"ID": "pop", "ID_LIKE": "ubuntu debian", "VERSION_ID": "22.04", "VERSION_CODENAME": "jammy"}, "Zorin": {"ID": "zorin", "ID_LIKE": "ubuntu debian", "VERSION_ID": "17", "VERSION_CODENAME": "jammy"}, "elementary": {"ID": "elementary", "ID_LIKE": "ubuntu", "VERSION_ID": "7.1"}, - "Raspbian": {"ID": "raspbian", "ID_LIKE": "debian", "VERSION_ID": "11", "VERSION_CODENAME": "bullseye"}, - "DietPi": {"ID": "dietpi", "ID_LIKE": "debian", "VERSION_ID": "12", "VERSION_CODENAME": "bookworm"}, - "OpenMediaVault": {"ID": "openmediavault", "ID_LIKE": "debian", "VERSION_ID": "12", "VERSION_CODENAME": "bookworm"}, - "Proxmox-like": {"ID": "proxmox", "ID_LIKE": "debian", "VERSION_ID": "12", "VERSION_CODENAME": "bookworm"}, + "Raspbian 32-bit": {"ID": "raspbian", "ID_LIKE": "debian", "VERSION_ID": "11", "VERSION_CODENAME": "bullseye"}, + // the name of a variant is not an ID: only the files, and Armbian's own PRETTY_NAME, say it + "an Armbian ID": {"ID": "armbian", "VERSION_ID": "24.2.1", "PRETTY_NAME": "Armbian 24.2.1 bookworm"}, "Kali": {"ID": "kali", "ID_LIKE": "debian", "VERSION_ID": "2024.1"}, "Devuan": {"ID": "devuan", "ID_LIKE": "debian", "VERSION_ID": "5", "VERSION_CODENAME": "daedalus"}, "Ubuntu Core": {"ID": "ubuntu-core", "ID_LIKE": "ubuntu", "VERSION_ID": "22"}, @@ -66,10 +68,10 @@ func TestOnlyDebianAndUbuntuAreKnown(t *testing.T) { } { t.Run(name, func(t *testing.T) { id := Parse(r, "amd64") - if id.Known { - t.Fatalf("Parse(%v) is known: %+v", r, id) + if id.Known || id.Variant != "" { + t.Fatalf("Parse(%v) is known, or a variant of what it is not: %+v", r, id) } - if got := Describe(r, "amd64", day(t, "2026-10-10")); got.Support.State != Unknown || got.Next != nil || got.Support.Ends != "" || got.Support.Extended != nil { + if got := Describe(r, "amd64", day(t, "2026-10-10"), nil); got.Support.State != Unknown || got.Next != nil || got.Support.Ends != "" || got.Support.Extended != nil { t.Fatalf("Describe(%v) claims %+v, want nothing", r, got) } }) @@ -85,6 +87,139 @@ func TestOnlyDebianAndUbuntuAreKnown(t *testing.T) { } } +// has is a box on which exactly these paths exist. +func has(paths ...string) func(string) bool { + return func(path string) bool { return slices.Contains(paths, path) } +} + +// The systems that keep Debian's or Ubuntu's own os-release (ID=debian, no ID_LIKE, the +// distribution's own PRETTY_NAME) are not known, whatever it says: their files say what they are, the +// answer says which variant, and it claims nothing and offers no next release, since each of them +// upgrades by its own guide, or not in place. +func TestAVariantIsNotKnownWhateverItsOSReleaseSays(t *testing.T) { + now := day(t, "2026-10-10") + debian := func(version, codename string) map[string]string { + return map[string]string{"ID": "debian", "VERSION_ID": version, "VERSION_CODENAME": codename, "PRETTY_NAME": "Debian GNU/Linux " + version + " (" + codename + ")"} + } + for name, c := range map[string]struct { + release map[string]string + arch string + files []string + variant string + }{ + "Raspberry Pi OS 64-bit, Bookworm": {debian("12", "bookworm"), "arm64", []string{"/etc/rpi-issue"}, VariantRaspberryPi}, + "Raspberry Pi OS 64-bit, Bullseye": {debian("11", "bullseye"), "arm64", []string{"/etc/rpi-issue"}, VariantRaspberryPi}, + "Armbian, by its file": {debian("12", "bookworm"), "arm64", []string{"/etc/armbian-release"}, VariantArmbian}, + "DietPi, by /boot/dietpi.txt": {debian("12", "bookworm"), "arm64", []string{"/boot/dietpi.txt"}, VariantDietPi}, + "DietPi, by /etc/dietpi": {debian("12", "bookworm"), "amd64", []string{"/etc/dietpi"}, VariantDietPi}, + "Proxmox VE 7, by pveversion": {debian("11", "bullseye"), "amd64", []string{"/usr/bin/pveversion"}, VariantProxmox}, + "Proxmox VE 8, by its /etc/pve": {debian("12", "bookworm"), "amd64", []string{"/etc/pve"}, VariantProxmox}, + "OpenMediaVault, by its /etc dir": {debian("11", "bullseye"), "amd64", []string{"/etc/openmediavault"}, VariantOpenMediaVault}, + "OpenMediaVault, by omv-confdbadm": {debian("12", "bookworm"), "amd64", []string{"/usr/sbin/omv-confdbadm"}, VariantOpenMediaVault}, + "Ubuntu on a Raspberry Pi": {release("ubuntu", "22.04", "jammy", "Ubuntu 22.04.5 LTS"), "arm64", []string{"/etc/rpi-issue"}, VariantRaspberryPi}, + "Proxmox on top of a Raspberry Pi": {debian("12", "bookworm"), "arm64", []string{"/etc/pve", "/etc/rpi-issue"}, VariantRaspberryPi}, + "Armbian, by its PRETTY_NAME alone": {map[string]string{"ID": "debian", "VERSION_ID": "12", "VERSION_CODENAME": "bookworm", "PRETTY_NAME": "Armbian 24.11.0-trunk.81 bookworm"}, "arm64", nil, VariantArmbian}, + "Armbian-unofficial, no file": {map[string]string{"ID": "debian", "VERSION_ID": "12", "VERSION_CODENAME": "bookworm", "PRETTY_NAME": "Armbian-unofficial 24.2.1 bookworm"}, "arm64", nil, VariantArmbian}, + } { + t.Run(name, func(t *testing.T) { + got := Describe(c.release, c.arch, now, has(c.files...)) + if got.Known || got.Variant != c.variant { + t.Fatalf("known = %v, variant = %q, want not known and %q: %+v", got.Known, got.Variant, c.variant, got) + } + if got.Support.State != Unknown || got.Support.Ends != "" || got.Support.Precision != "" || got.Support.Extended != nil || got.Support.Source != "" || got.Next != nil { + t.Fatalf("claims %+v and next %+v, want nothing", got.Support, got.Next) + } + // who it says it is, it still says: the dashboard may show it + if got.ID != c.release["ID"] || got.VersionID != c.release["VERSION_ID"] || got.Codename != c.release["VERSION_CODENAME"] || got.Arch != c.arch { + t.Fatalf("identity = %+v, want what the os-release says", got.Identity) + } + }) + } +} + +// The exact answer of a Raspberry Pi OS 64-bit image, and of an Armbian that its PRETTY_NAME gives +// away: the variant is said, the support is not. +func TestTheReportOfAVariantAsJSON(t *testing.T) { + for _, c := range []struct { + name string + release map[string]string + files []string + want string + }{ + { + "Raspberry Pi OS 64-bit, Bookworm", + map[string]string{"ID": "debian", "VERSION_ID": "12", "VERSION_CODENAME": "bookworm", "PRETTY_NAME": "Debian GNU/Linux 12 (bookworm)"}, + []string{"/etc/rpi-issue"}, + `{"id":"debian","version_id":"12","codename":"bookworm","pretty_name":"Debian GNU/Linux 12 (bookworm)","arch":"arm64","known":false,"variant":"raspberrypi","support":{"state":"unknown"}}`, + }, + { + "Armbian", + map[string]string{"ID": "debian", "VERSION_ID": "12", "VERSION_CODENAME": "bookworm", "PRETTY_NAME": "Armbian 24.11.0-trunk.81 bookworm"}, + nil, + `{"id":"debian","version_id":"12","codename":"bookworm","pretty_name":"Armbian 24.11.0-trunk.81 bookworm","arch":"arm64","known":false,"variant":"armbian","support":{"state":"unknown"}}`, + }, + } { + got, err := json.Marshal(Describe(c.release, "arm64", day(t, "2026-10-10"), has(c.files...))) + if err != nil { + t.Fatal(err) + } + if string(got) != c.want { + t.Errorf("%s:\n got %s\nwant %s", c.name, got, c.want) + } + } +} + +// A plain Debian or Ubuntu, with none of the files, is known and has its status; the box is asked +// about exactly the files the table names, and about none for a system that does not say "debian" or +// "ubuntu". +func TestAPlainDebianOrUbuntuHasNoneOfTheMarkers(t *testing.T) { + var asked []string + exists := func(path string) bool { asked = append(asked, path); return false } + now := day(t, "2026-10-10") + for _, c := range []struct { + release map[string]string + want string + }{ + {release("debian", "11", "bullseye", "Debian GNU/Linux 11 (bullseye)"), SecurityEnded}, + {release("debian", "12", "bookworm", "Debian GNU/Linux 12 (bookworm)"), Supported}, + {release("ubuntu", "22.04", "jammy", "Ubuntu 22.04.5 LTS"), Supported}, + } { + asked = nil + got := Describe(c.release, "amd64", now, exists) + if !got.Known || got.Variant != "" || got.Support.State != c.want || got.Next == nil && c.release["VERSION_ID"] != "13" { + t.Errorf("%s %s with none of the files: %+v", c.release["ID"], c.release["VERSION_ID"], got) + } + slices.Sort(asked) + want := []string{"/boot/dietpi.txt", "/etc/armbian-release", "/etc/dietpi", "/etc/openmediavault", "/etc/pve", "/etc/rpi-issue", "/usr/bin/pveversion", "/usr/sbin/omv-confdbadm"} + if !slices.Equal(asked, want) { + t.Errorf("the box was asked about %v, want %v", asked, want) + } + } + asked = nil + if got := Describe(release("linuxmint", "21.3", "virginia", ""), "amd64", now, exists); got.Known || got.Variant != "" || len(asked) != 0 { + t.Errorf("a Mint: %+v, files asked %v: want not known, no variant and no file looked for", got, asked) + } + // a path that is not a marker says nothing, and a box with no seam has no files + if got := Describe(release("debian", "12", "bookworm", ""), "amd64", now, has("/etc/os-release", "/etc/debian_version")); !got.Known || got.Variant != "" { + t.Errorf("a Debian with ordinary files: %+v", got) + } + if got := Describe(release("debian", "12", "bookworm", ""), "amd64", now, nil); !got.Known || got.Variant != "" { + t.Errorf("a Debian with no way to look for files: %+v", got) + } +} + +// A variant is not told a status, however far the table is from its day: nothing leaks through the +// stale rule either. +func TestAVariantStaysUnknownWhateverTheDay(t *testing.T) { + r := release("debian", "11", "bullseye", "Debian GNU/Linux 11 (bullseye)") + for _, now := range []string{"2026-10-10", "2027-10-10", "2028-05-01", "2035-01-01"} { + got := Describe(r, "amd64", day(t, now), has("/usr/bin/pveversion")) + if got.Known || got.Support.State != Unknown || got.Next != nil { + t.Errorf("%s: %+v", now, got) + } + } +} + func TestParseKeepsWhatIsValidAndDropsWhatIsNot(t *testing.T) { good := Parse(release("debian", "11", "bullseye", "Debian GNU/Linux 11 (bullseye)"), "amd64") if want := (Identity{ID: "debian", VersionID: "11", Codename: "bullseye", PrettyName: "Debian GNU/Linux 11 (bullseye)", Arch: "amd64", Known: true}); good != want { @@ -352,13 +487,13 @@ func TestWhatIsNotInTheTableIsUnknown(t *testing.T) { "Debian 11 as trixie": release("debian", "11", "trixie", ""), "Debian 12 as 11": release("debian", "012", "bookworm", ""), } { - got := Describe(r, "amd64", now) + got := Describe(r, "amd64", now, nil) if got.Support.State != Unknown || got.Next != nil || got.Support.Ends != "" || got.Support.Source != "" { t.Errorf("%s: %+v, want unknown and nothing else", name, got.Support) } } // a Debian that is known is still known, and says so - if got := Describe(release("debian", "14", "forky", ""), "amd64", now); !got.Known { + if got := Describe(release("debian", "14", "forky", ""), "amd64", now, nil); !got.Known { t.Errorf("Debian 14 is Debian: %+v", got) } } @@ -493,7 +628,7 @@ func TestTheReportsAsJSON(t *testing.T) { `{"arch":"amd64","known":false,"support":{"state":"unknown"}}`, }, } { - got, err := json.Marshal(Describe(c.release, c.arch, day(t, c.now))) + got, err := json.Marshal(Describe(c.release, c.arch, day(t, c.now), nil)) if err != nil { t.Fatal(err) } diff --git a/route/v1/system_os_test.go b/route/v1/system_os_test.go index aaf60764..7469ee90 100644 --- a/route/v1/system_os_test.go +++ b/route/v1/system_os_test.go @@ -4,6 +4,7 @@ import ( "encoding/json" "net/http" "net/http/httptest" + "slices" "testing" "time" @@ -12,21 +13,24 @@ import ( "github.com/labstack/echo/v4" ) -// fakeOSService is a box with the os-release and the clock of the test, the rest of the service being -// the real answer of ossupport. +// fakeOSService is a box with the os-release, the files and the clock of the test, the rest of the +// service being the real answer of ossupport. type fakeOSService struct { service.SystemService release map[string]string + files []string // the paths that exist on the box, besides os-release arch string now time.Time } func (f *fakeOSService) GetSystemOS() ossupport.Report { - return ossupport.Describe(f.release, f.arch, f.now) + return ossupport.Describe(f.release, f.arch, f.now, func(path string) bool { return slices.Contains(f.files, path) }) } -// The envelope is the other handlers', and the data is exactly what the spec shows, for three boxes: -// the owner's Debian 11, an Ubuntu that is ending (to the month), and a Mint, which is not known. +// The envelope is the other handlers', and the data is exactly what the spec shows, for the boxes +// that matter: the owner's Debian 11, the same with a table that has gone stale, a Raspberry Pi OS +// 64-bit image (Debian's own os-release, told by /etc/rpi-issue: no claim and no next release), an +// Ubuntu that is ending (to the month), and a Mint, which is not known. func TestGetSystemOSAnswersWithTheEnvelopeAndTheExactData(t *testing.T) { original := service.MyService t.Cleanup(func() { service.MyService = original }) @@ -46,6 +50,24 @@ func TestGetSystemOSAnswersWithTheEnvelopeAndTheExactData(t *testing.T) { `"support":{"state":"security_ended","ends":"2026-08-31","precision":"day","extended":{"name":"Debian ELTS (Freexian)","ends":"2031-06-30","precision":"day"},"source":"https://www.debian.org/News/2026/20260831"},` + `"next":{"id":"debian","version_id":"12","codename":"bookworm","pretty_name":"Debian 12 (bookworm)","state":"report"}}`, }, + { + "Debian 11, with a table that has gone stale: it has still ended", + &fakeOSService{ + release: map[string]string{"ID": "debian", "VERSION_ID": "11", "VERSION_CODENAME": "bullseye", "PRETTY_NAME": "Debian GNU/Linux 11 (bullseye)"}, + arch: "amd64", now: time.Date(2028, 5, 1, 9, 30, 0, 0, time.UTC), + }, + `{"id":"debian","version_id":"11","codename":"bullseye","pretty_name":"Debian GNU/Linux 11 (bullseye)","arch":"amd64","known":true,` + + `"support":{"state":"security_ended","ends":"2026-08-31","precision":"day","source":"https://www.debian.org/News/2026/20260831"}}`, + }, + { + "Raspberry Pi OS 64-bit, which says Debian", + &fakeOSService{ + release: map[string]string{"ID": "debian", "VERSION_ID": "12", "VERSION_CODENAME": "bookworm", "PRETTY_NAME": "Debian GNU/Linux 12 (bookworm)"}, + files: []string{"/etc/rpi-issue"}, + arch: "arm64", now: time.Date(2026, 10, 10, 9, 30, 0, 0, time.UTC), + }, + `{"id":"debian","version_id":"12","codename":"bookworm","pretty_name":"Debian GNU/Linux 12 (bookworm)","arch":"arm64","known":false,"variant":"raspberrypi","support":{"state":"unknown"}}`, + }, { "Ubuntu 22.04, ending", &fakeOSService{ diff --git a/service/system_os.go b/service/system_os.go index 205e98e5..988e7879 100644 --- a/service/system_os.go +++ b/service/system_os.go @@ -1,6 +1,7 @@ package service import ( + "os" "runtime" "time" @@ -9,17 +10,31 @@ import ( ) // GetSystemOS is the operating system the box runs and how long its security updates last. It reads -// /etc/os-release and nothing else: no apt, no network, no lock. +// /etc/os-release, and looks for the files that tell a variant (Raspberry Pi OS, Proxmox...) from the +// plain distribution: no apt, no network, no lock. func (s *systemService) GetSystemOS() ossupport.Report { - return systemOS(commonfile.ReadOSRelease, runtime.GOARCH, time.Now()) + return systemOS(readOSRelease, markerExists, runtime.GOARCH, time.Now()) +} + +// What GetSystemOS reads, as vars, so that a test can say what is on the box: os-release, and the +// files that tell a variant. +var ( + readOSRelease = commonfile.ReadOSRelease + markerExists = pathExists +) + +// pathExists says whether path exists on this box, whatever it is. +func pathExists(path string) bool { + _, err := os.Stat(path) + return err == nil } // systemOS is GetSystemOS with what it reads and what day it is given. A file that cannot be read // is an operating system that is not known: the answer says so, and is not an error. -func systemOS(readOSRelease func() (map[string]string, error), arch string, now time.Time) ossupport.Report { - release, err := readOSRelease() +func systemOS(read func() (map[string]string, error), exists func(path string) bool, arch string, now time.Time) ossupport.Report { + release, err := read() if err != nil { release = nil } - return ossupport.Describe(release, arch, now) + return ossupport.Describe(release, arch, now, exists) } diff --git a/service/system_os_test.go b/service/system_os_test.go index 45364860..704285f1 100644 --- a/service/system_os_test.go +++ b/service/system_os_test.go @@ -3,6 +3,8 @@ package service import ( "encoding/json" "errors" + "os" + "path/filepath" "runtime" "strings" "testing" @@ -11,19 +13,22 @@ import ( "github.com/ReCasaOS/CasaOS/pkg/ossupport" ) +// none is a box that has none of the files that tell a variant. +func none(string) bool { return false } + func TestSystemOSReadsTheReleaseAndTheDay(t *testing.T) { now := time.Date(2026, 10, 10, 12, 0, 0, 0, time.UTC) read := func() (map[string]string, error) { return map[string]string{"ID": "debian", "VERSION_ID": "11", "VERSION_CODENAME": "bullseye", "PRETTY_NAME": "Debian GNU/Linux 11 (bullseye)"}, nil } - got := systemOS(read, "amd64", now) + got := systemOS(read, none, "amd64", now) if !got.Known || got.ID != "debian" || got.VersionID != "11" || got.Arch != "amd64" || got.Support.State != ossupport.SecurityEnded || got.Next == nil || got.Next.VersionID != "12" { t.Fatalf("systemOS = %+v", got) } // the day is the one it was given, and a clock from before the table was written is not believed - if got := systemOS(read, "amd64", time.Date(2026, 8, 30, 0, 0, 0, 0, time.UTC)); got.Support.State != ossupport.Unknown { + if got := systemOS(read, none, "amd64", time.Date(2026, 8, 30, 0, 0, 0, 0, time.UTC)); got.Support.State != ossupport.Unknown { t.Errorf("before the table was written: %s", got.Support.State) } } @@ -34,7 +39,11 @@ func TestSystemOSOfAFileThatCannotBeReadIsNotKnown(t *testing.T) { return map[string]string{"ID": "debian", "VERSION_ID": "11"}, errors.New("read /etc/os-release: permission denied") } - got := systemOS(read, "amd64", time.Date(2026, 10, 10, 0, 0, 0, 0, time.UTC)) + asked := false + got := systemOS(read, func(string) bool { asked = true; return true }, "amd64", time.Date(2026, 10, 10, 0, 0, 0, 0, time.UTC)) + if asked { + t.Error("the box was asked about its files with no os-release to say what it is") + } body, err := json.Marshal(got) if err != nil { @@ -45,6 +54,73 @@ func TestSystemOSOfAFileThatCannotBeReadIsNotKnown(t *testing.T) { } } +// A box that says Debian and has the marker of a variant is not known, whichever variant it is: the +// answer says which, claims nothing and offers no next release. The files are looked for through the +// seam, so that no real file is needed. +func TestSystemOSOfAVariantIsNotKnown(t *testing.T) { + read := func() (map[string]string, error) { + return map[string]string{"ID": "debian", "VERSION_ID": "11", "VERSION_CODENAME": "bullseye", "PRETTY_NAME": "Debian GNU/Linux 11 (bullseye)"}, nil + } + now := time.Date(2026, 10, 10, 12, 0, 0, 0, time.UTC) + for variant, path := range map[string]string{ + "raspberrypi": "/etc/rpi-issue", + "armbian": "/etc/armbian-release", + "dietpi": "/boot/dietpi.txt", + "proxmox": "/usr/bin/pveversion", + "openmediavault": "/etc/openmediavault", + } { + t.Run(variant, func(t *testing.T) { + body, err := json.Marshal(systemOS(read, func(p string) bool { return p == path }, "arm64", now)) + if err != nil { + t.Fatal(err) + } + want := `{"id":"debian","version_id":"11","codename":"bullseye","pretty_name":"Debian GNU/Linux 11 (bullseye)","arch":"arm64","known":false,"variant":"` + variant + `","support":{"state":"unknown"}}` + if string(body) != want { + t.Fatalf("systemOS = %s, want %s", body, want) + } + }) + } +} + +// What the seam says is true of the real machine: a file and a directory exist, what is not there +// does not. +func TestPathExists(t *testing.T) { + dir := t.TempDir() + file := filepath.Join(dir, "marker") + if err := os.WriteFile(file, []byte("x"), 0o600); err != nil { + t.Fatal(err) + } + for path, want := range map[string]bool{dir: true, file: true, filepath.Join(dir, "missing"): false, filepath.Join(file, "below"): false} { + if got := pathExists(path); got != want { + t.Errorf("pathExists(%q) = %v, want %v", path, got, want) + } + // and it is what the method looks with + if got := markerExists(path); got != want { + t.Errorf("markerExists(%q) = %v, want %v", path, got, want) + } + } +} + +// The method reads os-release and looks for the files of a variant through the two things it is +// given: a Debian whose /etc/rpi-issue exists is a Raspberry Pi OS, and a box that has no such file is +// the plain Debian. +func TestGetSystemOSUsesTheFilesOfTheMachine(t *testing.T) { + readBefore, existsBefore := readOSRelease, markerExists + t.Cleanup(func() { readOSRelease, markerExists = readBefore, existsBefore }) + readOSRelease = func() (map[string]string, error) { + return map[string]string{"ID": "debian", "VERSION_ID": "12", "VERSION_CODENAME": "bookworm"}, nil + } + + markerExists = func(path string) bool { return path == "/etc/rpi-issue" } + if got := (&systemService{}).GetSystemOS(); got.Known || got.Variant != "raspberrypi" { + t.Errorf("with /etc/rpi-issue: %+v, want a Raspberry Pi OS that is not known", got) + } + markerExists = func(string) bool { return false } + if got := (&systemService{}).GetSystemOS(); !got.Known || got.Variant != "" { + t.Errorf("with none of the files: %+v, want the plain Debian", got) + } +} + // The method itself reads the real file of the machine the test runs on: whatever it is, the answer // is whole, and the architecture is Go's. func TestGetSystemOSAnswersForThisMachine(t *testing.T) { From b9213d17a9d440a6cd25810ad889a9732f71ea81 Mon Sep 17 00:00:00 2001 From: Gary Date: Sun, 11 Oct 2026 04:10:14 +0200 Subject: [PATCH 8/8] test(alerts): the channels are sent to from their own goroutines, so the test's list needs its lock go test -race found it in CI: two sends appended to the same slice. --- pkg/alerts/os_support_test.go | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/pkg/alerts/os_support_test.go b/pkg/alerts/os_support_test.go index 58995bfd..8fb4389d 100644 --- a/pkg/alerts/os_support_test.go +++ b/pkg/alerts/os_support_test.go @@ -294,15 +294,22 @@ func TestTheMarkerIsWrittenOnceTheMessageWentOut(t *testing.T) { h, o, now := newHub(t) osFrom(h, debian11, now) onDay(t, now, "2026-10-10") + // the channels are sent to from their own goroutines + var mu sync.Mutex var seen []string h.Send = func(rawURL, title, text string) error { - seen = append(seen, osMarker(h)) + marker := osMarker(h) + mu.Lock() + seen = append(seen, marker) + mu.Unlock() return o.send(rawURL, title, text) } h.checkOSSupport() h.sending.Wait() + mu.Lock() + defer mu.Unlock() if len(o.texts(phone)) != 1 || len(seen) != 2 { t.Fatalf("sent %q, the channels were sent to %d times", o.texts(phone), len(seen)) }