From 8c23827fabf542975b54873b3c509c5b31e4bf32 Mon Sep 17 00:00:00 2001 From: Ben Apprederisse Date: Mon, 1 Jun 2026 13:37:38 -0700 Subject: [PATCH] Expose structured EOL lifecycle data Co-authored-by: Amp Amp-Thread-ID: https://ampcode.com/threads/T-019e84de-2503-7335-a36d-8da06c5607f9 --- ARCHITECTURE.md | 11 +-- README.md | 14 ++-- pkg/config/defaults/resources.yaml | 12 ++-- pkg/eol/endoflife/adapters.go | 61 ++++++++++++---- pkg/eol/endoflife/adapters_test.go | 66 +++++++++++++---- pkg/eol/endoflife/client.go | 2 +- pkg/eol/endoflife/client_test.go | 9 ++- pkg/policy/default.go | 73 +++++++++++-------- pkg/policy/messages_test.go | 15 +++- pkg/snapshot/builder_test.go | 36 ++++++---- pkg/snapshot/store.go | 8 ++- pkg/types/lifecycle_details.go | 75 +++++++++++++------- pkg/types/resource.go | 23 ++++-- pkg/workflow/detection/activities.go | 4 +- pkg/workflow/detection/activities_test.go | 8 ++- pkg/workflow/orchestrator/activities_test.go | 2 +- 16 files changed, 298 insertions(+), 121 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 982cb76..fb29c4c 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -475,8 +475,11 @@ s3://your-bucket/snapshots/ Snapshots are produced via Go's `encoding/json` defaults. Top-level fields on `Snapshot` and `SnapshotSummary` carry explicit `snake_case` JSON tags (see [pkg/types/snapshot.go](./pkg/types/snapshot.go)); most per-`Finding` -fields serialize as PascalCase. Required `lifecycle_details` is the intentionally -snake_case exception for downstream enrichment metadata. The `findings_by_type` +fields serialize as PascalCase. Required `eol` is the intentionally snake_case +exception for downstream enrichment metadata and carries every provider-exposed +support boundary plus metadata dates such as `release_date`, +`latest_release_date`, and date-valued `lts_date`; `eol.actionable_date` is the +first date used for yellow warning windows. The `findings_by_type` map is keyed by the resource **config ID** (e.g. `aurora-mysql`, `eks`, `elasticache-redis`) — the uppercase `ResourceType` constants in `pkg/types/resource.go` are test fixtures only. @@ -484,13 +487,13 @@ are test fixtures only. ```json { "snapshot_id": "scan-2026-04-09-123456", - "version": "v3", + "version": "v4", "generated_at": "2026-04-09T12:34:56Z", "scan_start_time": "2026-04-09T12:00:00Z", "scan_end_time": "2026-04-09T12:34:56Z", "scan_duration_sec": 2096, "findings_by_type": { - "aurora-mysql": [{"ResourceID": "...", "Status": "RED", "Message": "...", "lifecycle_details": {"standard_support_end": "2024-02-29T00:00:00Z", "is_extended_support": true}}], + "aurora-mysql": [{"ResourceID": "...", "Status": "RED", "Message": "...", "eol": {"standard_support_end": "2024-02-29T00:00:00Z", "latest_release_date": "2025-02-13T00:00:00Z", "actionable_date": "2024-02-29T00:00:00Z", "is_extended_support": true}}], "eks": [{"ResourceID": "...", "Status": "YELLOW", "Message": "..."}] }, "summary": { diff --git a/README.md b/README.md index 2655270..af4edc8 100644 --- a/README.md +++ b/README.md @@ -511,15 +511,18 @@ s3://your-bucket/snapshots/latest.json Snapshots are produced via Go's `encoding/json` defaults. Top-level fields on `Snapshot` and `SnapshotSummary` carry explicit `snake_case` tags (see [pkg/types/snapshot.go](./pkg/types/snapshot.go)); most per-`Finding` fields -serialize as PascalCase. Required `lifecycle_details` is the intentionally -snake_case exception for downstream enrichment metadata. The `findings_by_type` map is keyed +serialize as PascalCase. Required `eol` is the intentionally snake_case +exception for downstream enrichment metadata and includes every support boundary +the EOL provider exposes, plus metadata dates such as `release_date`, +`latest_release_date`, and date-valued `lts_date`. `eol.actionable_date` is the +first lifecycle date used for yellow warning windows. The `findings_by_type` map is keyed by the resource config ID (e.g. `aurora-mysql`, `eks`), not by the `ResourceType` constants used in tests. ```json { "snapshot_id": "scan-2026-04-09-123456", - "version": "v3", + "version": "v4", "generated_at": "2026-04-09T12:34:56Z", "scan_start_time": "2026-04-09T12:00:00Z", "scan_end_time": "2026-04-09T12:34:56Z", @@ -534,10 +537,13 @@ constants used in tests. "Engine": "aurora-mysql", "Status": "RED", "Message": "Running deprecated version 5.6.10a (EOL: 2024-02-29)", - "lifecycle_details": { + "eol": { "standard_support_end": "2024-02-29T00:00:00Z", "extended_support_end": "2027-02-28T00:00:00Z", "eol_date": "2027-02-28T00:00:00Z", + "actionable_date": "2024-02-29T00:00:00Z", + "release_date": "2016-02-22T00:00:00Z", + "latest_release_date": "2025-02-13T00:00:00Z", "version": "5.7", "engine": "mysql", "source": "endoflife-date-api", diff --git a/pkg/config/defaults/resources.yaml b/pkg/config/defaults/resources.yaml index 800304c..9aab2c5 100644 --- a/pkg/config/defaults/resources.yaml +++ b/pkg/config/defaults/resources.yaml @@ -318,18 +318,18 @@ resources: eol: provider: endoflife-date product: aws-lambda - # Lambda's `support` date is the first/actionable runtime EOL date - # shown to service owners. The upstream `eol` date is AWS's later - # terminal deprecated-support date, so keep it as structured lifecycle - # detail but do not use it as the primary EOLDate. + # Lambda's `support` date is the first/actionable runtime EOL date. + # The upstream `eol` date is AWS's later terminal deprecated-support + # date. Keep both in the finding's eol block, but warn on the first date. schema: declarative lifecycle: deprecation_date: field: support - extended_support_end: + deprecated_support_end: field: eol + deprecated_window: deprecated_support eol_date: - field: support + field: eol # To add a new resource type, follow skills/add-version-guard-resource. # Standalone copy-paste templates live in skills/add-version-guard-resource/examples/. diff --git a/pkg/eol/endoflife/adapters.go b/pkg/eol/endoflife/adapters.go index 48047db..934f536 100644 --- a/pkg/eol/endoflife/adapters.go +++ b/pkg/eol/endoflife/adapters.go @@ -102,6 +102,10 @@ func (a *StandardSchemaAdapter) deriveBoundaries(dates lifecycleDates) derivedBo switch { case dates.support != nil: b.standardEnd = dates.support + if dates.eol != nil && dates.eol.After(*dates.support) && + dates.eol.Before(*dates.extendedSupport) { + b.deprecatedSupportEnd = dates.eol + } case dates.eol != nil: // AWS pattern: no `support` field — `eol` is end of standard support // because `extendedSupport` is the real terminal date. @@ -181,11 +185,13 @@ func (a *StandardSchemaAdapter) AdaptCycle(cycle *ProductCycle) (*types.VersionL lifecycle.ReleaseDate = &releaseDate } } + applyCycleMetadataDates(lifecycle, cycle) dates := a.parseCycleDates(cycle) b := a.deriveBoundaries(dates) lifecycle.DeprecationDate = b.standardEnd + lifecycle.DeprecatedSupportEnd = b.deprecatedSupportEnd lifecycle.ExtendedSupportEnd = b.extendedEnd lifecycle.EOLDate = b.trueEOL @@ -202,10 +208,11 @@ const ( lifecycleFieldSupport = "support" lifecycleFieldExtendedSupport = "extendedSupport" - lifecycleActionExtendedSupport = "extended_support" - lifecycleActionUnsupported = "unsupported" - lifecycleActionEOL = "eol" - lifecycleActionSupported = "supported" + lifecycleActionExtendedSupport = "extended_support" + lifecycleActionDeprecatedSupport = "deprecated_support" + lifecycleActionUnsupported = "unsupported" + lifecycleActionEOL = "eol" + lifecycleActionSupported = "supported" ) // DeclarativeLifecycleConfig lets YAML describe product-specific @@ -214,11 +221,12 @@ const ( // how to classify the post-standard-support windows. // // Supported field names: support, eol, extendedSupport. -// Supported actions: extended_support, unsupported, eol, supported. +// Supported actions: extended_support, deprecated_support, unsupported, eol, supported. type DeclarativeLifecycleConfig struct { - DeprecationDate LifecycleDateSource `yaml:"deprecation_date"` - ExtendedSupportEnd LifecycleDateSource `yaml:"extended_support_end"` - EOLDate LifecycleDateSource `yaml:"eol_date"` + DeprecationDate LifecycleDateSource `yaml:"deprecation_date"` + DeprecatedSupportEnd LifecycleDateSource `yaml:"deprecated_support_end"` + ExtendedSupportEnd LifecycleDateSource `yaml:"extended_support_end"` + EOLDate LifecycleDateSource `yaml:"eol_date"` // DeprecatedWindow is applied after DeprecationDate and before // ExtendedSupportEnd. Most AWS paid/deprecated support windows use @@ -263,9 +271,10 @@ func ValidateDeclarativeLifecycleConfig(config *DeclarativeLifecycleConfig) erro } for name, source := range map[string]LifecycleDateSource{ - "deprecation_date": config.DeprecationDate, - "extended_support_end": config.ExtendedSupportEnd, - "eol_date": config.EOLDate, + "deprecation_date": config.DeprecationDate, + "deprecated_support_end": config.DeprecatedSupportEnd, + "extended_support_end": config.ExtendedSupportEnd, + "eol_date": config.EOLDate, } { if err := validateLifecycleDateSource(source); err != nil { return errors.Wrapf(err, "%s", name) @@ -280,6 +289,7 @@ func ValidateDeclarativeLifecycleConfig(config *DeclarativeLifecycleConfig) erro } if config.DeprecationDate.Field == "" && + config.DeprecatedSupportEnd.Field == "" && config.ExtendedSupportEnd.Field == "" && config.EOLDate.Field == "" { return errors.New("at least one lifecycle date source is required") @@ -319,7 +329,8 @@ func validateLifecycleAction(action string, allowEmpty bool) error { return nil } switch action { - case lifecycleActionExtendedSupport, lifecycleActionUnsupported, lifecycleActionEOL, lifecycleActionSupported: + case lifecycleActionExtendedSupport, lifecycleActionDeprecatedSupport, + lifecycleActionUnsupported, lifecycleActionEOL, lifecycleActionSupported: return nil default: return errors.Errorf("unsupported action %q", action) @@ -341,8 +352,10 @@ func (a *DeclarativeSchemaAdapter) AdaptCycle(cycle *ProductCycle) (*types.Versi lifecycle.ReleaseDate = &releaseDate } } + applyCycleMetadataDates(lifecycle, cycle) lifecycle.DeprecationDate = a.parseDateSource(cycle, a.config.DeprecationDate) + lifecycle.DeprecatedSupportEnd = a.parseDateSource(cycle, a.config.DeprecatedSupportEnd) lifecycle.ExtendedSupportEnd = a.parseDateSource(cycle, a.config.ExtendedSupportEnd) lifecycle.EOLDate = a.parseDateSource(cycle, a.config.EOLDate) @@ -391,14 +404,33 @@ func cycleFieldValue(cycle *ProductCycle, field string) (any, bool) { } } +func applyCycleMetadataDates(lifecycle *types.VersionLifecycle, cycle *ProductCycle) { + if cycle.LatestReleaseDate != "" { + if latestReleaseDate, err := parseDate(cycle.LatestReleaseDate); err == nil { + lifecycle.LatestReleaseDate = &latestReleaseDate + } + } + + if dateStr := anyToDateString(cycle.LTS); dateStr != "" { + if ltsDate, err := parseDate(dateStr); err == nil { + lifecycle.LTSDate = <sDate + } + } +} + func (a *DeclarativeSchemaAdapter) classify(lifecycle *types.VersionLifecycle) { now := time.Now() switch { case lifecycle.EOLDate != nil && now.After(*lifecycle.EOLDate): applyLifecycleAction(lifecycle, lifecycleActionEOL) + case lifecycle.DeprecatedSupportEnd != nil && now.After(*lifecycle.DeprecatedSupportEnd): + applyLifecycleAction(lifecycle, lifecycleActionUnsupported) case lifecycle.ExtendedSupportEnd != nil && now.After(*lifecycle.ExtendedSupportEnd): applyLifecycleAction(lifecycle, defaultLifecycleAction(a.config.PastExtendedSupport, lifecycleActionUnsupported)) + case lifecycle.DeprecationDate != nil && lifecycle.DeprecatedSupportEnd != nil && + now.After(*lifecycle.DeprecationDate) && now.Before(*lifecycle.DeprecatedSupportEnd): + applyLifecycleAction(lifecycle, defaultLifecycleAction(a.config.DeprecatedWindow, lifecycleActionUnsupported)) case lifecycle.DeprecationDate != nil && lifecycle.ExtendedSupportEnd != nil && now.After(*lifecycle.DeprecationDate) && now.Before(*lifecycle.ExtendedSupportEnd): applyLifecycleAction(lifecycle, defaultLifecycleAction(a.config.DeprecatedWindow, lifecycleActionUnsupported)) @@ -422,6 +454,11 @@ func applyLifecycleAction(lifecycle *types.VersionLifecycle, action string) { lifecycle.IsSupported = true lifecycle.IsDeprecated = true lifecycle.IsExtendedSupport = true + case lifecycleActionDeprecatedSupport: + lifecycle.IsSupported = true + lifecycle.IsDeprecated = true + lifecycle.IsDeprecatedSupport = true + lifecycle.IsExtendedSupport = false case lifecycleActionUnsupported: lifecycle.IsSupported = false lifecycle.IsDeprecated = true diff --git a/pkg/eol/endoflife/adapters_test.go b/pkg/eol/endoflife/adapters_test.go index 172ca90..7f80da6 100644 --- a/pkg/eol/endoflife/adapters_test.go +++ b/pkg/eol/endoflife/adapters_test.go @@ -14,10 +14,12 @@ func TestStandardSchemaAdapter_CurrentVersion(t *testing.T) { // Future dates to ensure version is current futureYear := time.Now().Year() + 2 cycle := &ProductCycle{ - Cycle: "16.1", - ReleaseDate: "2024-01-15", - Support: time.Date(futureYear, 1, 15, 0, 0, 0, 0, time.UTC).Format("2006-01-02"), - EOL: time.Date(futureYear+2, 1, 15, 0, 0, 0, 0, time.UTC).Format("2006-01-02"), + Cycle: "16.1", + ReleaseDate: "2024-01-15", + LatestReleaseDate: "2024-04-15", + LTS: "2024-06-15", + Support: time.Date(futureYear, 1, 15, 0, 0, 0, 0, time.UTC).Format("2006-01-02"), + EOL: time.Date(futureYear+2, 1, 15, 0, 0, 0, 0, time.UTC).Format("2006-01-02"), } lifecycle, err := adapter.AdaptCycle(cycle) @@ -33,8 +35,12 @@ func TestStandardSchemaAdapter_CurrentVersion(t *testing.T) { // Verify dates assert.NotNil(t, lifecycle.ReleaseDate) + assert.NotNil(t, lifecycle.LatestReleaseDate) + assert.NotNil(t, lifecycle.LTSDate) assert.NotNil(t, lifecycle.DeprecationDate) assert.NotNil(t, lifecycle.EOLDate) + assert.Equal(t, "2024-04-15", lifecycle.LatestReleaseDate.Format("2006-01-02")) + assert.Equal(t, "2024-06-15", lifecycle.LTSDate.Format("2006-01-02")) } func TestStandardSchemaAdapter_DeprecatedSupportWindow(t *testing.T) { @@ -244,6 +250,33 @@ func TestStandardSchemaAdapter_ExtendedSupportOverridesPastEOL(t *testing.T) { assert.True(t, lifecycle.IsDeprecated) } +func TestStandardSchemaAdapter_PreservesIntermediateSupportDate(t *testing.T) { + adapter := &StandardSchemaAdapter{} + + standardEnd := time.Now().AddDate(0, 0, 60) + securityEnd := time.Now().AddDate(0, 0, 120) + extendedEnd := time.Now().AddDate(3, 0, 0) + cycle := &ProductCycle{ + Cycle: "8.0.41", + Support: standardEnd.Format("2006-01-02"), + EOL: securityEnd.Format("2006-01-02"), + ExtendedSupport: extendedEnd.Format("2006-01-02"), + } + + lifecycle, err := adapter.AdaptCycle(cycle) + require.NoError(t, err) + require.NotNil(t, lifecycle.DeprecationDate) + require.NotNil(t, lifecycle.DeprecatedSupportEnd) + require.NotNil(t, lifecycle.ExtendedSupportEnd) + require.NotNil(t, lifecycle.EOLDate) + assert.Equal(t, standardEnd.Format("2006-01-02"), lifecycle.DeprecationDate.Format("2006-01-02")) + assert.Equal(t, securityEnd.Format("2006-01-02"), lifecycle.DeprecatedSupportEnd.Format("2006-01-02")) + assert.Equal(t, extendedEnd.Format("2006-01-02"), lifecycle.ExtendedSupportEnd.Format("2006-01-02")) + assert.Equal(t, extendedEnd.Format("2006-01-02"), lifecycle.EOLDate.Format("2006-01-02")) + assert.True(t, lifecycle.IsSupported) + assert.False(t, lifecycle.IsDeprecated) +} + func eksDeclarativeAdapter(t *testing.T) *DeclarativeSchemaAdapter { t.Helper() @@ -265,9 +298,10 @@ func lambdaActionableEOLAdapter(t *testing.T) *DeclarativeSchemaAdapter { t.Helper() adapter, err := NewDeclarativeSchemaAdapter(&DeclarativeLifecycleConfig{ - DeprecationDate: LifecycleDateSource{Field: lifecycleFieldSupport}, - ExtendedSupportEnd: LifecycleDateSource{Field: lifecycleFieldEOL}, - EOLDate: LifecycleDateSource{Field: lifecycleFieldSupport}, + DeprecationDate: LifecycleDateSource{Field: lifecycleFieldSupport}, + DeprecatedSupportEnd: LifecycleDateSource{Field: lifecycleFieldEOL}, + EOLDate: LifecycleDateSource{Field: lifecycleFieldEOL}, + DeprecatedWindow: lifecycleActionDeprecatedSupport, }) require.NoError(t, err) return adapter @@ -296,6 +330,7 @@ func TestStandardSchemaAdapter_LambdaDeprecatedSupportWindow(t *testing.T) { assert.False(t, lifecycle.IsExtendedSupport) assert.False(t, lifecycle.IsEOL) assert.NotNil(t, lifecycle.DeprecationDate) + assert.NotNil(t, lifecycle.DeprecatedSupportEnd) assert.Nil(t, lifecycle.ExtendedSupportEnd) assert.NotNil(t, lifecycle.EOLDate) } @@ -340,7 +375,7 @@ func TestStandardSchemaAdapter_LambdaCurrentStandardSupport(t *testing.T) { assert.False(t, lifecycle.IsEOL) } -func TestDeclarativeSchemaAdapter_LambdaUsesSupportAsActionableEOL(t *testing.T) { +func TestDeclarativeSchemaAdapter_LambdaPreservesActionableAndTerminalDates(t *testing.T) { adapter := lambdaActionableEOLAdapter(t) support := time.Now().AddDate(0, 0, 60) @@ -357,16 +392,17 @@ func TestDeclarativeSchemaAdapter_LambdaUsesSupportAsActionableEOL(t *testing.T) require.NotNil(t, lifecycle.EOLDate) require.NotNil(t, lifecycle.DeprecationDate) - require.NotNil(t, lifecycle.ExtendedSupportEnd) - assert.Equal(t, support.Format("2006-01-02"), lifecycle.EOLDate.Format("2006-01-02")) + require.NotNil(t, lifecycle.DeprecatedSupportEnd) + assert.Nil(t, lifecycle.ExtendedSupportEnd) + assert.Equal(t, terminal.Format("2006-01-02"), lifecycle.EOLDate.Format("2006-01-02")) assert.Equal(t, support.Format("2006-01-02"), lifecycle.DeprecationDate.Format("2006-01-02")) - assert.Equal(t, terminal.Format("2006-01-02"), lifecycle.ExtendedSupportEnd.Format("2006-01-02")) + assert.Equal(t, terminal.Format("2006-01-02"), lifecycle.DeprecatedSupportEnd.Format("2006-01-02")) assert.True(t, lifecycle.IsSupported) assert.False(t, lifecycle.IsDeprecated) assert.False(t, lifecycle.IsEOL) } -func TestDeclarativeSchemaAdapter_LambdaPastActionableEOLIsEOL(t *testing.T) { +func TestDeclarativeSchemaAdapter_LambdaPastActionableDateIsDeprecatedSupport(t *testing.T) { adapter := lambdaActionableEOLAdapter(t) cycle := &ProductCycle{ @@ -379,10 +415,10 @@ func TestDeclarativeSchemaAdapter_LambdaPastActionableEOLIsEOL(t *testing.T) { lifecycle, err := adapter.AdaptCycle(cycle) require.NoError(t, err) - assert.False(t, lifecycle.IsSupported) + assert.True(t, lifecycle.IsSupported) assert.True(t, lifecycle.IsDeprecated) - assert.True(t, lifecycle.IsEOL) - assert.False(t, lifecycle.IsDeprecatedSupport) + assert.False(t, lifecycle.IsEOL) + assert.True(t, lifecycle.IsDeprecatedSupport) assert.False(t, lifecycle.IsExtendedSupport) } diff --git a/pkg/eol/endoflife/client.go b/pkg/eol/endoflife/client.go index 69c981f..e7f3a0f 100644 --- a/pkg/eol/endoflife/client.go +++ b/pkg/eol/endoflife/client.go @@ -46,7 +46,7 @@ type ProductCycle struct { Support any `json:"support"` // End of standard support (YYYY-MM-DD or boolean) EOL any `json:"eol"` // End of life date (YYYY-MM-DD or boolean) ExtendedSupport any `json:"extendedSupport"` // Extended support availability (boolean or date) - LTS bool `json:"lts"` // Long-term support flag + LTS any `json:"lts"` // Long-term support flag or start date Latest string `json:"latest"` // Latest patch version LatestReleaseDate string `json:"latestReleaseDate"` // Latest patch release date } diff --git a/pkg/eol/endoflife/client_test.go b/pkg/eol/endoflife/client_test.go index 2fc939e..8999b46 100644 --- a/pkg/eol/endoflife/client_test.go +++ b/pkg/eol/endoflife/client_test.go @@ -26,10 +26,11 @@ func TestRealHTTPClient_GetProductCycles(t *testing.T) { { "cycle": "1.31", "releaseDate": "2024-11-19", + "latestReleaseDate": "2025-01-15", "support": "2025-12-19", "eol": "2027-05-19", "extendedSupport": true, - "lts": false + "lts": "2025-02-01" }, { "cycle": "1.30", @@ -102,6 +103,12 @@ func TestRealHTTPClient_GetProductCycles(t *testing.T) { if cycles[0].ReleaseDate != "2024-11-19" { t.Errorf("First cycle release date = %s, want 2024-11-19", cycles[0].ReleaseDate) } + if cycles[0].LatestReleaseDate != "2025-01-15" { + t.Errorf("First cycle latest release date = %s, want 2025-01-15", cycles[0].LatestReleaseDate) + } + if cycles[0].LTS != "2025-02-01" { + t.Errorf("First cycle lts = %v, want 2025-02-01", cycles[0].LTS) + } } }) } diff --git a/pkg/policy/default.go b/pkg/policy/default.go index 5dcbd2f..df9f731 100644 --- a/pkg/policy/default.go +++ b/pkg/policy/default.go @@ -96,18 +96,12 @@ func (p *DefaultPolicy) isYellowStatus(lifecycle *types.VersionLifecycle) bool { return true } - // Approaching EOL (within warning window) - if lifecycle.EOLDate != nil { - daysUntilEOL := int(time.Until(*lifecycle.EOLDate).Hours() / 24) - if daysUntilEOL > 0 && daysUntilEOL <= p.EOLWarningDays { - return true - } - } - - // Approaching deprecation - if lifecycle.DeprecationDate != nil { - daysUntilDeprecation := int(time.Until(*lifecycle.DeprecationDate).Hours() / 24) - if daysUntilDeprecation > 0 && daysUntilDeprecation <= p.EOLWarningDays { + // Approaching the first actionable lifecycle boundary. Later support + // dates are still exposed in the finding's eol block, but they should not + // create additional yellow warning windows before the first date arrives. + if warningDate, ok := firstFutureLifecycleDate(lifecycle); ok { + daysUntilWarning := int(time.Until(*warningDate).Hours() / 24) + if daysUntilWarning > 0 && daysUntilWarning <= p.EOLWarningDays { return true } } @@ -158,12 +152,16 @@ func (p *DefaultPolicy) getRedMessage(resource *types.Resource, lifecycle *types func (p *DefaultPolicy) getYellowMessage(resource *types.Resource, lifecycle *types.VersionLifecycle) string { if lifecycle.IsDeprecatedSupport { - if lifecycle.EOLDate != nil { + end := lifecycle.DeprecatedSupportEnd + if end == nil { + end = lifecycle.EOLDate + } + if end != nil { return fmt.Sprintf("%s %s for %s is in deprecated support until %s", versionSubject(resource), resource.CurrentVersion, resource.Engine, - lifecycle.EOLDate.Format("Jan 2, 2006")) + end.Format("Jan 2, 2006")) } return fmt.Sprintf("%s %s for %s is in deprecated support", versionSubject(resource), @@ -177,25 +175,21 @@ func (p *DefaultPolicy) getYellowMessage(resource *types.Resource, lifecycle *ty resource.Engine) } - if lifecycle.EOLDate != nil { - daysUntilEOL := int(time.Until(*lifecycle.EOLDate).Hours() / 24) - if daysUntilEOL > 0 && daysUntilEOL <= p.EOLWarningDays { + if warningDate, ok := firstFutureLifecycleDate(lifecycle); ok { + daysUntilWarning := int(time.Until(*warningDate).Hours() / 24) + if daysUntilWarning > 0 && daysUntilWarning <= p.EOLWarningDays { + if lifecycle.DeprecationDate != nil && warningDate.Equal(*lifecycle.DeprecationDate) { + return fmt.Sprintf("Version %s of %s will leave standard support in %d days (on %s)", + resource.CurrentVersion, + resource.Engine, + daysUntilWarning, + warningDate.Format("Jan 2, 2006")) + } return fmt.Sprintf("Version %s of %s will reach End-of-Life in %d days (on %s)", resource.CurrentVersion, resource.Engine, - daysUntilEOL, - lifecycle.EOLDate.Format("Jan 2, 2006")) - } - } - - if lifecycle.DeprecationDate != nil { - daysUntilDeprecation := int(time.Until(*lifecycle.DeprecationDate).Hours() / 24) - if daysUntilDeprecation > 0 && daysUntilDeprecation <= p.EOLWarningDays { - return fmt.Sprintf("Version %s of %s will be deprecated in %d days (on %s)", - resource.CurrentVersion, - resource.Engine, - daysUntilDeprecation, - lifecycle.DeprecationDate.Format("Jan 2, 2006")) + daysUntilWarning, + warningDate.Format("Jan 2, 2006")) } } @@ -226,6 +220,25 @@ func isLambda(resource *types.Resource) bool { return resource.Engine == "aws-lambda" || strings.EqualFold(resource.Type.String(), "lambda") } +func firstFutureLifecycleDate(lifecycle *types.VersionLifecycle) (*time.Time, bool) { + var first *time.Time + now := time.Now() + for _, date := range []*time.Time{ + lifecycle.DeprecationDate, + lifecycle.DeprecatedSupportEnd, + lifecycle.ExtendedSupportEnd, + lifecycle.EOLDate, + } { + if date == nil || !date.After(now) { + continue + } + if first == nil || date.Before(*first) { + first = date + } + } + return first, first != nil +} + // versionMatches checks if a resource version matches a lifecycle version. // endoflife.date uses major.minor cycles (e.g., "8.0") while resources may have // full versions (e.g., "8.0.35") or prefixed versions (e.g., "k8s-1.33"). diff --git a/pkg/policy/messages_test.go b/pkg/policy/messages_test.go index 7296564..2409a0e 100644 --- a/pkg/policy/messages_test.go +++ b/pkg/policy/messages_test.go @@ -130,10 +130,23 @@ func TestGetMessage_Yellow_ApproachingDeprecation(t *testing.T) { lc := &types.VersionLifecycle{DeprecationDate: &soon} got := p.GetMessage(res, lc, types.StatusYellow) - assert.Contains(t, got, "will be deprecated") + assert.Contains(t, got, "will leave standard support") assert.Regexp(t, `\d+ days`, got) } +func TestGetMessage_Yellow_UsesFirstLifecycleDate(t *testing.T) { + p := NewDefaultPolicy() + res := &types.Resource{Engine: "mysql", CurrentVersion: "8.0.41"} + standardEnd := time.Now().Add(30*24*time.Hour + time.Hour) + terminalEOL := time.Now().Add(3 * 365 * 24 * time.Hour) + lc := &types.VersionLifecycle{DeprecationDate: &standardEnd, EOLDate: &terminalEOL} + + got := p.GetMessage(res, lc, types.StatusYellow) + assert.Contains(t, got, "will leave standard support") + assert.Contains(t, got, standardEnd.Format("Jan 2, 2006")) + assert.NotContains(t, got, terminalEOL.Format("Jan 2, 2006")) +} + func TestGetMessage_Yellow_Fallback(t *testing.T) { // YELLOW with no typed reason — exercises the fallback branch // ("should be upgraded soon"). diff --git a/pkg/snapshot/builder_test.go b/pkg/snapshot/builder_test.go index bb51d6b..2ac0a7f 100644 --- a/pkg/snapshot/builder_test.go +++ b/pkg/snapshot/builder_test.go @@ -145,7 +145,7 @@ func TestBuilder_JSONWireShape(t *testing.T) { // TestBuilder_CurrentSchemaBreakWireShape locks in the current schema: // -// - snapshot.Version is "v3" +// - snapshot.Version is "v4" // - top-level Finding JSON no longer carries ResourceName, // CloudAccountID, CloudRegion, or Recommendation // - those values flow through Finding.Extra under the YAML logical @@ -157,6 +157,7 @@ func TestBuilder_JSONWireShape(t *testing.T) { func TestBuilder_CurrentSchemaBreakWireShape(t *testing.T) { standardSupportEnd := time.Date(2024, time.February, 29, 0, 0, 0, 0, time.UTC) extendedSupportEnd := time.Date(2027, time.February, 28, 0, 0, 0, 0, time.UTC) + latestReleaseDate := time.Date(2025, time.February, 13, 0, 0, 0, 0, time.UTC) snap := NewBuilder(). AddFindings(types.ResourceTypeAurora, []*types.Finding{ { @@ -171,10 +172,12 @@ func TestBuilder_CurrentSchemaBreakWireShape(t *testing.T) { "account_id": "123456789012", "region": "us-east-1", }, - LifecycleDetails: types.LifecycleDetails{ + EOL: types.LifecycleDetails{ StandardSupportEnd: &standardSupportEnd, ExtendedSupportEnd: &extendedSupportEnd, EOLDate: &extendedSupportEnd, + ActionableDate: &standardSupportEnd, + LatestReleaseDate: &latestReleaseDate, Version: "13", Engine: "aurora-postgresql", Source: "endoflife-date-api", @@ -186,7 +189,7 @@ func TestBuilder_CurrentSchemaBreakWireShape(t *testing.T) { }). Build() - assert.Equal(t, "v3", snap.Version, + assert.Equal(t, "v4", snap.Version, "snapshot schema must advertise v3 once recommendation is removed") raw, err := json.Marshal(snap) @@ -209,23 +212,28 @@ func TestBuilder_CurrentSchemaBreakWireShape(t *testing.T) { for _, banned := range []string{"ResourceName", "CloudAccountID", "CloudRegion", "Recommendation"} { _, present := finding[banned] assert.False(t, present, - "v3 finding JSON must not contain top-level %q", banned) + "v4 finding JSON must not contain top-level %q", banned) } // The values now live in Extra under their YAML logical names. extra, ok := finding["Extra"].(map[string]any) - require.True(t, ok, "v3 finding JSON must carry an Extra map") + require.True(t, ok, "v4 finding JSON must carry an Extra map") assert.Equal(t, "c1", extra["name"]) assert.Equal(t, "123456789012", extra["account_id"]) assert.Equal(t, "us-east-1", extra["region"]) - lifecycleDetails, ok := finding["lifecycle_details"].(map[string]any) - require.True(t, ok, "v3 finding JSON must carry lifecycle_details for downstream enrichment") - assert.Equal(t, "2024-02-29T00:00:00Z", lifecycleDetails["standard_support_end"]) - assert.Equal(t, "2027-02-28T00:00:00Z", lifecycleDetails["extended_support_end"]) - assert.Equal(t, "2027-02-28T00:00:00Z", lifecycleDetails["eol_date"]) - assert.Equal(t, "13", lifecycleDetails["version"]) - assert.Equal(t, "aurora-postgresql", lifecycleDetails["engine"]) - assert.Equal(t, "endoflife-date-api", lifecycleDetails["source"]) - assert.Equal(t, true, lifecycleDetails["is_extended_support"]) + _, hasSimpleEOLDate := finding["EOLDate"] + assert.False(t, hasSimpleEOLDate, "v4 finding JSON must not expose EOL as a simple date") + + eol, ok := finding["eol"].(map[string]any) + require.True(t, ok, "v4 finding JSON must carry an eol block for downstream enrichment") + assert.Equal(t, "2024-02-29T00:00:00Z", eol["standard_support_end"]) + assert.Equal(t, "2027-02-28T00:00:00Z", eol["extended_support_end"]) + assert.Equal(t, "2027-02-28T00:00:00Z", eol["eol_date"]) + assert.Equal(t, "2024-02-29T00:00:00Z", eol["actionable_date"]) + assert.Equal(t, "2025-02-13T00:00:00Z", eol["latest_release_date"]) + assert.Equal(t, "13", eol["version"]) + assert.Equal(t, "aurora-postgresql", eol["engine"]) + assert.Equal(t, "endoflife-date-api", eol["source"]) + assert.Equal(t, true, eol["is_extended_support"]) } diff --git a/pkg/snapshot/store.go b/pkg/snapshot/store.go index 6839c2c..fda7b9e 100644 --- a/pkg/snapshot/store.go +++ b/pkg/snapshot/store.go @@ -17,7 +17,11 @@ import ( const ( // SnapshotSchemaVersion is the current schema version for snapshots. // - // v3 (current): removed Finding.Recommendation from the snapshot schema; + // v4 (current): replaced the top-level Finding.EOLDate JSON date and + // lifecycle_details block with Finding.eol, a structured block that + // carries every provider-exposed support boundary, metadata dates, and + // actionable_date. + // v3 (deprecated): removed Finding.Recommendation from the snapshot schema; // remediation guidance belongs in curated docs, not in generated // findings. Finding.lifecycle_details is required metadata added for // downstream enrichment and remains backward-compatible within v3. @@ -29,7 +33,7 @@ const ( // ("name", "account_id", "region"). // v1 (deprecated): typed Finding included resource_name, // cloud_account_id, and cloud_region as top-level keys. - SnapshotSchemaVersion = "v3" + SnapshotSchemaVersion = "v4" ) // Store handles persisting snapshots to S3 diff --git a/pkg/types/lifecycle_details.go b/pkg/types/lifecycle_details.go index 539c55b..e1c74b6 100644 --- a/pkg/types/lifecycle_details.go +++ b/pkg/types/lifecycle_details.go @@ -5,18 +5,22 @@ import "time" // LifecycleDetails preserves structured lifecycle data on findings so // downstream enrichment can reason about support windows without re-fetching EOL data. type LifecycleDetails struct { - StandardSupportEnd *time.Time `json:"standard_support_end,omitempty"` - EOLDate *time.Time `json:"eol_date,omitempty"` - ExtendedSupportEnd *time.Time `json:"extended_support_end,omitempty"` - ReleaseDate *time.Time `json:"release_date,omitempty"` - FetchedAt time.Time `json:"fetched_at,omitempty"` - Version string `json:"version,omitempty"` - Engine string `json:"engine,omitempty"` - Source string `json:"source,omitempty"` - IsSupported bool `json:"is_supported"` - IsDeprecated bool `json:"is_deprecated"` - IsExtendedSupport bool `json:"is_extended_support"` - IsEOL bool `json:"is_eol"` + StandardSupportEnd *time.Time `json:"standard_support_end,omitempty"` + DeprecatedSupportEnd *time.Time `json:"deprecated_support_end,omitempty"` + EOLDate *time.Time `json:"eol_date,omitempty"` + ExtendedSupportEnd *time.Time `json:"extended_support_end,omitempty"` + ActionableDate *time.Time `json:"actionable_date,omitempty"` + ReleaseDate *time.Time `json:"release_date,omitempty"` + LatestReleaseDate *time.Time `json:"latest_release_date,omitempty"` + LTSDate *time.Time `json:"lts_date,omitempty"` + FetchedAt time.Time `json:"fetched_at,omitempty"` + Version string `json:"version,omitempty"` + Engine string `json:"engine,omitempty"` + Source string `json:"source,omitempty"` + IsSupported bool `json:"is_supported"` + IsDeprecated bool `json:"is_deprecated"` + IsExtendedSupport bool `json:"is_extended_support"` + IsEOL bool `json:"is_eol"` } // LifecycleDetailsFromVersionLifecycle converts EOL provider output into @@ -29,18 +33,41 @@ func LifecycleDetailsFromVersionLifecycle(lifecycle *VersionLifecycle) Lifecycle if standardSupportEnd == nil && lifecycle.ExtendedSupportEnd != nil { standardSupportEnd = lifecycle.EOLDate } + actionableDate := firstLifecycleDate( + standardSupportEnd, + lifecycle.DeprecatedSupportEnd, + lifecycle.ExtendedSupportEnd, + lifecycle.EOLDate, + ) return LifecycleDetails{ - ReleaseDate: lifecycle.ReleaseDate, - StandardSupportEnd: standardSupportEnd, - EOLDate: lifecycle.EOLDate, - ExtendedSupportEnd: lifecycle.ExtendedSupportEnd, - FetchedAt: lifecycle.FetchedAt, - Version: lifecycle.Version, - Engine: lifecycle.Engine, - Source: lifecycle.Source, - IsSupported: lifecycle.IsSupported, - IsDeprecated: lifecycle.IsDeprecated, - IsExtendedSupport: lifecycle.IsExtendedSupport, - IsEOL: lifecycle.IsEOL, + ReleaseDate: lifecycle.ReleaseDate, + LatestReleaseDate: lifecycle.LatestReleaseDate, + LTSDate: lifecycle.LTSDate, + StandardSupportEnd: standardSupportEnd, + DeprecatedSupportEnd: lifecycle.DeprecatedSupportEnd, + EOLDate: lifecycle.EOLDate, + ExtendedSupportEnd: lifecycle.ExtendedSupportEnd, + ActionableDate: actionableDate, + FetchedAt: lifecycle.FetchedAt, + Version: lifecycle.Version, + Engine: lifecycle.Engine, + Source: lifecycle.Source, + IsSupported: lifecycle.IsSupported, + IsDeprecated: lifecycle.IsDeprecated, + IsExtendedSupport: lifecycle.IsExtendedSupport, + IsEOL: lifecycle.IsEOL, } } + +func firstLifecycleDate(dates ...*time.Time) *time.Time { + var first *time.Time + for _, date := range dates { + if date == nil { + continue + } + if first == nil || date.Before(*first) { + first = date + } + } + return first +} diff --git a/pkg/types/resource.go b/pkg/types/resource.go index 62b74b3..dec49d9 100644 --- a/pkg/types/resource.go +++ b/pkg/types/resource.go @@ -85,9 +85,18 @@ type VersionLifecycle struct { // ExtendedSupportEnd is when extended support ends (if applicable) ExtendedSupportEnd *time.Time + // DeprecatedSupportEnd is when non-paid deprecated/security support ends (if applicable) + DeprecatedSupportEnd *time.Time + // DeprecationDate is when this version is deprecated DeprecationDate *time.Time + // LatestReleaseDate is when the current latest patch for this cycle was released + LatestReleaseDate *time.Time + + // LTSDate is when this version entered long-term support, if endoflife.date provides a date + LTSDate *time.Time + // FetchedAt is the timestamp when this lifecycle data was fetched FetchedAt time.Time @@ -136,11 +145,17 @@ type Finding struct { // attributes without a schema change. Extra map[string]string `json:",omitempty"` - // LifecycleDetails preserves structured lifecycle status for downstream enrichment. - LifecycleDetails LifecycleDetails `json:"lifecycle_details"` + // EOL preserves structured lifecycle status for downstream enrichment. + EOL LifecycleDetails `json:"eol"` - // EOLDate is when the current version reaches End-of-Life - EOLDate *time.Time + // LifecycleDetails preserves the same structured lifecycle status for + // in-process consumers. The snapshot wire format uses EOL instead of a + // second lifecycle_details block. + LifecycleDetails LifecycleDetails `json:"-"` + + // EOLDate is when the current version reaches End-of-Life. Kept for + // in-process compatibility; snapshots expose the structured EOL block. + EOLDate *time.Time `json:"-"` // DetectedAt is when this finding was first detected DetectedAt time.Time diff --git a/pkg/workflow/detection/activities.go b/pkg/workflow/detection/activities.go index 694275a..d5c6ba9 100644 --- a/pkg/workflow/detection/activities.go +++ b/pkg/workflow/detection/activities.go @@ -250,6 +250,7 @@ func (a *Activities) DetectDrift(ctx context.Context, input DetectInput) (*Detec // Classify using policy status := a.Policy.Classify(resource, lifecycle) message := a.Policy.GetMessage(resource, lifecycle, status) + lifecycleDetails := types.LifecycleDetailsFromVersionLifecycle(lifecycle) // Create finding. Name, account, and region (when configured) are // part of resource.Extra and propagate through verbatim. @@ -265,7 +266,8 @@ func (a *Activities) DetectDrift(ctx context.Context, input DetectInput) (*Detec EOLDate: lifecycle.EOLDate, Tags: resource.Tags, Extra: resource.Extra, - LifecycleDetails: types.LifecycleDetailsFromVersionLifecycle(lifecycle), + EOL: lifecycleDetails, + LifecycleDetails: lifecycleDetails, } findings = append(findings, finding) diff --git a/pkg/workflow/detection/activities_test.go b/pkg/workflow/detection/activities_test.go index bc5d55d..4428fce 100644 --- a/pkg/workflow/detection/activities_test.go +++ b/pkg/workflow/detection/activities_test.go @@ -336,6 +336,7 @@ func TestDetectDrift_PropagatesExtra(t *testing.T) { func TestDetectDrift_PropagatesLifecycleDetails(t *testing.T) { standardSupportEnd := time.Date(2024, time.February, 29, 0, 0, 0, 0, time.UTC) extendedSupportEnd := time.Date(2027, time.February, 28, 0, 0, 0, 0, time.UTC) + latestReleaseDate := time.Date(2025, time.February, 13, 0, 0, 0, 0, time.UTC) fetchedAt := time.Date(2026, time.May, 12, 0, 0, 0, 0, time.UTC) resources := []*types.Resource{ { @@ -354,6 +355,7 @@ func TestDetectDrift_PropagatesLifecycleDetails(t *testing.T) { DeprecationDate: &standardSupportEnd, ExtendedSupportEnd: &extendedSupportEnd, EOLDate: &extendedSupportEnd, + LatestReleaseDate: &latestReleaseDate, FetchedAt: fetchedAt, IsSupported: true, IsDeprecated: true, @@ -374,16 +376,20 @@ func TestDetectDrift_PropagatesLifecycleDetails(t *testing.T) { require.NoError(t, result.Get(&detect)) require.Len(t, detect.Findings, 1) - details := detect.Findings[0].LifecycleDetails + details := detect.Findings[0].EOL assert.Equal(t, "5.7", details.Version) assert.Equal(t, "mysql", details.Engine) assert.Equal(t, "endoflife-date-api", details.Source) require.NotNil(t, details.StandardSupportEnd) require.NotNil(t, details.ExtendedSupportEnd) require.NotNil(t, details.EOLDate) + require.NotNil(t, details.LatestReleaseDate) + require.NotNil(t, details.ActionableDate) assert.Equal(t, standardSupportEnd, *details.StandardSupportEnd) assert.Equal(t, extendedSupportEnd, *details.ExtendedSupportEnd) assert.Equal(t, extendedSupportEnd, *details.EOLDate) + assert.Equal(t, latestReleaseDate, *details.LatestReleaseDate) + assert.Equal(t, standardSupportEnd, *details.ActionableDate) assert.Equal(t, fetchedAt, details.FetchedAt) assert.True(t, details.IsExtendedSupport) } diff --git a/pkg/workflow/orchestrator/activities_test.go b/pkg/workflow/orchestrator/activities_test.go index 4c30045..81ec3d6 100644 --- a/pkg/workflow/orchestrator/activities_test.go +++ b/pkg/workflow/orchestrator/activities_test.go @@ -120,7 +120,7 @@ func TestActivities_CreateSnapshot_HappyPath(t *testing.T) { require.Equal(t, 1, fakeSnap.saveCallCount) require.NotNil(t, fakeSnap.saved) assert.Equal(t, "scan-123", fakeSnap.saved.SnapshotID) - assert.Equal(t, "v3", fakeSnap.saved.Version) + assert.Equal(t, "v4", fakeSnap.saved.Version) assert.Equal(t, int64(60), fakeSnap.saved.ScanDurationSec) assert.Equal(t, 3, fakeSnap.saved.Summary.TotalResources) }