diff --git a/pkg/eol/endoflife/ADAPTERS.md b/pkg/eol/endoflife/ADAPTERS.md index e56a5bc..b182403 100644 --- a/pkg/eol/endoflife/ADAPTERS.md +++ b/pkg/eol/endoflife/ADAPTERS.md @@ -1,147 +1,124 @@ -# Schema Adapters — and why EKS needs its own +# Schema Adapters — and why EKS still needs its own `endoflife.date` is the single upstream source for every EOL provider in -Version Guard, but it is **not** a uniform schema. Most products use the -"standard" cycle shape; a handful use product-specific semantics where -the same field name means a different thing. The `SchemaAdapter` +Version Guard, but it is **not** a uniform schema. Most products use +the "standard" cycle shape; a handful use product-specific semantics +where the same field name means a different thing. The `SchemaAdapter` interface in [adapters.go](./adapters.go) is the seam where those deviations are absorbed so the rest of Version Guard sees a single, canonical `types.VersionLifecycle`. -This doc exists because the EKS deviation is the kind of thing that -will silently mis-classify clusters in production if you wire it up the -"obvious" way. +This doc exists because EKS is the kind of deviation that will silently +mis-classify clusters in production if you wire it up the "obvious" +way. --- ## The standard schema (what most products look like) -Example cycle for `amazon-aurora-postgresql`: +Three real-world cycle shapes are all handled by the single +`StandardSchemaAdapter`: + +### 1. Plain OSS (PostgreSQL, etc.) + +```json +{ + "cycle": "15", + "support": "2027-11-09", + "eol": "2027-11-09" +} +``` + +`support` = end of standard support, `eol` = true end of life. There +is no extended-support concept; past `eol` is RED, past `support` but +before `eol` (if they differ) is YELLOW (deprecated). + +### 2. Aurora pattern (support + eol + extendedSupport date) ```json { "cycle": "17", - "releaseDate": "2025-02-20", "support": "2030-02-28", "eol": "2030-02-28", "extendedSupport": "2033-02-28" } ``` -`StandardSchemaAdapter` maps these as you would expect: +`support` = end of standard support, `extendedSupport` = end of paid +extended support and **the true terminal date**. Past `support` but +before `extendedSupport` is in extended support (YELLOW); past +`extendedSupport` is RED. -| endoflife.date field | `VersionLifecycle` field | meaning | -| -------------------- | ----------------------------- | ---------------------------------------- | -| `support` | `DeprecationDate` | end of standard support | -| `eol` | `EOLDate` | true end of life — version stops working | -| `extendedSupport` | `ExtendedSupportEnd` | last day AWS will sell extended support | +### 3. AWS ElastiCache / Aurora MySQL pattern (no `support` field) -A version past `eol` is `IsEOL=true`, classified RED by the policy -layer. A version past `support` but before `eol` is in extended support, -classified YELLOW. Simple. +```json +{ + "cycle": "5", + "eol": "2026-01-31", + "extendedSupport": "2029-01-31" +} +``` ---- +There is no `support` field. Upstream uses `eol` as shorthand for +"end of standard support" *because* there's a real terminal date in +`extendedSupport`. The adapter recognizes the AWS pattern by +`extendedSupport` being a date, and treats `eol` as the +standard-support boundary, with `extendedSupport` as both the +extended-support end **and** the true EOL date. -## The EKS gotcha — three deviations from the standard +The same `StandardSchemaAdapter` handles all three shapes — see +`deriveBoundaries` in [adapters.go](./adapters.go) for the three-way +switch. -EKS does not match the standard schema in three concrete ways: +| `VersionLifecycle` field | Sourced from | +| ------------------------ | ------------------------------------------------------------------------------- | +| `DeprecationDate` | `support` if present, else `eol` when `extendedSupport` is also present (AWS pattern). | +| `ExtendedSupportEnd` | `extendedSupport` (date), or legacy boolean `true` falling back to `eol`. | +| `EOLDate` | `extendedSupport` when set (true terminal); else `eol`; else nil. | -1. The same field name (`cycle.eol`) means a different thing on EKS - than on standard products. -2. EKS removes a concept that standard products always have (true EOL). -3. The adapter compensates for (1) and (2) with a routing that itself - loses information — output field name and source field name do not - mean the same thing. +--- -Each deviation, in turn: +## EKS — still its own adapter, but for narrower reasons now -### Deviation 1 — `cycle.eol` is **not** the true EOL +EKS still doesn't fit the standard schema because EKS clusters +**never truly stop working** — once you're past extended support, AWS +stops issuing patches but the control plane keeps running. There's no +"true EOL" for an EKS version, only "out of AWS support". -For `amazon-eks`, the `eol` field is the day **standard support ends**, -not the day the version stops working. Compare: +`EKSSchemaAdapter` therefore: -```json -// amazon-eks cycle 1.31 (live data) -{ - "cycle": "1.31", - "eol": "2025-11-26", // ← standard-support end, NOT true EOL - "extendedSupport": "2026-11-26" // ← extended-support end -} -``` +1. Maps `cycle.eol` → `DeprecationDate` (end of standard support; + start of paid extended support). +2. Maps `cycle.extendedSupport` (date) → `ExtendedSupportEnd`. +3. Hard-sets `lifecycle.EOLDate = nil` regardless of input. +4. Classifies past-extended-support as RED via + `IsDeprecated && !IsExtendedSupport` (same effect as the standard + adapter's terminal RED branch, but without claiming the cluster is + dead). -If you ran cycle 1.31 through `StandardSchemaAdapter`, today -(2026-04-28) it would be flagged `IsEOL=true` — implying the cluster -has stopped working. It hasn't. The cluster is in extended support and -will be supported by AWS until 2026-11-26. `EKSSchemaAdapter` avoids -that specific misclassification by hard-setting `lifecycle.EOLDate = -nil` (see Deviation 2) and routing `cycle.eol` into -`lifecycle.ExtendedSupportEnd` instead. - -(Even after this routing the in-extended-support classification is -still imperfect — see Deviation 3 — but no version is ever reported as -past true EOL.) - -### Deviation 2 — EKS has no true EOL - -EKS clusters never stop working. Once you are past extended support AWS -stops issuing patches, but the control plane keeps running on the old -version indefinitely. There is no equivalent of the standard `eol` -field for EKS, and the adapter encodes that by hard-setting -`lifecycle.EOLDate = nil` regardless of input. Any classifier rule -keyed on `EOLDate` is therefore inert for EKS — the policy reads -`ExtendedSupportEnd` instead. - -### Deviation 3 — `cycle.eol` (standard-support-end) is mapped onto `ExtendedSupportEnd`; `cycle.extendedSupport` is ignored - -The adapter routes `cycle.eol` (which marks **standard-support-end** — -see Deviation 1) into the output field `lifecycle.ExtendedSupportEnd`, -and ignores `cycle.extendedSupport` entirely. This is the part of the -adapter most likely to mislead a reader of the code: input field name -and output field name **do not** mean the same thing. - -In effect, the adapter pretends standard-support-end is -extended-support-end. For cycle 1.31: - -| Source field | Source value | Real meaning | Mapped to | Effect | -| ----------------------- | ------------ | ------------------------ | ---------------------------- | ------------------------------------------------------------------- | -| `cycle.eol` | `2025-11-26` | standard-support-end | `lifecycle.ExtendedSupportEnd` | adapter treats this date as the policy threshold for "past extended support" | -| `cycle.extendedSupport` | `2026-11-26` | extended-support-end | (ignored) | the real extended-support window is invisible to the policy layer | - -Historical reason: when the adapter was written, endoflife.date -returned `extendedSupport` as a boolean flag, so `cycle.eol` was the -only available date signal. Live data now returns a real date in -`extendedSupport`, but the adapter has not been updated. - -**Consequence:** an EKS version that is genuinely in extended support -today is classified as past-extended-support (RED) by the policy -layer. This is a known coarsening — it errs toward urging upgrades, -which matches the intended product behavior, but it is a real -semantic gap to be aware of when reading EKS findings, and a -candidate for a follow-up fix. +The adapter also tolerates the pre-2026 shape where +`cycle.extendedSupport` was a boolean — a `true` value falls back to +`cycle.eol` as the extended-support boundary. ---- +### Live example -## What the adapter actually outputs +For amazon-eks cycle 1.30 (`eol: 2025-07-23`, +`extendedSupport: 2026-07-23`), evaluated on 2026-04-29: -For `amazon-eks` cycle 1.31 (`eol: 2025-11-26`, -`extendedSupport: 2026-11-26`), evaluated on 2026-04-28 — a date that -is genuinely inside the AWS-defined extended-support window -(2025-11-26 → 2026-11-26): +| Field | Value | Source / note | +| ------------------------------ | -------------- | -------------------------------------------------------- | +| `EOLDate` | `nil` | always nil for EKS | +| `DeprecationDate` | `2025-07-23` | `cycle.eol` (end of standard support) | +| `ExtendedSupportEnd` | `2026-07-23` | `cycle.extendedSupport` | +| `IsExtendedSupport` | `true` | now is between standard-end and extended-end | +| `IsSupported` | `true` | still in paid extended support | +| `IsDeprecated` | `true` | past standard support | -| Field | Value | Source / note | -| ------------------------------ | ------------ | ----------------------------------------------------------------------------- | -| `EOLDate` | `nil` | always for EKS (Deviation 2) | -| `DeprecationDate` | `nil` | EKS cycles have no `cycle.support` field | -| `ExtendedSupportEnd` | `2025-11-26` | sourced from **`cycle.eol`** (standard-support-end) — *not* `cycle.extendedSupport`; see Deviation 3 | -| `IsExtendedSupport` | `false`* | branch is unreachable in practice — see footnote | -| `IsSupported` / `IsDeprecated` | `false` / `true` | classified RED, because `now` (2026-04-28) is past `ExtendedSupportEnd` (2025-11-26) — even though AWS is still in real extended support until 2026-11-26 | +→ Policy classifies as **YELLOW**. -\* The adapter reports `IsExtendedSupport=true` only while -`now` is between `cycle.support` and `cycle.eol` — and EKS cycles have -no `cycle.support` field, so the "in extended support" branch is -unreachable in practice. Combined with Deviation 3, the policy layer -never sees an EKS version as YELLOW; it's GREEN until it crosses -`cycle.eol` (standard-support-end), then RED. +For the same cycle past `extendedSupport`, status flips to +`IsDeprecated=true, IsExtendedSupport=false, IsSupported=false` → +**RED**, with `EOLDate` still nil. --- @@ -155,7 +132,7 @@ resource entry, validated by the config loader at startup: eol: provider: endoflife-date product: amazon-eks - schema: eks_adapter # ← the EKS gotcha lives here + schema: eks_adapter # ← EKS-only — no true EOL ``` ```yaml @@ -163,34 +140,35 @@ resource entry, validated by the config loader at startup: eol: provider: endoflife-date product: amazon-aurora-postgresql - schema: standard # ← the default for almost everything + schema: standard # ← the default for almost everything, + # including AWS ElastiCache/RDS/Aurora + # that ship eol+extendedSupport ``` Empty `schema` defaults to `standard`. Adding a new schema means implementing `SchemaAdapter`, registering it in `SchemaAdapters` in -[adapters.go](./adapters.go), and naming it from YAML — no Go change in -the resource detector, the activities, or the policy. +[adapters.go](./adapters.go), and naming it from YAML — no Go change +in the activities or the policy layer. --- ## Adding a new adapter — the rule of thumb If a new product cycle's fields have different semantics from the -standard ones, write an adapter. Symptoms that indicate you need one: +standard ones (in any of the three shapes above), write an adapter. +Symptoms that indicate you need one: - A field's name suggests one thing but the dates encode another (the - EKS `eol` case above). -- A field is a boolean where the standard schema expects a date, or - vice versa. + EKS `eol`-isn't-EOL case). - The product is missing a concept the standard schema relies on (EKS having no true EOL). -- Comparing the live JSON cycle to the standard layout shows a - field that should never be parsed as a date, or a date that should - never be treated as the true EOL. +- A field is a boolean where the standard schema expects a date in a + way that the existing `extendedSupport: true` fallback doesn't cover. -If a new product matches the standard semantics, do not write an -adapter — use `standard` and move on. The point of this seam is to keep -deviations explicit and small, not to encode every product separately. +If a new product matches one of the three standard shapes, do not +write an adapter — use `standard` and move on. The point of this seam +is to keep deviations explicit and small, not to encode every product +separately. --- @@ -198,11 +176,11 @@ deviations explicit and small, not to encode every product separately. ```sh curl -s https://endoflife.date/api/amazon-eks.json | jq '.[0]' +curl -s https://endoflife.date/api/amazon-elasticache-redis.json | jq '.[0]' curl -s https://endoflife.date/api/amazon-aurora-postgresql.json | jq '.[0]' ``` -Two cycles, side by side, will show you in seconds whether you are -looking at a standard-schema product or another EKS-style gotcha. If -the field shapes match the standard table at the top of this doc, ship -it as `schema: standard`. If they don't, write an adapter and add a row -to this doc. +Three cycles side-by-side will show you in seconds which shape you're +looking at. Match against the table at the top — if the field shapes +are one of the three standard patterns, ship it as `schema: standard`. +If not, write an adapter and add a section here. diff --git a/pkg/eol/endoflife/adapters.go b/pkg/eol/endoflife/adapters.go index 611b867..55253bb 100644 --- a/pkg/eol/endoflife/adapters.go +++ b/pkg/eol/endoflife/adapters.go @@ -8,121 +8,147 @@ import ( "github.com/block/Version-Guard/pkg/types" ) -// SchemaAdapter adapts endoflife.date ProductCycle to VersionLifecycle -// Some products use non-standard field semantics and need custom adapters +// SchemaAdapter adapts endoflife.date ProductCycle to VersionLifecycle. +// Some products use non-standard field semantics and need custom +// adapters; see ADAPTERS.md for the catalog. type SchemaAdapter interface { AdaptCycle(cycle *ProductCycle) (*types.VersionLifecycle, error) } -// StandardSchemaAdapter handles products with standard endoflife.date schema -// Standard semantics: -// - cycle.support → DeprecationDate (end of standard support) -// - cycle.eol → EOLDate (true end of life) -// - cycle.extendedSupport → ExtendedSupportEnd +// StandardSchemaAdapter handles products with standard endoflife.date semantics. +// +// The adapter recognizes three field shapes that real upstream cycles +// take, and unifies them into the canonical VersionLifecycle dates: +// +// 1. support + eol + extendedSupport (date) — Aurora pattern. +// standardEnd = support, extendedEnd = extendedSupport, trueEOL = extendedSupport. +// +// 2. eol + extendedSupport (date), no support — AWS ElastiCache pattern. +// `eol` here is upstream shorthand for "end of standard support" because +// `extendedSupport` is the real terminal date. standardEnd = eol, +// extendedEnd = extendedSupport, trueEOL = extendedSupport. +// +// 3. support + eol, no extendedSupport — pure OSS pattern (PostgreSQL etc.). +// standardEnd = support, trueEOL = eol, no extended-support window. +// +// Legacy boolean `extendedSupport: true` is honored: when paired with +// a date `eol`, the adapter treats `eol` itself as the end of the +// extended-support window. `false` boolean is treated as no extended +// support. type StandardSchemaAdapter struct{} -// lifecycleDates holds parsed date values for lifecycle calculations +// lifecycleDates holds the raw parsed dates from a cycle, before +// semantic interpretation in setLifecycleStatus. type lifecycleDates struct { eol *time.Time support *time.Time - extendedSupport *time.Time + extendedSupport *time.Time // only set if cycle.extendedSupport was a *date string* + extendedTrue bool // legacy: cycle.extendedSupport was bool true } -// parseCycleDates extracts and parses dates from a ProductCycle func (a *StandardSchemaAdapter) parseCycleDates(cycle *ProductCycle) lifecycleDates { dates := lifecycleDates{} - // Parse EOL date (STANDARD semantics: true end of life) if dateStr := anyToDateString(cycle.EOL); dateStr != "" { if parsed, err := parseDate(dateStr); err == nil { dates.eol = &parsed } } - // Parse support date (STANDARD semantics: end of standard support) if dateStr := anyToDateString(cycle.Support); dateStr != "" { if parsed, err := parseDate(dateStr); err == nil { dates.support = &parsed } } - // Parse extended support - if cycle.ExtendedSupport != nil { - dates.extendedSupport = a.parseExtendedSupport(cycle.ExtendedSupport, dates.eol) - } - - return dates -} - -// parseExtendedSupport handles the extended support field which can be string or bool -func (a *StandardSchemaAdapter) parseExtendedSupport(extSupport interface{}, eolDate *time.Time) *time.Time { - switch v := extSupport.(type) { + switch v := cycle.ExtendedSupport.(type) { case string: if v != "" && v != falseBool { if parsed, err := parseDate(v); err == nil { - return &parsed + dates.extendedSupport = &parsed } } case bool: - // If boolean true, use EOL date as extended support end - if v && eolDate != nil { - return eolDate + if v { + dates.extendedTrue = true } } - return nil -} -// setLifecycleStatus determines lifecycle status flags based on dates -func (a *StandardSchemaAdapter) setLifecycleStatus(lifecycle *types.VersionLifecycle, dates lifecycleDates) { - now := time.Now() - - // If we have an EOL date and we're past it, mark as EOL - if dates.eol != nil && now.After(*dates.eol) { - lifecycle.IsEOL = true - lifecycle.IsSupported = false - lifecycle.IsDeprecated = true - return - } + return dates +} - // If we have extended support end and we're past standard support - if dates.extendedSupport != nil && dates.support != nil && now.After(*dates.support) { - a.setExtendedSupportStatus(lifecycle, dates, now) - return - } +// derivedBoundaries collapses the raw parsed dates into the three +// semantic boundaries the policy layer cares about. +type derivedBoundaries struct { + standardEnd *time.Time // last day of standard (free) support + extendedEnd *time.Time // last day of extended (paid) support, if any + trueEOL *time.Time // last day the version is supported at all +} - // If we're past support date but no extended support info - if dates.support != nil && now.After(*dates.support) { - lifecycle.IsDeprecated = true - lifecycle.IsSupported = false - // If we have EOL date and not past it yet, not EOL - if dates.eol != nil && now.Before(*dates.eol) { - lifecycle.IsEOL = false +func (a *StandardSchemaAdapter) deriveBoundaries(dates lifecycleDates) derivedBoundaries { + b := derivedBoundaries{} + + switch { + case dates.extendedSupport != nil: + // Extended support window with an explicit end date (Aurora, + // ElastiCache, or any product that ships an extendedSupport + // date alongside eol/support). + b.extendedEnd = dates.extendedSupport + b.trueEOL = dates.extendedSupport + switch { + case dates.support != nil: + b.standardEnd = dates.support + case dates.eol != nil: + // AWS pattern: no `support` field — `eol` is end of standard support + // because `extendedSupport` is the real terminal date. + b.standardEnd = dates.eol + } + case dates.extendedTrue && dates.eol != nil: + // Legacy boolean: treat eol as the extended-support end. + b.extendedEnd = dates.eol + b.trueEOL = dates.eol + if dates.support != nil { + b.standardEnd = dates.support + } + default: + // No extended support concept — the standard pattern. + b.trueEOL = dates.eol + if dates.support != nil { + b.standardEnd = dates.support } - return } - // Still in standard support - lifecycle.IsSupported = true - lifecycle.IsDeprecated = false - lifecycle.IsEOL = false + return b } -// setExtendedSupportStatus handles status when in or past extended support window -func (a *StandardSchemaAdapter) setExtendedSupportStatus(lifecycle *types.VersionLifecycle, dates lifecycleDates, now time.Time) { - if now.Before(*dates.extendedSupport) { - // In extended support window +func (a *StandardSchemaAdapter) classify(lifecycle *types.VersionLifecycle, b derivedBoundaries) { + now := time.Now() + + switch { + case b.trueEOL != nil && now.After(*b.trueEOL): + // Past true end of life — no support of any kind remains. + lifecycle.IsEOL = true + lifecycle.IsSupported = false + lifecycle.IsDeprecated = true + case b.extendedEnd != nil && b.standardEnd != nil && + now.After(*b.standardEnd) && now.Before(*b.extendedEnd): + // In the paid extended-support window. lifecycle.IsSupported = true lifecycle.IsExtendedSupport = true lifecycle.IsDeprecated = true - } else { - // Past extended support - lifecycle.IsEOL = true - lifecycle.IsSupported = false + case b.standardEnd != nil && now.After(*b.standardEnd): + // Past standard support but no extended support is available + // (or we're past it without a true-EOL date pinning RED). lifecycle.IsDeprecated = true + lifecycle.IsSupported = false + default: + // Still in standard support, or no date info at all. + lifecycle.IsSupported = true } } -// AdaptCycle converts a ProductCycle to VersionLifecycle using standard semantics +// AdaptCycle converts a ProductCycle to VersionLifecycle using the +// standard semantics described in the StandardSchemaAdapter doc. func (a *StandardSchemaAdapter) AdaptCycle(cycle *ProductCycle) (*types.VersionLifecycle, error) { lifecycle := &types.VersionLifecycle{ Version: cycle.Cycle, @@ -131,36 +157,81 @@ func (a *StandardSchemaAdapter) AdaptCycle(cycle *ProductCycle) (*types.VersionL FetchedAt: time.Now(), } - // Parse release date if cycle.ReleaseDate != "" { if releaseDate, err := parseDate(cycle.ReleaseDate); err == nil { lifecycle.ReleaseDate = &releaseDate } } - // Parse lifecycle dates dates := a.parseCycleDates(cycle) + b := a.deriveBoundaries(dates) - // Set dates on lifecycle - lifecycle.EOLDate = dates.eol - lifecycle.DeprecationDate = dates.support - lifecycle.ExtendedSupportEnd = dates.extendedSupport + lifecycle.DeprecationDate = b.standardEnd + lifecycle.ExtendedSupportEnd = b.extendedEnd + lifecycle.EOLDate = b.trueEOL - // Determine lifecycle status - a.setLifecycleStatus(lifecycle, dates) + a.classify(lifecycle, b) return lifecycle, nil } -// EKSSchemaAdapter handles amazon-eks product with NON-STANDARD schema -// EKS semantics (DIFFERENT from standard): -// - cycle.support → DeprecationDate (end of standard support) ✅ Same -// - cycle.eol → ExtendedSupportEnd (NOT true EOL!) ⚠️ DIFFERENT -// - cycle.extendedSupport → boolean flag (NOT a date) ⚠️ DIFFERENT -// - EKS has NO true EOL (clusters keep running forever) +// EKSSchemaAdapter handles amazon-eks, whose cycles ship in a shape +// that disagrees with the standard schema in two ways: +// +// - There is no `support` field; `cycle.eol` is the end of *standard* +// support (start of paid extended support), not a true terminal date. +// - `cycle.extendedSupport` is now a *date* (the end of paid extended +// support); it used to be a boolean and the adapter still tolerates +// that legacy shape. +// - EKS clusters never truly stop working, so we always leave +// lifecycle.EOLDate = nil. Past-extended-support is still classified +// RED via `IsDeprecated && !IsExtendedSupport`, matching the prior +// product behavior of urging upgrades. type EKSSchemaAdapter struct{} -// AdaptCycle converts EKS ProductCycle to VersionLifecycle using EKS-specific semantics +// parseEKSExtendedEnd resolves cycle.extendedSupport into an end date. +// The current schema uses a date string; older cycles used boolean +// `true` to mean "extended support exists, ends at cycle.eol", so we +// fall back to standardEnd in that case to keep upgrade nudges firing. +func (a *EKSSchemaAdapter) parseEKSExtendedEnd(extSupport interface{}, standardEnd *time.Time) *time.Time { + switch v := extSupport.(type) { + case string: + if v != "" && v != falseBool { + if parsed, err := parseDate(v); err == nil { + return &parsed + } + } + case bool: + if v && standardEnd != nil { + return standardEnd + } + } + return nil +} + +// classifyEKS sets the lifecycle status flags from the derived +// boundaries. EKS has no true EOL, so past-extended-support is +// represented as RED via IsDeprecated && !IsExtendedSupport. +func (a *EKSSchemaAdapter) classifyEKS(lifecycle *types.VersionLifecycle, standardEnd, extendedEnd *time.Time) { + now := time.Now() + switch { + case extendedEnd != nil && now.After(*extendedEnd): + // Past extended support — AWS no longer issues patches. + lifecycle.IsSupported = false + lifecycle.IsDeprecated = true + lifecycle.IsExtendedSupport = false + case standardEnd != nil && now.After(*standardEnd): + // In the paid extended-support window. + lifecycle.IsSupported = true + lifecycle.IsExtendedSupport = true + lifecycle.IsDeprecated = true + default: + // Still in standard support (or no date info at all). + lifecycle.IsSupported = true + } +} + +// AdaptCycle converts an amazon-eks ProductCycle to VersionLifecycle. func (a *EKSSchemaAdapter) AdaptCycle(cycle *ProductCycle) (*types.VersionLifecycle, error) { lifecycle := &types.VersionLifecycle{ Version: cycle.Cycle, @@ -169,67 +240,36 @@ func (a *EKSSchemaAdapter) AdaptCycle(cycle *ProductCycle) (*types.VersionLifecy FetchedAt: time.Now(), } - // Parse release date (standard) if cycle.ReleaseDate != "" { if releaseDate, err := parseDate(cycle.ReleaseDate); err == nil { lifecycle.ReleaseDate = &releaseDate } } - // Parse standard support end (standard) - var supportDate *time.Time - if dateStr := anyToDateString(cycle.Support); dateStr != "" { - if parsed, err := parseDate(dateStr); err == nil { - supportDate = &parsed - lifecycle.DeprecationDate = supportDate - } - } - - // ⚠️ NON-STANDARD: cycle.EOL → ExtendedSupportEnd (NOT EOLDate!) - var extendedSupportEnd *time.Time + // cycle.eol is end-of-standard-support for amazon-eks. + var standardEnd *time.Time if dateStr := anyToDateString(cycle.EOL); dateStr != "" { if parsed, err := parseDate(dateStr); err == nil { - extendedSupportEnd = &parsed - lifecycle.ExtendedSupportEnd = &parsed + standardEnd = &parsed } } - - // EKS has NO true EOL (clusters keep running forever) + lifecycle.DeprecationDate = standardEnd + lifecycle.ExtendedSupportEnd = a.parseEKSExtendedEnd(cycle.ExtendedSupport, standardEnd) + // EKS has no true EOL — clusters keep running indefinitely. lifecycle.EOLDate = nil - // Determine lifecycle status - now := time.Now() - - if extendedSupportEnd != nil && now.After(*extendedSupportEnd) { - // Past extended support - lifecycle.IsEOL = false // NOT true EOL, just no AWS support - lifecycle.IsSupported = false - lifecycle.IsDeprecated = true - lifecycle.IsExtendedSupport = false - } else if supportDate != nil && now.After(*supportDate) { - // In extended support window - lifecycle.IsSupported = true - lifecycle.IsExtendedSupport = true - lifecycle.IsDeprecated = true - lifecycle.IsEOL = false - } else { - // Still in standard support - lifecycle.IsSupported = true - lifecycle.IsDeprecated = false - lifecycle.IsEOL = false - lifecycle.IsExtendedSupport = false - } + a.classifyEKS(lifecycle, standardEnd, lifecycle.ExtendedSupportEnd) return lifecycle, nil } -// SchemaAdapters is a registry of available schema adapters +// SchemaAdapters is a registry of available schema adapters. var SchemaAdapters = map[string]SchemaAdapter{ "standard": &StandardSchemaAdapter{}, "eks_adapter": &EKSSchemaAdapter{}, } -// GetSchemaAdapter returns the appropriate schema adapter for a product +// GetSchemaAdapter returns the appropriate schema adapter for a product. func GetSchemaAdapter(schemaName string) (SchemaAdapter, error) { adapter, ok := SchemaAdapters[schemaName] if !ok { diff --git a/pkg/eol/endoflife/adapters_test.go b/pkg/eol/endoflife/adapters_test.go index 4c5b951..63952c1 100644 --- a/pkg/eol/endoflife/adapters_test.go +++ b/pkg/eol/endoflife/adapters_test.go @@ -141,15 +141,117 @@ func TestStandardSchemaAdapter_FalseBooleans(t *testing.T) { assert.False(t, lifecycle.IsDeprecated) } +// TestStandardSchemaAdapter_AWSPattern_InExtendedSupport pins the +// amazon-elasticache-redis cycle 5/6 shape: no `support` field, +// `eol` is the end of standard support (NOT terminal), and +// `extendedSupport` is the real terminal date. A version past `eol` +// but before `extendedSupport` must classify as in-extended-support +// (YELLOW), not EOL (RED). +func TestStandardSchemaAdapter_AWSPattern_InExtendedSupport(t *testing.T) { + adapter := &StandardSchemaAdapter{} + + pastYear := time.Now().Year() - 1 + futureYear := time.Now().Year() + 2 + cycle := &ProductCycle{ + Cycle: "5", + ReleaseDate: "2018-10-17", + EOL: time.Date(pastYear, 1, 31, 0, 0, 0, 0, time.UTC).Format("2006-01-02"), // standard-support end (past) + ExtendedSupport: time.Date(futureYear, 1, 31, 0, 0, 0, 0, time.UTC).Format("2006-01-02"), // extended-support end (future) + } + + lifecycle, err := adapter.AdaptCycle(cycle) + require.NoError(t, err) + + assert.True(t, lifecycle.IsSupported) + assert.True(t, lifecycle.IsDeprecated) + assert.True(t, lifecycle.IsExtendedSupport) + assert.False(t, lifecycle.IsEOL) + // True EOL = end of extended support, not the renamed `eol`. + assert.NotNil(t, lifecycle.EOLDate) + assert.NotNil(t, lifecycle.ExtendedSupportEnd) + assert.Equal(t, *lifecycle.EOLDate, *lifecycle.ExtendedSupportEnd) + // DeprecationDate = `eol` (since there's no `support` field). + assert.NotNil(t, lifecycle.DeprecationDate) +} + +// TestStandardSchemaAdapter_AWSPattern_PastExtendedSupport: same shape +// as above but past extendedSupport too — true EOL. +func TestStandardSchemaAdapter_AWSPattern_PastExtendedSupport(t *testing.T) { + adapter := &StandardSchemaAdapter{} + + cycle := &ProductCycle{ + Cycle: "3", + EOL: "2020-01-31", // standard-support end (past) + ExtendedSupport: "2023-01-31", // extended-support end (past) + } + + lifecycle, err := adapter.AdaptCycle(cycle) + require.NoError(t, err) + + assert.True(t, lifecycle.IsEOL) + assert.False(t, lifecycle.IsSupported) + assert.True(t, lifecycle.IsDeprecated) +} + +// TestStandardSchemaAdapter_AWSPattern_StillStandard: AWS pattern +// with both eol and extendedSupport in the future → standard support. +func TestStandardSchemaAdapter_AWSPattern_StillStandard(t *testing.T) { + adapter := &StandardSchemaAdapter{} + + futureYear := time.Now().Year() + 1 + cycle := &ProductCycle{ + Cycle: "6", + EOL: time.Date(futureYear, 1, 31, 0, 0, 0, 0, time.UTC).Format("2006-01-02"), // future + ExtendedSupport: time.Date(futureYear+3, 1, 31, 0, 0, 0, 0, time.UTC).Format("2006-01-02"), // future + } + + lifecycle, err := adapter.AdaptCycle(cycle) + require.NoError(t, err) + + assert.True(t, lifecycle.IsSupported) + assert.False(t, lifecycle.IsDeprecated) + assert.False(t, lifecycle.IsExtendedSupport) + assert.False(t, lifecycle.IsEOL) +} + +// TestStandardSchemaAdapter_ExtendedSupportOverridesPastEOL guards the +// reordering: previously, when `eol` was past, the EOL branch returned +// before the extended-support branch could fire, so a future +// `extendedSupport` date was ignored. This test pins that a future +// extendedSupport date now correctly extends the version's life. +func TestStandardSchemaAdapter_ExtendedSupportOverridesPastEOL(t *testing.T) { + adapter := &StandardSchemaAdapter{} + + pastYear := time.Now().Year() - 2 + futureYear := time.Now().Year() + 2 + cycle := &ProductCycle{ + Cycle: "5.6", + Support: time.Date(pastYear, 2, 1, 0, 0, 0, 0, time.UTC).Format("2006-01-02"), + EOL: time.Date(pastYear, 2, 1, 0, 0, 0, 0, time.UTC).Format("2006-01-02"), + ExtendedSupport: time.Date(futureYear, 2, 1, 0, 0, 0, 0, time.UTC).Format("2006-01-02"), + } + + lifecycle, err := adapter.AdaptCycle(cycle) + require.NoError(t, err) + + assert.False(t, lifecycle.IsEOL) + assert.True(t, lifecycle.IsExtendedSupport) + assert.True(t, lifecycle.IsSupported) + assert.True(t, lifecycle.IsDeprecated) +} + func TestEKSSchemaAdapter_CurrentVersion(t *testing.T) { adapter := &EKSSchemaAdapter{} + // Live amazon-eks shape: cycle.eol is end-of-standard-support and + // cycle.extendedSupport is end-of-extended-support. Both in the + // future → standard support today. futureYear := time.Now().Year() + 1 cycle := &ProductCycle{ - Cycle: "1.31", - ReleaseDate: "2024-11-15", - Support: time.Date(futureYear, 11, 15, 0, 0, 0, 0, time.UTC).Format("2006-01-02"), // Future - EOL: time.Date(futureYear+1, 5, 15, 0, 0, 0, 0, time.UTC).Format("2006-01-02"), // Future (extended support end in EKS) + Cycle: "1.31", + ReleaseDate: "2024-11-15", + EOL: time.Date(futureYear, 11, 15, 0, 0, 0, 0, time.UTC).Format("2006-01-02"), // standard-support end (future) + ExtendedSupport: time.Date(futureYear+1, 11, 15, 0, 0, 0, 0, time.UTC).Format("2006-01-02"), // extended-support end (future) } lifecycle, err := adapter.AdaptCycle(cycle) @@ -162,23 +264,26 @@ func TestEKSSchemaAdapter_CurrentVersion(t *testing.T) { assert.False(t, lifecycle.IsEOL) assert.False(t, lifecycle.IsExtendedSupport) - // EKS has NO true EOL + // EKS has NO true EOL. assert.Nil(t, lifecycle.EOLDate) + // DeprecationDate = cycle.eol (end of standard support). assert.NotNil(t, lifecycle.DeprecationDate) + // ExtendedSupportEnd = cycle.extendedSupport. assert.NotNil(t, lifecycle.ExtendedSupportEnd) } func TestEKSSchemaAdapter_InExtendedSupport(t *testing.T) { adapter := &EKSSchemaAdapter{} - // Version past standard support but in extended support + // Past cycle.eol (end of standard support) but before cycle.extendedSupport + // (end of extended support) → IN extended support → YELLOW. pastYear := time.Now().Year() - 1 futureYear := time.Now().Year() + 1 cycle := &ProductCycle{ - Cycle: "1.28", - ReleaseDate: "2023-09-15", - Support: time.Date(pastYear, 9, 15, 0, 0, 0, 0, time.UTC).Format("2006-01-02"), // Past - EOL: time.Date(futureYear, 3, 15, 0, 0, 0, 0, time.UTC).Format("2006-01-02"), // Future (extended support end) + Cycle: "1.30", + ReleaseDate: "2024-05-23", + EOL: time.Date(pastYear, 7, 23, 0, 0, 0, 0, time.UTC).Format("2006-01-02"), // standard-support end (past) + ExtendedSupport: time.Date(futureYear, 7, 23, 0, 0, 0, 0, time.UTC).Format("2006-01-02"), // extended-support end (future) } lifecycle, err := adapter.AdaptCycle(cycle) @@ -193,12 +298,12 @@ func TestEKSSchemaAdapter_InExtendedSupport(t *testing.T) { func TestEKSSchemaAdapter_PastExtendedSupport(t *testing.T) { adapter := &EKSSchemaAdapter{} - // Version past extended support end + // Past both cycle.eol AND cycle.extendedSupport — AWS no longer patches. cycle := &ProductCycle{ - Cycle: "1.25", - ReleaseDate: "2023-01-15", - Support: "2024-01-15", // Past - EOL: "2024-07-15", // Past (extended support end) + Cycle: "1.25", + ReleaseDate: "2023-01-15", + EOL: "2024-01-15", // standard-support end (past) + ExtendedSupport: "2024-07-15", // extended-support end (past) } lifecycle, err := adapter.AdaptCycle(cycle) @@ -214,22 +319,54 @@ func TestEKSSchemaAdapter_NoTrueEOL(t *testing.T) { adapter := &EKSSchemaAdapter{} cycle := &ProductCycle{ - Cycle: "1.20", - ReleaseDate: "2021-01-15", - Support: "2022-01-15", - EOL: "2022-07-15", + Cycle: "1.20", + ReleaseDate: "2021-01-15", + EOL: "2022-01-15", + ExtendedSupport: "2022-07-15", } lifecycle, err := adapter.AdaptCycle(cycle) require.NoError(t, err) - // Verify EKS has NO true EOL date + // Verify EKS has NO true EOL date. assert.Nil(t, lifecycle.EOLDate) - // ExtendedSupportEnd comes from cycle.EOL + // ExtendedSupportEnd comes from cycle.extendedSupport (NOT cycle.eol). assert.NotNil(t, lifecycle.ExtendedSupportEnd) expectedDate, _ := time.Parse("2006-01-02", "2022-07-15") assert.Equal(t, expectedDate, *lifecycle.ExtendedSupportEnd) + + // DeprecationDate comes from cycle.eol (end of standard support). + assert.NotNil(t, lifecycle.DeprecationDate) + expectedStd, _ := time.Parse("2006-01-02", "2022-01-15") + assert.Equal(t, expectedStd, *lifecycle.DeprecationDate) +} + +// TestEKSSchemaAdapter_LegacyBooleanExtendedSupport guards the +// pre-2026 amazon-eks shape where cycle.extendedSupport was a boolean. +// Live data now uses dates, but the adapter still tolerates the +// legacy boolean so a hypothetical replay against archived JSON +// classifies clusters consistently. +func TestEKSSchemaAdapter_LegacyBooleanExtendedSupport(t *testing.T) { + adapter := &EKSSchemaAdapter{} + + // Past cycle.eol with extendedSupport=true bool — the bool falls + // back to standardEnd as the extended-support boundary, so we + // land in past-extended → IsDeprecated, !IsExtendedSupport. + cycle := &ProductCycle{ + Cycle: "1.24", + ReleaseDate: "2022-08-15", + EOL: "2024-01-15", // past + ExtendedSupport: true, // legacy bool + } + + lifecycle, err := adapter.AdaptCycle(cycle) + require.NoError(t, err) + + assert.False(t, lifecycle.IsSupported) + assert.True(t, lifecycle.IsDeprecated) + assert.False(t, lifecycle.IsExtendedSupport) + assert.Nil(t, lifecycle.EOLDate) } func TestGetSchemaAdapter_Standard(t *testing.T) {