Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions .github/eol-notifier/dependencies.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what about amazonlinux:2023 and rockylinux:9?
eoas in less than a year

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

per Slack, we should skip it

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.
Expand Down
4 changes: 4 additions & 0 deletions eol-notifier/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
32 changes: 32 additions & 0 deletions eol-notifier/cmd/notifier/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand All @@ -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 {
Expand Down
29 changes: 29 additions & 0 deletions eol-notifier/cmd/notifier/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
Expand Down
35 changes: 22 additions & 13 deletions eol-notifier/cmd/notifier/lifecycle.go
Original file line number Diff line number Diff line change
Expand Up @@ -150,19 +150,21 @@

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
}
Expand Down Expand Up @@ -343,19 +345,26 @@
}

// 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 {
if dependency.Product != product {
continue
}
if phase != "" && !dependency.tracks(phase) {
continue
}
if cycle != "" && !dependency.tracksCycle(cycle) {
continue
}

Check warning on line 367 in eol-notifier/cmd/notifier/lifecycle.go

View check run for this annotation

probelabs / Visor: performance

performance Issue

The `dependenciesFor` function is called from within a loop that iterates over all products in `detectAlerts`. The function itself then iterates over the entire list of dependencies from the configuration on every call. This results in an O(N*M) complexity where N is the number of products and M is the number of dependencies. While the current number of dependencies is small, this pattern is inefficient and will scale poorly if the configuration grows.
Raw output
To optimize this, pre-process the dependencies into a map where keys are product names and values are lists of dependencies for that product. This can be done once before the product loop in `detectAlerts`. The `dependenciesFor` function can then operate on the much smaller, pre-filtered list of dependencies for a specific product, changing the overall complexity to be closer to O(N+M).
refs = append(refs, DependencyRef{Name: dependency.Name, UpstreamProxy: dependency.UpstreamProxy})
}

Expand Down
138 changes: 138 additions & 0 deletions eol-notifier/cmd/notifier/lifecycle_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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"},
Expand Down
Loading