Skip to content
Merged
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
11 changes: 7 additions & 4 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -475,22 +475,25 @@ 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.

```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": {
Expand Down
14 changes: 10 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down
12 changes: 6 additions & 6 deletions pkg/config/defaults/resources.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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/.
61 changes: 49 additions & 12 deletions pkg/eol/endoflife/adapters.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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

Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -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")
Expand Down Expand Up @@ -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)
Expand All @@ -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)

Expand Down Expand Up @@ -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 = &ltsDate
}
}
}

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))
Expand All @@ -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
Expand Down
66 changes: 51 additions & 15 deletions pkg/eol/endoflife/adapters_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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) {
Expand Down Expand Up @@ -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()

Expand All @@ -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
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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)
Expand All @@ -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{
Expand All @@ -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)
}

Expand Down
2 changes: 1 addition & 1 deletion pkg/eol/endoflife/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
Loading
Loading