diff --git a/.github/eol-notifier/dependencies.yaml b/.github/eol-notifier/dependencies.yaml index 7c90d74..1cdec68 100644 --- a/.github/eol-notifier/dependencies.yaml +++ b/.github/eol-notifier/dependencies.yaml @@ -27,6 +27,38 @@ dependencies: product: mariadb track: [eol, eoes] + # RPM: RHEL 7, 8, 9, and 10. Per policy, a new major LTS is added to the + # validated matrix once the vendor releases it, without waiting for a Tyk + # LTS. eoas (Full Support) ends years before eol (Maintenance Support), and + # eoes (Extended Life Cycle Support) years after; tracking eol alone would + # miss the Full Support cliff and call a version dead long before ELS + # actually ends it. + - name: RPM (RHEL 7, 8, 9, 10) + product: rhel + track: [eoas, eol, eoes] + cycles: ["7", "8", "9", "10"] + # DEB: Ubuntu 22.04 LTS, 24.04 LTS, 26.04 LTS, and Debian 12 (Bookworm), + # 13 (Trixie). Debian's eoes (Debian LTS) runs well past its eol (Debian + # Security Support), so eol alone would call a release dead years early. + - name: DEB (Ubuntu 22.04 LTS, 24.04 LTS, 26.04 LTS) + product: ubuntu + track: [eol, eoes] + cycles: ["22.04", "24.04", "26.04"] + - name: DEB (Debian 12 Bookworm, 13 Trixie) + product: debian + track: [eol, eoes] + cycles: ["12", "13"] + + # EE-FIPS validated set: RHEL 7, 8, and 9, and Ubuntu 24.04 only. + - name: EE-FIPS (RHEL 7, 8, 9) + product: rhel + track: [eoas, eol, eoes] + cycles: ["7", "8", "9"] + - name: EE-FIPS (Ubuntu 24.04) + product: ubuntu + track: [eol, eoes] + cycles: ["24.04"] + # HashiCorp tooling. A version gets its end-of-life date on the day a newer # release drops it out of support, so the date is never in the future: expect # the ended alert from these, not the 12/6/1 month countdown. diff --git a/eol-notifier/README.md b/eol-notifier/README.md index 842dd27..b10e6c4 100644 --- a/eol-notifier/README.md +++ b/eol-notifier/README.md @@ -69,6 +69,10 @@ Each entry under `dependencies` takes these keys: this phase, but a version keeps no date until the vendor announces one. - `eoas` is the end of active support. For example `redis` and `valkey`. - `eoes` is the end of extended support. For example `amazon-rds-postgresql`. +- `cycles` restricts tracking to the named release cycles, using the same names + endoflife.date does, e.g. `["7", "8", "9"]` for RHEL or `["22.04", "24.04"]` + for Ubuntu. Omit it to track every cycle the product publishes, including + ones added after this config was written. - `upstream_proxy` is for a service that `endoflife.date` does not track. The action then uses the dates of the open source engine under it. GCP MemoryStore uses `redis`, GCP Cloud SQL uses `postgresql`, and Azure DocumentDB uses diff --git a/eol-notifier/cmd/notifier/config.go b/eol-notifier/cmd/notifier/config.go index a53d7cc..2bee9ed 100644 --- a/eol-notifier/cmd/notifier/config.go +++ b/eol-notifier/cmd/notifier/config.go @@ -39,6 +39,9 @@ type Dependency struct { Product string `yaml:"product"` // Track lists the lifecycle phases to alert on. Defaults to [eol]. Track []string `yaml:"track"` + // Cycles restricts tracking to the named release cycles, e.g. ["7", "8", "9"] + // for RHEL. Empty means every cycle the product publishes. + Cycles []string `yaml:"cycles"` // UpstreamProxy marks a dependency whose lifecycle is not published by its // vendor, so the upstream OSS engine is used as a stand-in. Its dates are // indicative only and are labelled as such in the alert. @@ -142,6 +145,19 @@ func (c *DependencyConfig) validate() error { } seenPhase[phase] = true } + + seenCycle := make(map[string]bool, len(dep.Cycles)) + for i := range dep.Cycles { + dep.Cycles[i] = strings.TrimSpace(dep.Cycles[i]) + cycle := dep.Cycles[i] + if cycle == "" { + return fmt.Errorf("dependency %q has an empty cycle", dep.Name) + } + if seenCycle[cycle] { + return fmt.Errorf("dependency %q lists cycle %q more than once", dep.Name, cycle) + } + seenCycle[cycle] = true + } } return nil @@ -158,6 +174,22 @@ func (d Dependency) tracks(phase string) bool { return false } +// tracksCycle reports whether the dependency covers the given release cycle. +// An empty Cycles list means every cycle the product publishes. +func (d Dependency) tracksCycle(cycle string) bool { + if len(d.Cycles) == 0 { + return true + } + + for _, tracked := range d.Cycles { + if tracked == cycle { + return true + } + } + + return false +} + func isKnownPhase(phase string) bool { for _, known := range phaseOrder { if phase == known { diff --git a/eol-notifier/cmd/notifier/config_test.go b/eol-notifier/cmd/notifier/config_test.go index 9ab4b2e..bbe8425 100644 --- a/eol-notifier/cmd/notifier/config_test.go +++ b/eol-notifier/cmd/notifier/config_test.go @@ -117,6 +117,35 @@ dependencies: - name: Redis product: redis upstream-proxy: true +`, + wantErr: true, + }, + { + name: "cycles restricts tracking to named release cycles", + contents: ` +dependencies: + - name: RPM (RHEL 7, 8, 9) + product: rhel + cycles: ["7", "8", "9"] +`, + }, + { + name: "empty cycle is rejected", + contents: ` +dependencies: + - name: Redis + product: redis + cycles: ["7", ""] +`, + wantErr: true, + }, + { + name: "duplicate cycle is rejected", + contents: ` +dependencies: + - name: Redis + product: redis + cycles: ["7", "7"] `, wantErr: true, }, diff --git a/eol-notifier/cmd/notifier/lifecycle.go b/eol-notifier/cmd/notifier/lifecycle.go index 03d1d34..3d896ce 100644 --- a/eol-notifier/cmd/notifier/lifecycle.go +++ b/eol-notifier/cmd/notifier/lifecycle.go @@ -150,19 +150,21 @@ func detectAlerts(config *DependencyConfig, products map[string]*Product, state for _, release := range product.Releases { if !baseline && !seen[release.Name] && !release.IsEOL { - report.NewVersions = append(report.NewVersions, NewVersionAlert{ - Product: name, - ProductLabel: product.Label, - ProductURL: product.Links.HTML, - Release: releaseLabel(release), - ReleaseDate: release.ReleaseDate, - IsLTS: release.IsLTS, - Dependencies: dependenciesFor(config, name, ""), - }) + if dependencies := dependenciesFor(config, name, "", ""); len(dependencies) > 0 { + report.NewVersions = append(report.NewVersions, NewVersionAlert{ + Product: name, + ProductLabel: product.Label, + ProductURL: product.Links.HTML, + Release: releaseLabel(release), + ReleaseDate: release.ReleaseDate, + IsLTS: release.IsLTS, + Dependencies: dependencies, + }) + } } for _, phase := range phaseOrder { - dependencies := dependenciesFor(config, name, phase) + dependencies := dependenciesFor(config, name, phase, release.Name) if len(dependencies) == 0 { continue } @@ -343,9 +345,13 @@ func productOrder(config *DependencyConfig) []string { } // dependenciesFor returns the dependencies backed by a product. An empty phase -// matches every dependency, which is what new-version alerts want; otherwise -// only the dependencies configured to track that phase are returned. -func dependenciesFor(config *DependencyConfig, product, phase string) []DependencyRef { +// matches every dependency regardless of tracked phase, and an empty cycle +// matches every dependency regardless of tracked cycles; both are empty for +// new-version alerts, which must fire for a cycle a dependency's `cycles` +// filter excludes, so the vendor shipping something the config doesn't know +// about yet is never silently absorbed into "seen" without ever being +// announced. +func dependenciesFor(config *DependencyConfig, product, phase, cycle string) []DependencyRef { var refs []DependencyRef for _, dependency := range config.Dependencies { @@ -355,6 +361,9 @@ func dependenciesFor(config *DependencyConfig, product, phase string) []Dependen if phase != "" && !dependency.tracks(phase) { continue } + if cycle != "" && !dependency.tracksCycle(cycle) { + continue + } refs = append(refs, DependencyRef{Name: dependency.Name, UpstreamProxy: dependency.UpstreamProxy}) } diff --git a/eol-notifier/cmd/notifier/lifecycle_test.go b/eol-notifier/cmd/notifier/lifecycle_test.go index f16fdd8..69d4a9e 100644 --- a/eol-notifier/cmd/notifier/lifecycle_test.go +++ b/eol-notifier/cmd/notifier/lifecycle_test.go @@ -447,6 +447,109 @@ func TestDetectAlertsTracksConfiguredPhasesOnly(t *testing.T) { } } +// TestDetectAlertsTracksConfiguredCyclesOnly covers restricting a dependency to +// specific release cycles, e.g. RHEL 7/8/9 out of every cycle rhel publishes. +func TestDetectAlertsTracksConfiguredCyclesOnly(t *testing.T) { + products := map[string]*Product{ + "rhel": { + Name: "rhel", + Label: "Red Hat Enterprise Linux", + Releases: []Release{ + {Name: "6", EOLFrom: strPtr("2027-02-28")}, + {Name: "9", EOLFrom: strPtr("2027-02-28")}, + }, + }, + } + + config := testConfig(t, Dependency{ + Name: "RPM (RHEL 7, 8, 9)", + Product: "rhel", + Cycles: []string{"7", "8", "9"}, + }) + state := tracked("rhel", "6", "9") + + report, _ := detectAlerts(config, products, state, mustDate(t, "2027-02-28"), false) + + if len(report.EOL) != 1 { + t.Fatalf("got %d alert(s), want 1 for cycle 9 only", len(report.EOL)) + } + if report.EOL[0].Release != "9" { + t.Errorf("release = %q, want 9", report.EOL[0].Release) + } +} + +// TestDetectAlertsDistroTracksEveryPhase guards against a distro looking dead +// years early: RHEL publishes eoas well before eol, and eol well before eoes, +// and a config tracking eol alone would report the eol date as if it were the +// end, silently missing the eoas warning and skipping the eoes date entirely. +// Each phase is checked at its own 12-month mark, so a leak from one phase's +// window into another's would show up as an unexpected second alert. +func TestDetectAlertsDistroTracksEveryPhase(t *testing.T) { + release := Release{ + Name: "9", + EOASFrom: strPtr("2027-05-31"), // Full Support + EOLFrom: strPtr("2032-05-31"), // Maintenance Support + EOESFrom: strPtr("2036-05-31"), // Extended Life Cycle Support + } + + config := testConfig(t, Dependency{ + Name: "RPM (RHEL 7, 8, 9)", + Product: "rhel", + Track: []string{phaseEOAS, phaseEOL, phaseEOES}, + Cycles: []string{"9"}, + }) + + tests := []struct { + name string + today string + wantPhase string + wantMonths int + }{ + {"eoas 12 months out", "2026-05-31", phaseEOAS, 12}, + {"eol 12 months out", "2031-05-31", phaseEOL, 12}, + {"eoes 12 months out", "2035-05-31", phaseEOES, 12}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + products := map[string]*Product{ + "rhel": {Name: "rhel", Label: "Red Hat Enterprise Linux", Releases: []Release{release}}, + } + state := tracked("rhel", "9") + + report, _ := detectAlerts(config, products, state, mustDate(t, tt.today), false) + + var got *EOLAlert + for i := range report.EOL { + if report.EOL[i].Phase == tt.wantPhase { + got = &report.EOL[i] + } + } + if got == nil { + t.Fatalf("no alert for phase %q, got: %+v", tt.wantPhase, report.EOL) + } + if got.Ended { + t.Errorf("phase %q already ended by %s, want a 12-month countdown", tt.wantPhase, tt.today) + } + if got.MonthsLeft != tt.wantMonths { + t.Errorf("months left = %d, want %d", got.MonthsLeft, tt.wantMonths) + } + + // Every phase whose date has already passed by today correctly + // reports as ended - only the phase under test must still be + // counting down, so no other alert may fire at the same date. + for _, alert := range report.EOL { + if alert.Phase == tt.wantPhase { + continue + } + if !alert.Ended { + t.Errorf("unexpected live countdown for phase %q at %s: %+v", alert.Phase, tt.today, alert) + } + } + }) + } +} + // TestDetectAlertsGroupsSharedProduct covers the upstream-fallback mapping: a // managed service the API does not track rides on the upstream engine, and both // dependencies must appear on one alert rather than producing two. @@ -559,6 +662,41 @@ func TestDetectAlertsNewVersions(t *testing.T) { } } +// TestDetectAlertsNewVersionsIgnoreCycleFilter guards against a new-version +// alert going silent forever: a dependency's `cycles` scopes which cycles it +// tracks for EOL, but the vendor shipping a cycle outside that scope is still +// news the first time it is seen. If the new-version check were filtered by +// cycles too, recordRun would mark the release seen the same run and it could +// never be announced later, even after cycles was updated to include it. +func TestDetectAlertsNewVersionsIgnoreCycleFilter(t *testing.T) { + products := map[string]*Product{ + "rhel": { + Name: "rhel", + Label: "Red Hat Enterprise Linux", + Releases: []Release{ + {Name: "9", ReleaseDate: "2022-05-17", EOLFrom: strPtr("2032-05-31")}, + {Name: "10", ReleaseDate: "2025-05-20", EOLFrom: strPtr("2035-05-31")}, + }, + }, + } + + config := testConfig(t, Dependency{ + Name: "RPM (RHEL 7, 8, 9)", + Product: "rhel", + Cycles: []string{"7", "8", "9"}, + }) + state := tracked("rhel", "9") + + report, _ := detectAlerts(config, products, state, mustDate(t, "2026-01-01"), false) + + if len(report.NewVersions) != 1 || report.NewVersions[0].Release != "10" { + t.Fatalf("new versions = %+v, want a single alert for cycle 10", report.NewVersions) + } + if len(report.NewVersions[0].Dependencies) != 1 || report.NewVersions[0].Dependencies[0].Name != "RPM (RHEL 7, 8, 9)" { + t.Errorf("dependencies = %+v, want RPM (RHEL 7, 8, 9) named even though cycle 10 is outside its cycles", report.NewVersions[0].Dependencies) + } +} + func TestProductOrderDeduplicates(t *testing.T) { config := testConfig(t, Dependency{Name: "Redis", Product: "redis"},