From f3c0a94df1f374a387f935e5405f86b56d49fb52 Mon Sep 17 00:00:00 2001 From: Ben Apprederisse Date: Wed, 29 Apr 2026 13:41:55 -0700 Subject: [PATCH] Remove generated recommendations from findings schema --- ARCHITECTURE.md | 4 +- README.md | 5 +- USAGE.md | 3 - cmd/cli/main.go | 1 - pkg/config/defaults/resources.yaml | 18 +- pkg/emitters/examples/logging_emitter.go | 1 - pkg/eol/endoflife/adapters.go | 28 ++- pkg/eol/endoflife/adapters_test.go | 43 ++-- pkg/eol/endoflife/provider.go | 112 +--------- pkg/eol/endoflife/provider_test.go | 199 +---------------- pkg/policy/default.go | 110 +++------- pkg/policy/default_test.go | 212 +++++-------------- pkg/policy/policy.go | 3 - pkg/snapshot/builder_test.go | 20 +- pkg/snapshot/store.go | 7 +- pkg/snapshot/store_s3_test.go | 8 +- pkg/store/store.go | 2 +- pkg/types/resource.go | 33 +-- pkg/types/resource_test.go | 4 +- pkg/types/snapshot.go | 6 +- pkg/types/status.go | 2 +- pkg/workflow/detection/activities.go | 2 - pkg/workflow/orchestrator/activities_test.go | 2 +- 23 files changed, 178 insertions(+), 647 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index a97ee80..770f150 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -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", @@ -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), }, } diff --git a/README.md b/README.md index 0252d28..5bf101f 100644 --- a/README.md +++ b/README.md @@ -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)" } ] }, diff --git a/USAGE.md b/USAGE.md index 7e3ab90..04ae9ac 100644 --- a/USAGE.md +++ b/USAGE.md @@ -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: @@ -57,7 +56,6 @@ 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 @@ -65,7 +63,6 @@ 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 ``` diff --git a/cmd/cli/main.go b/cmd/cli/main.go index 0aa2e97..0d873dc 100644 --- a/cmd/cli/main.go +++ b/cmd/cli/main.go @@ -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 } diff --git a/pkg/config/defaults/resources.yaml b/pkg/config/defaults/resources.yaml index a02a864..f29e0bf 100644 --- a/pkg/config/defaults/resources.yaml +++ b/pkg/config/defaults/resources.yaml @@ -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/. diff --git a/pkg/emitters/examples/logging_emitter.go b/pkg/emitters/examples/logging_emitter.go index fe01513..f81267d 100644 --- a/pkg/emitters/examples/logging_emitter.go +++ b/pkg/emitters/examples/logging_emitter.go @@ -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: diff --git a/pkg/eol/endoflife/adapters.go b/pkg/eol/endoflife/adapters.go index 52d8c63..48047db 100644 --- a/pkg/eol/endoflife/adapters.go +++ b/pkg/eol/endoflife/adapters.go @@ -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 @@ -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 { @@ -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 @@ -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). diff --git a/pkg/eol/endoflife/adapters_test.go b/pkg/eol/endoflife/adapters_test.go index 33ea08c..72ebe98 100644 --- a/pkg/eol/endoflife/adapters_test.go +++ b/pkg/eol/endoflife/adapters_test.go @@ -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", @@ -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) } @@ -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 @@ -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", @@ -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{ diff --git a/pkg/eol/endoflife/provider.go b/pkg/eol/endoflife/provider.go index e532cd3..3c6b972 100644 --- a/pkg/eol/endoflife/provider.go +++ b/pkg/eol/endoflife/provider.go @@ -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) @@ -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 to avoid extended -// support costs" while 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 @@ -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{ diff --git a/pkg/eol/endoflife/provider_test.go b/pkg/eol/endoflife/provider_test.go index f09ec47..30df91c 100644 --- a/pkg/eol/endoflife/provider_test.go +++ b/pkg/eol/endoflife/provider_test.go @@ -561,133 +561,10 @@ func TestProvider_InterfaceCompliance(t *testing.T) { } = (*Provider)(nil) } -// TestLatestSupportedVersion exercises the heuristic that picks the -// upgrade target the policy layer recommends. Cycles are passed in -// newest-first to mirror endoflife.date's response ordering. -func TestLatestSupportedVersion(t *testing.T) { - //nolint:govet // field alignment sacrificed for table-test readability - tests := []struct { - name string - in []*types.VersionLifecycle - want string - }{ - { - name: "picks newest non-extended supported cycle", - in: []*types.VersionLifecycle{ - {Version: "17", IsSupported: true, IsExtendedSupport: false}, - {Version: "16", IsSupported: true, IsExtendedSupport: false}, - {Version: "14", IsSupported: true, IsExtendedSupport: true}, - {Version: "12", IsSupported: false}, - }, - want: "17", - }, - { - name: "skips unsupported cycles even if newer", - in: []*types.VersionLifecycle{ - {Version: "18-beta", IsSupported: false}, - {Version: "17", IsSupported: true, IsExtendedSupport: false}, - }, - want: "17", - }, - { - name: "falls back to extended-support when nothing is in standard support", - in: []*types.VersionLifecycle{ - {Version: "14", IsSupported: true, IsExtendedSupport: true}, - {Version: "12", IsSupported: false}, - }, - want: "14", - }, - { - name: "returns empty when no cycle is supported", - in: []*types.VersionLifecycle{ - {Version: "12", IsSupported: false}, - {Version: "11", IsSupported: false}, - }, - want: "", - }, - { - name: "returns empty for nil/empty slice", - in: nil, - want: "", - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := latestSupportedVersion(tt.in) - if got != tt.want { - t.Errorf("latestSupportedVersion = %q, want %q", got, tt.want) - } - }) - } -} - -// TestLatestNonExtendedSupportedVersion exercises the strict variant -// used by the YELLOW IsExtendedSupport path. Empty result is a -// meaningful signal — see provider.go's doc comment — so the -// "all-extended" case is the most important table entry here. -func TestLatestNonExtendedSupportedVersion(t *testing.T) { - //nolint:govet // field alignment sacrificed for table-test readability - tests := []struct { - name string - in []*types.VersionLifecycle - want string - }{ - { - name: "picks newest non-extended supported cycle", - in: []*types.VersionLifecycle{ - {Version: "17", IsSupported: true, IsExtendedSupport: false}, - {Version: "16", IsSupported: true, IsExtendedSupport: false}, - {Version: "14", IsSupported: true, IsExtendedSupport: true}, - }, - want: "17", - }, - { - name: "skips extended-support cycles even if newer", - in: []*types.VersionLifecycle{ - {Version: "17", IsSupported: true, IsExtendedSupport: true}, - {Version: "16", IsSupported: true, IsExtendedSupport: false}, - }, - want: "16", - }, - { - name: "all supported cycles in extended support → empty (signals fallback)", - in: []*types.VersionLifecycle{ - {Version: "13", IsSupported: true, IsExtendedSupport: true}, - {Version: "12", IsSupported: true, IsExtendedSupport: true}, - {Version: "11", IsSupported: false}, - }, - want: "", - }, - { - name: "no supported cycles at all → empty", - in: []*types.VersionLifecycle{ - {Version: "12", IsSupported: false}, - {Version: "11", IsSupported: false}, - }, - want: "", - }, - { - name: "nil/empty slice → empty", - in: nil, - want: "", - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := latestNonExtendedSupportedVersion(tt.in) - if got != tt.want { - t.Errorf("latestNonExtendedSupportedVersion = %q, want %q", got, tt.want) - } - }) - } -} - -// TestProvider_ListAllVersions_PreservesCycleOrder pins the invariant -// that latestSupportedVersion depends on: cycles flow through -// ListAllVersions in the same order the upstream client returned -// them. If a future refactor sorts/dedupes/groups cycles inside -// ListAllVersions, the recommendation heuristic silently breaks; this -// test fails first. +// TestProvider_ListAllVersions_PreservesCycleOrder documents that cycles flow +// through ListAllVersions in the same order the upstream client returned them. +// Future consumers may care about this ordering even though the provider does +// not currently derive upgrade targets from it. func TestProvider_ListAllVersions_PreservesCycleOrder(t *testing.T) { mockClient := &MockClient{ GetProductCyclesFunc: func(_ context.Context, _ string) ([]*ProductCycle, error) { @@ -721,8 +598,7 @@ func TestProvider_ListAllVersions_PreservesCycleOrder(t *testing.T) { // TestProvider_GetVersionLifecycle_ConcurrentSafe verifies that // concurrent callers on the same product do not race on shared cached // *VersionLifecycle pointers. Run with `go test -race` to catch any -// regression that re-introduces the cache-mutation race fixed by -// stamping RecommendedVersion at cache-population time. +// regression that re-introduces shared cache mutation. func TestProvider_GetVersionLifecycle_ConcurrentSafe(t *testing.T) { mockClient := &MockClient{ GetProductCyclesFunc: func(_ context.Context, _ string) ([]*ProductCycle, error) { @@ -758,8 +634,8 @@ func TestProvider_GetVersionLifecycle_ConcurrentSafe(t *testing.T) { t.Errorf("GetVersionLifecycle(%q) error: %v", v, err) return } - if lifecycle.RecommendedVersion != "17" { - t.Errorf("RecommendedVersion = %q, want %q", lifecycle.RecommendedVersion, "17") + if lifecycle == nil { + t.Errorf("GetVersionLifecycle(%q) returned nil lifecycle", v) return } } @@ -767,64 +643,3 @@ func TestProvider_GetVersionLifecycle_ConcurrentSafe(t *testing.T) { } wg.Wait() } - -// TestProvider_GetVersionLifecycle_PopulatesRecommendedVersion verifies -// that every code path through GetVersionLifecycle (exact match, -// prefix match, unknown) stamps the product-wide RecommendedVersion -// onto the returned lifecycle so the policy layer can read it -// without re-querying the provider. -func TestProvider_GetVersionLifecycle_PopulatesRecommendedVersion(t *testing.T) { - mockClient := &MockClient{ - GetProductCyclesFunc: func(_ context.Context, _ string) ([]*ProductCycle, error) { - return []*ProductCycle{ - // Newest first — the newest non-extended supported - // cycle (16.2) is what we expect to surface. - {Cycle: "16.2", ReleaseDate: "2024-05-09", Support: "2028-11-09", EOL: "2028-11-09"}, - {Cycle: "14.10", ReleaseDate: "2022-11-10", Support: "2024-11-12", EOL: "2027-11-12", ExtendedSupport: "2027-11-12"}, - {Cycle: "12.18", ReleaseDate: "2020-11-12", Support: "2024-11-14", EOL: "2024-11-14"}, - }, nil - }, - } - provider, _ := NewProvider(mockClient, "amazon-rds-postgresql", "", 1*time.Hour, nil) - - tests := []struct { - name string - version string - wantRecommendedV string - wantMatchedCycleV string // empty means we expect the unknown lifecycle - }{ - { - name: "exact match still receives RecommendedVersion", - version: "16.2", - wantRecommendedV: "16.2", - wantMatchedCycleV: "16.2", - }, - { - name: "prefix match still receives RecommendedVersion", - version: "16.2.3", - wantRecommendedV: "16.2", - wantMatchedCycleV: "16.2", - }, - { - name: "unknown version still receives RecommendedVersion", - version: "99.0", - wantRecommendedV: "16.2", - wantMatchedCycleV: "", // unknown lifecycle has empty Version - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - lifecycle, err := provider.GetVersionLifecycle(context.Background(), "postgres", tt.version) - if err != nil { - t.Fatalf("GetVersionLifecycle error: %v", err) - } - if lifecycle.RecommendedVersion != tt.wantRecommendedV { - t.Errorf("RecommendedVersion = %q, want %q", lifecycle.RecommendedVersion, tt.wantRecommendedV) - } - if lifecycle.Version != tt.wantMatchedCycleV { - t.Errorf("Version = %q, want %q", lifecycle.Version, tt.wantMatchedCycleV) - } - }) - } -} diff --git a/pkg/policy/default.go b/pkg/policy/default.go index 426f845..5dcbd2f 100644 --- a/pkg/policy/default.go +++ b/pkg/policy/default.go @@ -33,8 +33,8 @@ func (p *DefaultPolicy) Name() string { // Classify determines the compliance status based on version lifecycle // // Classification Rules: -// - RED: Past EOL, deprecated, or extended support expired -// - YELLOW: In extended support (costly), or approaching EOL (< 90 days) +// - RED: Past EOL, unsupported deprecated, or extended support expired +// - YELLOW: In extended/deprecated support, or approaching EOL (< 90 days) // - GREEN: Current supported version // - UNKNOWN: Version not found in EOL database func (p *DefaultPolicy) Classify(resource *types.Resource, lifecycle *types.VersionLifecycle) types.Status { @@ -71,8 +71,8 @@ func (p *DefaultPolicy) isRedStatus(lifecycle *types.VersionLifecycle) bool { return true } - // Deprecated (but not if still in extended support) - if lifecycle.IsDeprecated && !lifecycle.IsExtendedSupport { + // Deprecated (but not if still in a supported warning window) + if lifecycle.IsDeprecated && !lifecycle.IsExtendedSupport && !lifecycle.IsDeprecatedSupport { return true } @@ -86,7 +86,12 @@ func (p *DefaultPolicy) isRedStatus(lifecycle *types.VersionLifecycle) bool { // isYellowStatus checks if the lifecycle indicates a YELLOW status func (p *DefaultPolicy) isYellowStatus(lifecycle *types.VersionLifecycle) bool { - // In extended support (higher cost) + // In deprecated support (for example Lambda deprecated runtimes). + if lifecycle.IsDeprecatedSupport { + return true + } + + // In extended support. if p.WarnExtendedSupport && lifecycle.IsExtendedSupport { return true } @@ -152,8 +157,22 @@ 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 { + 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")) + } + return fmt.Sprintf("%s %s for %s is in deprecated support", + versionSubject(resource), + resource.CurrentVersion, + resource.Engine) + } + if lifecycle.IsExtendedSupport { - return fmt.Sprintf("Version %s of %s is in extended support (6x standard cost)", + return fmt.Sprintf("Version %s of %s is in extended support", resource.CurrentVersion, resource.Engine) } @@ -196,82 +215,15 @@ func (p *DefaultPolicy) getUnknownMessage(resource *types.Resource, lifecycle *t return fmt.Sprintf("Unable to determine support status for %s version %s", resource.Engine, resource.CurrentVersion) } -// GetRecommendation generates a recommendation for addressing the issue -func (p *DefaultPolicy) GetRecommendation(resource *types.Resource, lifecycle *types.VersionLifecycle, status types.Status) string { - switch status { - case types.StatusRed: - return p.getRedRecommendation(resource, lifecycle) - case types.StatusYellow: - return p.getYellowRecommendation(resource, lifecycle) - case types.StatusGreen: - return "No action required" - case types.StatusUnknown: - return "Verify version and check EOL database" - default: - return "Unable to provide recommendation" +func versionSubject(resource *types.Resource) string { + if isLambda(resource) { + return "Runtime" } + return "Version" } -// usableUpgradeTarget returns candidate as the suggested upgrade -// target if it's both non-empty AND different from the resource's -// current cycle. Recommending the same cycle the resource is already -// on (which can happen on the YELLOW approaching-EOL path when the -// user is already on the newest supported cycle) would produce a -// confusing "Upgrade to X" message that's effectively a no-op, so we -// fall through to the generic wording instead. -// -// Callers pass the right candidate for their context: -// -// - getRedRecommendation and the YELLOW approaching-EOL branch use -// lifecycle.RecommendedVersion (extended-support fallback allowed — -// a target in extended support is still better than no target). -// - The YELLOW IsExtendedSupport branch uses -// lifecycle.RecommendedNonExtendedVersion. Suggesting another -// extended-support cycle there would falsely promise the upgrade -// "avoids extended support costs" when it doesn't. -func usableUpgradeTarget(resource *types.Resource, lifecycle *types.VersionLifecycle, candidate string) string { - if candidate == "" { - return "" - } - if candidate == lifecycle.Version || candidate == resource.CurrentVersion { - return "" - } - return candidate -} - -func (p *DefaultPolicy) getRedRecommendation(resource *types.Resource, lifecycle *types.VersionLifecycle) string { - // Past EOL — any supported cycle restores support, so prefer the - // general RecommendedVersion (extended-support fallback included). - if rec := usableUpgradeTarget(resource, lifecycle, lifecycle.RecommendedVersion); rec != "" { - return fmt.Sprintf("Upgrade to %s %s immediately to restore support", - resource.Engine, rec) - } - - return fmt.Sprintf("Upgrade to the latest supported version of %s immediately", resource.Engine) -} - -func (p *DefaultPolicy) getYellowRecommendation(resource *types.Resource, lifecycle *types.VersionLifecycle) string { - if lifecycle.IsExtendedSupport { - // "Avoid extended support costs" requires a target that is - // itself NOT in extended support. RecommendedNonExtendedVersion - // is empty when every supported cycle for this product is - // already in extended support — fall back to the neutral - // wording rather than over-promising cost relief. - if rec := usableUpgradeTarget(resource, lifecycle, lifecycle.RecommendedNonExtendedVersion); rec != "" { - return fmt.Sprintf("Upgrade to %s %s to avoid extended support costs", - resource.Engine, rec) - } - return fmt.Sprintf("Upgrade to a supported version of %s to avoid extended support costs", resource.Engine) - } - - // Approaching EOL — any supported target buys the user runway, so - // the general RecommendedVersion is fine here. - if rec := usableUpgradeTarget(resource, lifecycle, lifecycle.RecommendedVersion); rec != "" { - return fmt.Sprintf("Plan upgrade to %s %s within the next 90 days", - resource.Engine, rec) - } - - return fmt.Sprintf("Plan upgrade to the latest supported version of %s within the next 90 days", resource.Engine) +func isLambda(resource *types.Resource) bool { + return resource.Engine == "aws-lambda" || strings.EqualFold(resource.Type.String(), "lambda") } // versionMatches checks if a resource version matches a lifecycle version. diff --git a/pkg/policy/default_test.go b/pkg/policy/default_test.go index ee4f222..fceac36 100644 --- a/pkg/policy/default_test.go +++ b/pkg/policy/default_test.go @@ -102,6 +102,31 @@ func TestDefaultPolicy_Classify_ExtendedSupport(t *testing.T) { } } +func TestDefaultPolicy_Classify_DeprecatedSupport(t *testing.T) { + policy := NewDefaultPolicy() + + resource := &types.Resource{ + Engine: "aws-lambda", + CurrentVersion: "go1.x", + } + + lifecycle := &types.VersionLifecycle{ + Version: "go1.x", + Engine: "aws-lambda", + IsDeprecated: true, + IsDeprecatedSupport: true, + IsSupported: true, + DeprecationDate: datePtr(time.Now().AddDate(0, -1, 0)), + EOLDate: datePtr(time.Now().AddDate(0, 5, 0)), + } + + status := policy.Classify(resource, lifecycle) + + if status != types.StatusYellow { + t.Errorf("Expected YELLOW status for deprecated support, got %s", status) + } +} + func TestDefaultPolicy_Classify_ApproachingEOL(t *testing.T) { policy := NewDefaultPolicy() policy.EOLWarningDays = 90 @@ -257,180 +282,39 @@ func TestDefaultPolicy_GetMessage_YellowExtendedSupport(t *testing.T) { if !contains(message, expectedSubstring) { t.Errorf("Expected message to contain '%s', got: %s", expectedSubstring, message) } -} - -func TestDefaultPolicy_GetMessage_Green(t *testing.T) { - policy := NewDefaultPolicy() - - resource := &types.Resource{ - Engine: "aurora-mysql", - CurrentVersion: "8.0.35", - } - - lifecycle := &types.VersionLifecycle{ - Version: "8.0.35", - Engine: "aurora-mysql", - IsSupported: true, - } - - message := policy.GetMessage(resource, lifecycle, types.StatusGreen) - - expectedSubstring := "currently supported" - if !contains(message, expectedSubstring) { - t.Errorf("Expected message to contain '%s', got: %s", expectedSubstring, message) - } -} - -func TestDefaultPolicy_GetRecommendation_Red(t *testing.T) { - policy := NewDefaultPolicy() - - resource := &types.Resource{ - Engine: "aurora-mysql", - CurrentVersion: "5.6.10a", - } - - // RecommendedVersion is populated by the EOL provider from the - // latest supported cycle; the policy reads it verbatim into the - // recommendation string. - lifecycle := &types.VersionLifecycle{ - Version: "5.6.10a", - Engine: "aurora-mysql", - IsEOL: true, - RecommendedVersion: "8.0.35", - } - - recommendation := policy.GetRecommendation(resource, lifecycle, types.StatusRed) - - expectedSubstring := "Upgrade" - if !contains(recommendation, expectedSubstring) { - t.Errorf("Expected recommendation to contain '%s', got: %s", expectedSubstring, recommendation) - } - - expectedVersionSubstring := "8.0.35" - if !contains(recommendation, expectedVersionSubstring) { - t.Errorf("Expected recommendation to contain '%s', got: %s", expectedVersionSubstring, recommendation) - } -} - -// TestDefaultPolicy_GetRecommendation_YellowExtendedSupport_AllRecommendationsExtended -// pins the fix for the "every supported cycle is itself in extended -// support" case. The naive code path would suggest "Upgrade to to -// avoid extended support costs" while is still in extended -// support — a false promise. RecommendedNonExtendedVersion is empty -// in this scenario; the policy must drop the concrete target and -// fall back to the neutral "supported version" wording. -func TestDefaultPolicy_GetRecommendation_YellowExtendedSupport_AllRecommendationsExtended(t *testing.T) { - policy := NewDefaultPolicy() - - resource := &types.Resource{ - Engine: "redis", - CurrentVersion: "5.0", - } - lifecycle := &types.VersionLifecycle{ - Version: "5.0", - Engine: "redis", - IsSupported: true, - IsExtendedSupport: true, - RecommendedVersion: "6.2", // a newer cycle, but - RecommendedNonExtendedVersion: "", // ...also in extended support - } - - recommendation := policy.GetRecommendation(resource, lifecycle, types.StatusYellow) - - // Must NOT suggest "Upgrade to redis 6.2 to avoid extended support costs" - // because 6.2 itself is in extended support — the upgrade wouldn't - // actually avoid the costs. - if contains(recommendation, "to redis 6.2") { - t.Errorf("recommendation surfaced an extended-support target as cost-avoidance: %q", recommendation) - } - expected := "Upgrade to a supported version of redis to avoid extended support costs" - if recommendation != expected { - t.Errorf("Expected fallback recommendation %q, got: %q", expected, recommendation) + if contains(message, "6x") { + t.Errorf("message must not hard-code extended support pricing: %s", message) } } -// TestDefaultPolicy_GetRecommendation_Yellow_SuppressesSelfRecommendation -// pins the behavior where the EOL provider reports the same cycle as -// both the user's current version and the recommendation (e.g., the -// resource is already on the newest supported cycle but is now -// approaching that cycle's EOL date). Suggesting "Upgrade to 17" -// when the user is already on 17 is misleading, so the policy must -// fall back to the generic "latest supported version" wording. -func TestDefaultPolicy_GetRecommendation_Yellow_SuppressesSelfRecommendation(t *testing.T) { +func TestDefaultPolicy_GetMessage_YellowDeprecatedSupport(t *testing.T) { policy := NewDefaultPolicy() resource := &types.Resource{ - Engine: "aurora-postgresql", - CurrentVersion: "17", - } - lifecycle := &types.VersionLifecycle{ - Version: "17", - Engine: "aurora-postgresql", - IsSupported: true, - RecommendedVersion: "17", // same as the resource's current cycle - } - - recommendation := policy.GetRecommendation(resource, lifecycle, types.StatusYellow) - - // Must not suggest upgrading to the same cycle the resource is on. - if contains(recommendation, "to aurora-postgresql 17") { - t.Errorf("recommendation suggested upgrading to current cycle: %q", recommendation) - } - expected := "Plan upgrade to the latest supported version of aurora-postgresql within the next 90 days" - if recommendation != expected { - t.Errorf("Expected fallback recommendation %q, got: %q", expected, recommendation) - } -} - -// TestDefaultPolicy_GetRecommendation_Red_NoRecommendation verifies the -// fallback path when the EOL provider didn't supply a RecommendedVersion -// (e.g., 404 product on endoflife.date, every cycle past EOL). -func TestDefaultPolicy_GetRecommendation_Red_NoRecommendation(t *testing.T) { - policy := NewDefaultPolicy() - - resource := &types.Resource{ - Engine: "aurora-mysql", - CurrentVersion: "5.6.10a", + Engine: "aws-lambda", + CurrentVersion: "nodejs18.x", } lifecycle := &types.VersionLifecycle{ - Version: "5.6.10a", - Engine: "aurora-mysql", - IsEOL: true, - // RecommendedVersion intentionally empty + Version: "nodejs18.x", + Engine: "aws-lambda", + IsDeprecated: true, + IsDeprecatedSupport: true, + IsSupported: true, + EOLDate: datePtr(time.Date(2026, 9, 30, 0, 0, 0, 0, time.UTC)), } - recommendation := policy.GetRecommendation(resource, lifecycle, types.StatusRed) - - expected := "Upgrade to the latest supported version of aurora-mysql immediately" - if recommendation != expected { - t.Errorf("Expected fallback recommendation %q, got: %q", expected, recommendation) - } -} - -func TestDefaultPolicy_GetRecommendation_YellowExtendedSupport(t *testing.T) { - policy := NewDefaultPolicy() - - resource := &types.Resource{ - Engine: "redis", - CurrentVersion: "5.0", - } + message := policy.GetMessage(resource, lifecycle, types.StatusYellow) - lifecycle := &types.VersionLifecycle{ - Version: "5.0", - Engine: "redis", - IsExtendedSupport: true, + if !contains(message, "Runtime nodejs18.x for aws-lambda is in deprecated support until Sep 30, 2026") { + t.Errorf("unexpected deprecated-support message: %s", message) } - - recommendation := policy.GetRecommendation(resource, lifecycle, types.StatusYellow) - - expectedSubstring := "extended support costs" - if !contains(recommendation, expectedSubstring) { - t.Errorf("Expected recommendation to contain '%s', got: %s", expectedSubstring, recommendation) + if contains(message, "extended support") || contains(message, "cost") { + t.Errorf("deprecated-support message must not use extended-support cost wording: %s", message) } } -func TestDefaultPolicy_GetRecommendation_Green(t *testing.T) { +func TestDefaultPolicy_GetMessage_Green(t *testing.T) { policy := NewDefaultPolicy() resource := &types.Resource{ @@ -444,11 +328,11 @@ func TestDefaultPolicy_GetRecommendation_Green(t *testing.T) { IsSupported: true, } - recommendation := policy.GetRecommendation(resource, lifecycle, types.StatusGreen) + message := policy.GetMessage(resource, lifecycle, types.StatusGreen) - expected := "No action required" - if recommendation != expected { - t.Errorf("Expected recommendation '%s', got: %s", expected, recommendation) + expectedSubstring := "currently supported" + if !contains(message, expectedSubstring) { + t.Errorf("Expected message to contain '%s', got: %s", expectedSubstring, message) } } @@ -476,3 +360,7 @@ func containsHelper(s, substr string) bool { } return false } + +func datePtr(t time.Time) *time.Time { + return &t +} diff --git a/pkg/policy/policy.go b/pkg/policy/policy.go index 827b3ac..0a066e9 100644 --- a/pkg/policy/policy.go +++ b/pkg/policy/policy.go @@ -11,9 +11,6 @@ type VersionPolicy interface { // GetMessage generates a human-readable message describing the status GetMessage(resource *types.Resource, lifecycle *types.VersionLifecycle, status types.Status) string - // GetRecommendation generates a recommendation for addressing the issue - GetRecommendation(resource *types.Resource, lifecycle *types.VersionLifecycle, status types.Status) string - // Name returns the name of this policy Name() string } diff --git a/pkg/snapshot/builder_test.go b/pkg/snapshot/builder_test.go index ff00094..835dd7c 100644 --- a/pkg/snapshot/builder_test.go +++ b/pkg/snapshot/builder_test.go @@ -143,18 +143,18 @@ func TestBuilder_JSONWireShape(t *testing.T) { } } -// TestBuilder_V2SchemaBreakWireShape locks in the v2 break: +// TestBuilder_CurrentSchemaBreakWireShape locks in the current schema: // -// - snapshot.Version is "v2" +// - snapshot.Version is "v3" // - top-level Finding JSON no longer carries ResourceName, -// CloudAccountID, or CloudRegion +// CloudAccountID, CloudRegion, or Recommendation // - those values flow through Finding.Extra under the YAML logical // names "name", "account_id", "region" // // Reverting any of these would silently re-introduce the v1 wire shape // downstream tools have been told to drop, so the test fails fast on // regressions. -func TestBuilder_V2SchemaBreakWireShape(t *testing.T) { +func TestBuilder_CurrentSchemaBreakWireShape(t *testing.T) { snap := NewBuilder(). AddFindings(types.ResourceTypeAurora, []*types.Finding{ { @@ -173,8 +173,8 @@ func TestBuilder_V2SchemaBreakWireShape(t *testing.T) { }). Build() - assert.Equal(t, "v2", snap.Version, - "snapshot schema must advertise v2 once typed core is tightened") + assert.Equal(t, "v3", snap.Version, + "snapshot schema must advertise v3 once recommendation is removed") raw, err := json.Marshal(snap) require.NoError(t, err) @@ -192,16 +192,16 @@ func TestBuilder_V2SchemaBreakWireShape(t *testing.T) { finding, ok := auroraFindings[0].(map[string]any) require.True(t, ok) - // v1 top-level keys must be gone. - for _, banned := range []string{"ResourceName", "CloudAccountID", "CloudRegion"} { + // Removed top-level keys must stay gone. + for _, banned := range []string{"ResourceName", "CloudAccountID", "CloudRegion", "Recommendation"} { _, present := finding[banned] assert.False(t, present, - "v2 finding JSON must not contain top-level %q (moved into Extra)", banned) + "v3 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, "v2 finding JSON must carry an Extra map") + require.True(t, ok, "v3 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"]) diff --git a/pkg/snapshot/store.go b/pkg/snapshot/store.go index 4790b46..be47e27 100644 --- a/pkg/snapshot/store.go +++ b/pkg/snapshot/store.go @@ -17,7 +17,10 @@ import ( const ( // SnapshotSchemaVersion is the current schema version for snapshots. // - // v2 (current): tightened the typed Finding surface to only the + // v3 (current): removed Finding.Recommendation from the snapshot + // schema; remediation guidance belongs in curated docs, not in + // generated findings. + // v2 (deprecated): tightened the typed Finding surface to only the // fields the system itself requires (identity, EOL keys, service, // classification metadata, tags). Top-level resource_name, // cloud_account_id, and cloud_region keys were removed; their @@ -25,7 +28,7 @@ const ( // ("name", "account_id", "region"). // v1 (deprecated): typed Finding included resource_name, // cloud_account_id, and cloud_region as top-level keys. - SnapshotSchemaVersion = "v2" + SnapshotSchemaVersion = "v3" ) // Store handles persisting snapshots to S3 diff --git a/pkg/snapshot/store_s3_test.go b/pkg/snapshot/store_s3_test.go index db2fd4c..b0e6561 100644 --- a/pkg/snapshot/store_s3_test.go +++ b/pkg/snapshot/store_s3_test.go @@ -142,7 +142,7 @@ func fixtureSnapshot(id string) *types.Snapshot { gen := time.Date(2026, 4, 8, 12, 0, 0, 0, time.UTC) return &types.Snapshot{ SnapshotID: id, - Version: "v2", + Version: "v3", GeneratedAt: gen, Summary: types.SnapshotSummary{ TotalResources: 3, @@ -170,7 +170,7 @@ func TestS3Store_SaveSnapshot_WritesBothCanonicalAndLatest(t *testing.T) { latest, ok := fake.objects["snapshots/latest.json"] require.True(t, ok, "latest.json must be written") assert.Equal(t, "snap-001", latest.metadata["snapshot-id"]) - assert.Equal(t, "v2", latest.metadata["schema-version"]) + assert.Equal(t, "v3", latest.metadata["schema-version"]) // Locate the timestamped object. var dateKey string @@ -187,7 +187,7 @@ func TestS3Store_SaveSnapshot_WritesBothCanonicalAndLatest(t *testing.T) { var out types.Snapshot require.NoError(t, json.Unmarshal(latest.body, &out)) assert.Equal(t, "snap-001", out.SnapshotID) - assert.Equal(t, "v2", out.Version) + assert.Equal(t, "v3", out.Version) } func TestS3Store_SaveSnapshot_PutError(t *testing.T) { @@ -208,7 +208,7 @@ func TestS3Store_GetLatestSnapshot_RoundTrip(t *testing.T) { got, err := store.GetLatestSnapshot(context.Background()) require.NoError(t, err) assert.Equal(t, "snap-latest", got.SnapshotID) - assert.Equal(t, "v2", got.Version) + assert.Equal(t, "v3", got.Version) assert.Equal(t, 3, got.Summary.TotalResources) } diff --git a/pkg/store/store.go b/pkg/store/store.go index db29a3b..51cd6e2 100644 --- a/pkg/store/store.go +++ b/pkg/store/store.go @@ -28,7 +28,7 @@ type Store interface { // FindingFilters defines optional filters for querying findings. // // Filterable attributes are limited to the typed Finding surface. -// Account ID and region moved into Finding.Extra in snapshot v2 and +// Account ID and region moved into Finding.Extra in snapshot v2+ and // are no longer filterable through this interface; consumers that // need to slice on those should iterate findings and read Extra // directly. diff --git a/pkg/types/resource.go b/pkg/types/resource.go index c209d41..eafbe4a 100644 --- a/pkg/types/resource.go +++ b/pkg/types/resource.go @@ -100,35 +100,21 @@ type VersionLifecycle struct { // Source indicates where this lifecycle data came from (e.g., "aws-rds-api", "endoflife.date") Source string - // RecommendedVersion is the latest currently-supported cycle for - // this product as reported by the EOL provider. Non-extended - // support is preferred; if no non-extended cycle exists, the - // provider falls back to the newest extended-support cycle so - // the user still gets *some* concrete target. Used by the RED - // path and the YELLOW approaching-EOL path. Empty when the - // provider couldn't determine any supported cycle (404 product, - // every cycle past EOL, etc.). - RecommendedVersion string - - // RecommendedNonExtendedVersion is the latest cycle that is - // supported AND NOT in (paid) extended support. Used by the - // YELLOW extended-support recommendation path, where suggesting - // another extended-support cycle would falsely claim the upgrade - // avoids extended-support costs. Empty when every supported - // cycle for this product is already in extended support — in - // that case the policy layer falls back to a neutral message - // rather than over-promising cost relief. - RecommendedNonExtendedVersion string - // IsEOL indicates if the version is past End-of-Life IsEOL bool // IsDeprecated indicates if the version is deprecated IsDeprecated bool - // IsExtendedSupport indicates if the version is in extended support (typically higher cost) + // IsExtendedSupport indicates if the version is in extended support. IsExtendedSupport bool + // IsDeprecatedSupport indicates if the version is past standard support + // but before true EOL without an extendedSupport field in the upstream + // lifecycle data. Lambda deprecated runtimes use this shape: they are a + // warning state, but not paid extended support. + IsDeprecatedSupport bool + // IsSupported indicates if the version is currently supported IsSupported bool } @@ -139,7 +125,7 @@ type VersionLifecycle struct { // requires (identity, EOL keys, service, classification metadata) are // typed. Optional descriptive attributes — human-readable name, cloud // account, region, and any YAML-defined extras — live in Extra under -// their YAML logical name. Wire-shape is locked by snapshot v2. +// their YAML logical name. Wire-shape is locked by snapshot v3. type Finding struct { // Tags are the resource's key-value metadata (e.g., AWS resource tags) Tags map[string]string `json:",omitempty"` @@ -174,9 +160,6 @@ type Finding struct { // Message is a human-readable description of the issue Message string - // Recommendation is the recommended action to resolve the issue - Recommendation string - // ResourceType is the type of resource ResourceType ResourceType diff --git a/pkg/types/resource_test.go b/pkg/types/resource_test.go index 5284925..40f19d9 100644 --- a/pkg/types/resource_test.go +++ b/pkg/types/resource_test.go @@ -60,13 +60,13 @@ func TestStatBucket_JSONShape(t *testing.T) { assert.Equal(t, float64(60), decoded["compliance_percentage"]) } -// TestSnapshot_JSONShape locks the v2 top-level snapshot wire keys. +// TestSnapshot_JSONShape locks the current top-level snapshot wire keys. // PR #41 stabilized this shape; reordering or renaming any key here is // a breaking wire-format change and should be intentional. func TestSnapshot_JSONShape(t *testing.T) { s := Snapshot{ SnapshotID: "snap-1", - Version: "v2", + Version: "v3", ScanDurationSec: 60, FindingsByType: map[ResourceType][]*Finding{}, Summary: SnapshotSummary{}, diff --git a/pkg/types/snapshot.go b/pkg/types/snapshot.go index 62e8e8c..884d195 100644 --- a/pkg/types/snapshot.go +++ b/pkg/types/snapshot.go @@ -4,11 +4,11 @@ import "time" // Snapshot represents a versioned point-in-time view of all findings // -//nolint:govet // field order is the v2 wire format; reordering would change JSON key order +//nolint:govet // field order is the v3 wire format; reordering would change JSON key order type Snapshot struct { // Metadata SnapshotID string `json:"snapshot_id"` - Version string `json:"version"` // Schema version (e.g., "v1") + Version string `json:"version"` // Schema version (e.g., "v3") GeneratedAt time.Time `json:"generated_at"` ScanStartTime time.Time `json:"scan_start_time"` ScanEndTime time.Time `json:"scan_end_time"` @@ -23,7 +23,7 @@ type Snapshot struct { // SnapshotSummary provides aggregate statistics across all resource types // -//nolint:govet // field order is the v2 wire format; reordering would change JSON key order +//nolint:govet // field order is the v3 wire format; reordering would change JSON key order type SnapshotSummary struct { TotalResources int `json:"total_resources"` RedCount int `json:"red_count"` diff --git a/pkg/types/status.go b/pkg/types/status.go index 16b5b72..1b890a6 100644 --- a/pkg/types/status.go +++ b/pkg/types/status.go @@ -6,7 +6,7 @@ type Status string const ( // StatusRed indicates critical issues: past EOL, deprecated, or extended support expired StatusRed Status = "RED" - // StatusYellow indicates warnings: in extended support (costly) or approaching EOL (< 90 days) + // StatusYellow indicates warnings: in extended/deprecated support or approaching EOL (< 90 days) StatusYellow Status = "YELLOW" // StatusGreen indicates compliant: current supported version StatusGreen Status = "GREEN" diff --git a/pkg/workflow/detection/activities.go b/pkg/workflow/detection/activities.go index 6b4f9b4..02edd96 100644 --- a/pkg/workflow/detection/activities.go +++ b/pkg/workflow/detection/activities.go @@ -249,7 +249,6 @@ 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) - recommendation := a.Policy.GetRecommendation(resource, lifecycle, status) // Create finding. Name, account, and region (when configured) are // part of resource.Extra and propagate through verbatim. @@ -262,7 +261,6 @@ func (a *Activities) DetectDrift(ctx context.Context, input DetectInput) (*Detec Engine: resource.Engine, Status: status, Message: message, - Recommendation: recommendation, EOLDate: lifecycle.EOLDate, Tags: resource.Tags, Extra: resource.Extra, diff --git a/pkg/workflow/orchestrator/activities_test.go b/pkg/workflow/orchestrator/activities_test.go index 5c16008..2cd6534 100644 --- a/pkg/workflow/orchestrator/activities_test.go +++ b/pkg/workflow/orchestrator/activities_test.go @@ -118,7 +118,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, "v2", fakeSnap.saved.Version) + assert.Equal(t, "v3", fakeSnap.saved.Version) assert.Equal(t, int64(60), fakeSnap.saved.ScanDurationSec) assert.Equal(t, 3, fakeSnap.saved.Summary.TotalResources) }