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
4 changes: 2 additions & 2 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -460,7 +460,7 @@ s3://your-bucket/snapshots/
```json
{
"snapshot_id": "scan-2026-04-09-123456",
"version": "v1",
"version": "v3",
"generated_at": "2026-04-09T12:34:56Z",
"scan_start_time": "2026-04-09T12:00:00Z",
"scan_end_time": "2026-04-09T12:34:56Z",
Expand Down Expand Up @@ -554,7 +554,7 @@ func (e *JiraEmitter) Emit(ctx context.Context, snapshotID string, findings []*t
Fields: &jira.IssueFields{
Project: jira.Project{Key: "INFRA"},
Summary: finding.Message,
Description: finding.Recommendation,
Description: finding.Message,
Priority: e.mapPriority(finding.Status),
},
}
Expand Down
5 changes: 2 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -458,15 +458,14 @@ s3://your-bucket/snapshots/latest.json
```json
{
"snapshot_id": "scan-2026-04-09-123456",
"version": "v1",
"version": "v3",
"generated_at": "2026-04-09T12:34:56Z",
"findings_by_type": {
"aurora": [
{
"resource_id": "db-cluster-1",
"status": "red",
"message": "Running deprecated version 13.3 (EOL: 2025-03-01)",
"recommendation": "Upgrade to version 15.5 or later"
"message": "Running deprecated version 13.3 (EOL: 2025-03-01)"
}
]
},
Expand Down
3 changes: 0 additions & 3 deletions USAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,6 @@ Each finding includes:
- **Current Version**: What's running now
- **Status**: RED/YELLOW/GREEN
- **Message**: What's wrong
- **Recommendation**: What to do
- **EOL Date**: When version support ends

Examples:
Expand All @@ -57,15 +56,13 @@ Resource: arn:aws:rds:us-east-1:123456:cluster:my-db
Current Version: aurora-mysql 5.6.10a
Status: RED
Message: Version is past End-of-Life (EOL since Nov 2024)
Recommendation: Upgrade to aurora-mysql 8.0.35 immediately
EOL Date: 2024-11-01

# EKS
Resource: arn:aws:eks:us-west-2:123456:cluster/my-cluster
Current Version: k8s-1.27
Status: YELLOW
Message: Version in extended support (6x cost), ends 2025-11-24
Recommendation: Upgrade to k8s-1.31 to exit extended support
EOL Date: 2025-11-24
```

Expand Down
1 change: 0 additions & 1 deletion cmd/cli/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -158,7 +158,6 @@ func (c *FindingShowCmd) Run(_ *Context) error {
fmt.Println("Engine: aurora-mysql")
fmt.Println("EOL Date: 2024-11-01")
fmt.Println("Message: Version is past End-of-Life (EOL since Nov 2024)")
fmt.Println("Recommendation: Upgrade to aurora-mysql 8.0.35 immediately")

return nil
}
Expand Down
18 changes: 5 additions & 13 deletions pkg/config/defaults/resources.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -313,19 +313,11 @@ resources:
eol:
provider: endoflife-date
product: aws-lambda
schema: declarative
lifecycle:
# Lambda uses "Standard Support" and "Deprecated Support" instead
# of extendedSupport. Treat the deprecated-support window as the
# same YELLOW warning state Version Guard uses for extended support.
deprecation_date:
field: support
extended_support_end:
field: eol
eol_date:
field: eol
deprecated_window: extended_support
past_extended_support: eol
# Lambda uses the standard API shape: `support` marks the start of
# deprecated runtime support and `eol` is the terminal date. There is
# no `extendedSupport` field, so the standard adapter classifies that
# window as deprecated support, not paid extended support.
schema: standard

# To add a new resource type, follow skills/add-version-guard-resource.
# Standalone copy-paste templates live in skills/add-version-guard-resource/examples/.
1 change: 0 additions & 1 deletion pkg/emitters/examples/logging_emitter.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,6 @@ func (e *LoggingIssueTrackerEmitter) Emit(ctx context.Context, snapshotID string
// Would create or update issue
fmt.Printf("[%s] %s (%s)\n", f.Status, f.ResourceID, f.ResourceType)
fmt.Printf(" Message: %s\n", f.Message)
fmt.Printf(" Recommendation: %s\n", f.Recommendation)
fmt.Printf(" → Would create/update issue\n\n")
created++
case types.StatusGreen:
Expand Down
28 changes: 23 additions & 5 deletions pkg/eol/endoflife/adapters.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,10 @@ type SchemaAdapter interface {
// `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.
// 3. support + eol, no extendedSupport — deprecated-support pattern
// (AWS Lambda runtimes, and OSS products with maintenance/security
// support after active support). standardEnd = support,
// deprecatedSupportEnd = trueEOL = eol, no paid 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
Expand Down Expand Up @@ -81,9 +83,10 @@ func (a *StandardSchemaAdapter) parseCycleDates(cycle *ProductCycle) lifecycleDa
// 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
standardEnd *time.Time // last day of standard support
deprecatedSupportEnd *time.Time // last day of deprecated/non-paid support, if any
extendedEnd *time.Time // last day of extended support, if any
trueEOL *time.Time // last day the version is supported at all
}

func (a *StandardSchemaAdapter) deriveBoundaries(dates lifecycleDates) derivedBoundaries {
Expand Down Expand Up @@ -111,6 +114,14 @@ func (a *StandardSchemaAdapter) deriveBoundaries(dates lifecycleDates) derivedBo
if dates.support != nil {
b.standardEnd = dates.support
}
case dates.support != nil && dates.eol != nil && dates.eol.After(*dates.support):
// No extendedSupport field: the API is describing a warning
// window after active/standard support but before terminal EOL.
// This is not paid extended support, so policy must not use
// cost-avoidance wording.
b.standardEnd = dates.support
b.deprecatedSupportEnd = dates.eol
b.trueEOL = dates.eol
default:
// No extended support concept — the standard pattern.
b.trueEOL = dates.eol
Expand All @@ -137,6 +148,13 @@ func (a *StandardSchemaAdapter) classify(lifecycle *types.VersionLifecycle, b de
lifecycle.IsSupported = true
lifecycle.IsExtendedSupport = true
lifecycle.IsDeprecated = true
case b.deprecatedSupportEnd != nil && b.standardEnd != nil &&
now.After(*b.standardEnd) && now.Before(*b.deprecatedSupportEnd):
// In a deprecated-support window. This remains YELLOW-worthy,
// but it is not the paid extended-support state.
lifecycle.IsSupported = true
lifecycle.IsDeprecated = true
lifecycle.IsDeprecatedSupport = true
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).
Expand Down
43 changes: 17 additions & 26 deletions pkg/eol/endoflife/adapters_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,10 +37,12 @@ func TestStandardSchemaAdapter_CurrentVersion(t *testing.T) {
assert.NotNil(t, lifecycle.EOLDate)
}

func TestStandardSchemaAdapter_DeprecatedVersion(t *testing.T) {
func TestStandardSchemaAdapter_DeprecatedSupportWindow(t *testing.T) {
adapter := &StandardSchemaAdapter{}

// Version past standard support but before EOL
// Version past standard support but before EOL, with no
// extendedSupport field. This is deprecated support, not paid
// extended support.
cycle := &ProductCycle{
Cycle: "15.0",
ReleaseDate: "2023-01-15",
Expand All @@ -51,8 +53,10 @@ func TestStandardSchemaAdapter_DeprecatedVersion(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.IsDeprecatedSupport)
assert.False(t, lifecycle.IsExtendedSupport)
assert.False(t, lifecycle.IsEOL)
}

Expand Down Expand Up @@ -256,22 +260,8 @@ func eksDeclarativeAdapter(t *testing.T) *DeclarativeSchemaAdapter {
return adapter
}

func lambdaDeclarativeAdapter(t *testing.T) *DeclarativeSchemaAdapter {
t.Helper()

adapter, err := NewDeclarativeSchemaAdapter(&DeclarativeLifecycleConfig{
DeprecationDate: LifecycleDateSource{Field: lifecycleFieldSupport},
ExtendedSupportEnd: LifecycleDateSource{Field: lifecycleFieldEOL},
EOLDate: LifecycleDateSource{Field: lifecycleFieldEOL},
DeprecatedWindow: lifecycleActionExtendedSupport,
PastExtendedSupport: lifecycleActionEOL,
})
require.NoError(t, err)
return adapter
}

func TestDeclarativeSchemaAdapter_LambdaDeprecatedSupportWindow(t *testing.T) {
adapter := lambdaDeclarativeAdapter(t)
func TestStandardSchemaAdapter_LambdaDeprecatedSupportWindow(t *testing.T) {
adapter := &StandardSchemaAdapter{}

pastYear := time.Now().Year() - 1
futureYear := time.Now().Year() + 1
Expand All @@ -289,16 +279,16 @@ func TestDeclarativeSchemaAdapter_LambdaDeprecatedSupportWindow(t *testing.T) {
assert.Empty(t, lifecycle.Engine)
assert.True(t, lifecycle.IsSupported)
assert.True(t, lifecycle.IsDeprecated)
assert.True(t, lifecycle.IsExtendedSupport)
assert.True(t, lifecycle.IsDeprecatedSupport)
assert.False(t, lifecycle.IsExtendedSupport)
assert.False(t, lifecycle.IsEOL)
assert.NotNil(t, lifecycle.DeprecationDate)
assert.NotNil(t, lifecycle.ExtendedSupportEnd)
assert.Nil(t, lifecycle.ExtendedSupportEnd)
assert.NotNil(t, lifecycle.EOLDate)
assert.Equal(t, *lifecycle.EOLDate, *lifecycle.ExtendedSupportEnd)
}

func TestDeclarativeSchemaAdapter_LambdaPastDeprecatedSupport(t *testing.T) {
adapter := lambdaDeclarativeAdapter(t)
func TestStandardSchemaAdapter_LambdaPastDeprecatedSupport(t *testing.T) {
adapter := &StandardSchemaAdapter{}

cycle := &ProductCycle{
Cycle: "nodejs12.x",
Expand All @@ -312,12 +302,13 @@ func TestDeclarativeSchemaAdapter_LambdaPastDeprecatedSupport(t *testing.T) {

assert.False(t, lifecycle.IsSupported)
assert.True(t, lifecycle.IsDeprecated)
assert.False(t, lifecycle.IsDeprecatedSupport)
assert.False(t, lifecycle.IsExtendedSupport)
assert.True(t, lifecycle.IsEOL)
}

func TestDeclarativeSchemaAdapter_LambdaCurrentStandardSupport(t *testing.T) {
adapter := lambdaDeclarativeAdapter(t)
func TestStandardSchemaAdapter_LambdaCurrentStandardSupport(t *testing.T) {
adapter := &StandardSchemaAdapter{}

futureYear := time.Now().Year() + 1
cycle := &ProductCycle{
Expand Down
112 changes: 6 additions & 106 deletions pkg/eol/endoflife/provider.go
Original file line number Diff line number Diff line change
Expand Up @@ -119,16 +119,9 @@ func (p *Provider) Engines() []string {
// on the returned VersionLifecycle for downstream display; product
// resolution comes from p.product, set at construction time.
//
// Every returned lifecycle (matched, prefix-matched, or unknown) carries
// the product-wide RecommendedVersion — the latest currently-supported
// cycle the provider knows about — so the policy layer can suggest a
// concrete upgrade target without re-querying the provider.
//
// Concurrency note: this function MUST NOT mutate the *VersionLifecycle
// pointers it gets back from ListAllVersions — those are shared across
// concurrent callers via the cache. RecommendedVersion is therefore
// stamped onto every cached lifecycle once, at cache-population time
// inside ListAllVersions, and we read it from there.
// concurrent callers via the cache.
func (p *Provider) GetVersionLifecycle(ctx context.Context, engine, version string) (*types.VersionLifecycle, error) {
engine = strings.ToLower(engine)
version = strings.TrimSpace(version)
Expand Down Expand Up @@ -169,92 +162,15 @@ func (p *Provider) GetVersionLifecycle(ctx context.Context, engine, version stri
// Alternative (rejected): Return error - would cause workflow to skip resource,
// losing visibility into resources with incomplete EOL data coverage.
//
// We still want the unknown lifecycle to carry the product-wide
// recommendation fields so the policy layer can suggest an
// upgrade target even when the resource's exact version isn't on
// endoflife.date yet. It's safe to read them off the first
// cached lifecycle (every entry carries the same values, stamped
// once at cache-population time); empty if the cache itself is
// empty (404 product / no cycles).
var recommended, recommendedNonExt string
if len(versions) > 0 {
recommended = versions[0].RecommendedVersion
recommendedNonExt = versions[0].RecommendedNonExtendedVersion
}
return &types.VersionLifecycle{
Version: "", // Empty = unknown data, not unsupported version
Engine: engine,
IsSupported: false,
Source: p.Name(),
FetchedAt: time.Now(),
RecommendedVersion: recommended,
RecommendedNonExtendedVersion: recommendedNonExt,
Version: "", // Empty = unknown data, not unsupported version
Engine: engine,
IsSupported: false,
Source: p.Name(),
FetchedAt: time.Now(),
}, nil
}

// latestSupportedVersion picks the upgrade target the policy layer
// should recommend for this product as a *general* target — non-extended
// preferred, but extended fallback allowed so the user always gets
// *some* concrete cycle to point at on RED / YELLOW-approaching-EOL.
// If a stricter "must not be in extended support" answer is required
// (the YELLOW IsExtendedSupport branch needs this — see the comment
// on RecommendedNonExtendedVersion), use latestNonExtendedSupportedVersion
// instead.
//
// PRECONDITION: versions MUST be in newest-first order. We rely on
// endoflife.date returning cycles newest-first, and ListAllVersions
// preserves that ordering verbatim through convertCycle. The contract
// is pinned by TestProvider_ListAllVersions_PreservesCycleOrder so a
// future caching/reordering refactor that breaks the invariant fails
// loudly in CI rather than silently mis-recommending older cycles.
//
// Within that ordering we take the first cycle that is still
// supported and not already in (paid) extended support — that's the
// freshest cycle a customer can move to without paying the
// extended-support premium.
//
// If every supported cycle is in extended support (the product is
// fully past standard support across the board), we fall back to the
// newest extended-support cycle so the user still gets *some* concrete
// target. If nothing is supported at all, return "" — the policy layer
// is responsible for the no-target fallback message.
func latestSupportedVersion(versions []*types.VersionLifecycle) string {
var extendedFallback string
for _, v := range versions {
if !v.IsSupported {
continue
}
if !v.IsExtendedSupport {
return v.Version
}
if extendedFallback == "" {
extendedFallback = v.Version
}
}
return extendedFallback
}

// latestNonExtendedSupportedVersion picks the strictest upgrade target:
// the newest cycle that is BOTH IsSupported AND NOT IsExtendedSupport.
// Returns "" when no such cycle exists (every supported cycle for the
// product is already in extended support).
//
// The empty-string contract is meaningful — it tells the policy
// layer's YELLOW IsExtendedSupport branch that suggesting any concrete
// target would falsely claim "Upgrade to <X> to avoid extended
// support costs" while <X> is itself in extended support. See
// pkg/policy/default.go's getYellowRecommendation.
//
// Same newest-first PRECONDITION as latestSupportedVersion.
func latestNonExtendedSupportedVersion(versions []*types.VersionLifecycle) string {
for _, v := range versions {
if v.IsSupported && !v.IsExtendedSupport {
return v.Version
}
}
return ""
}

// ListAllVersions retrieves all versions for the provider's product.
// The engine argument is preserved on the returned VersionLifecycle
// values for downstream display; it does not affect which product is
Expand Down Expand Up @@ -324,22 +240,6 @@ func (p *Provider) ListAllVersions(ctx context.Context, engine string) ([]*types
versions = append(versions, lifecycle)
}

// Stamp the product-wide upgrade recommendations onto every
// cached lifecycle exactly once, before publishing to the
// cache. After this point versions[i].RecommendedVersion and
// versions[i].RecommendedNonExtendedVersion are immutable for
// the lifetime of this cache entry — concurrent readers in
// GetVersionLifecycle observe a stable value without further
// synchronization. Both fields are computed here (rather than
// derived in the policy layer) so the policy doesn't need to
// rescan the cycles slice on every finding.
recommended := latestSupportedVersion(versions)
recommendedNonExt := latestNonExtendedSupportedVersion(versions)
for _, v := range versions {
v.RecommendedVersion = recommended
v.RecommendedNonExtendedVersion = recommendedNonExt
}

// Cache the result
p.mu.Lock()
p.cache[cacheKey] = &cachedVersions{
Expand Down
Loading
Loading