diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 3a52146..982cb76 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -474,11 +474,12 @@ 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)); per-`Finding` fields -have no JSON tags and therefore serialize as PascalCase. The `findings_by_type` +(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 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. +`elasticache-redis`) — the uppercase `ResourceType` constants in `pkg/types/resource.go` +are test fixtures only. ```json { @@ -489,7 +490,7 @@ map is keyed by the resource **config ID** (e.g. `aurora-mysql`, `eks`, "scan_end_time": "2026-04-09T12:34:56Z", "scan_duration_sec": 2096, "findings_by_type": { - "aurora-mysql": [{"ResourceID": "...", "Status": "RED", "Message": "..."}], + "aurora-mysql": [{"ResourceID": "...", "Status": "RED", "Message": "...", "lifecycle_details": {"standard_support_end": "2024-02-29T00:00:00Z", "is_extended_support": true}}], "eks": [{"ResourceID": "...", "Status": "YELLOW", "Message": "..."}] }, "summary": { diff --git a/README.md b/README.md index 38e41af..2655270 100644 --- a/README.md +++ b/README.md @@ -510,10 +510,11 @@ 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)); per-`Finding` fields have no -JSON tags and therefore serialize as PascalCase. 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. +[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 +by the resource config ID (e.g. `aurora-mysql`, `eks`), not by the `ResourceType` +constants used in tests. ```json { @@ -533,6 +534,18 @@ keyed by the resource config ID (e.g. `aurora-mysql`, `eks`), not by the "Engine": "aurora-mysql", "Status": "RED", "Message": "Running deprecated version 5.6.10a (EOL: 2024-02-29)", + "lifecycle_details": { + "standard_support_end": "2024-02-29T00:00:00Z", + "extended_support_end": "2027-02-28T00:00:00Z", + "eol_date": "2027-02-28T00:00:00Z", + "version": "5.7", + "engine": "mysql", + "source": "endoflife-date-api", + "is_supported": true, + "is_deprecated": true, + "is_extended_support": true, + "is_eol": false + }, "DetectedAt": "2026-04-09T12:34:56Z", "UpdatedAt": "2026-04-09T12:34:56Z" } diff --git a/pkg/snapshot/builder_test.go b/pkg/snapshot/builder_test.go index 835dd7c..bb51d6b 100644 --- a/pkg/snapshot/builder_test.go +++ b/pkg/snapshot/builder_test.go @@ -155,6 +155,8 @@ func TestBuilder_JSONWireShape(t *testing.T) { // downstream tools have been told to drop, so the test fails fast on // regressions. 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) snap := NewBuilder(). AddFindings(types.ResourceTypeAurora, []*types.Finding{ { @@ -169,6 +171,17 @@ func TestBuilder_CurrentSchemaBreakWireShape(t *testing.T) { "account_id": "123456789012", "region": "us-east-1", }, + LifecycleDetails: types.LifecycleDetails{ + StandardSupportEnd: &standardSupportEnd, + ExtendedSupportEnd: &extendedSupportEnd, + EOLDate: &extendedSupportEnd, + Version: "13", + Engine: "aurora-postgresql", + Source: "endoflife-date-api", + IsSupported: true, + IsDeprecated: true, + IsExtendedSupport: true, + }, }, }). Build() @@ -205,4 +218,14 @@ func TestBuilder_CurrentSchemaBreakWireShape(t *testing.T) { 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"]) } diff --git a/pkg/snapshot/store.go b/pkg/snapshot/store.go index be47e27..6839c2c 100644 --- a/pkg/snapshot/store.go +++ b/pkg/snapshot/store.go @@ -17,9 +17,10 @@ import ( const ( // SnapshotSchemaVersion is the current schema version for snapshots. // - // v3 (current): removed Finding.Recommendation from the snapshot - // schema; remediation guidance belongs in curated docs, not in - // generated findings. + // v3 (current): 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. // 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, diff --git a/pkg/types/lifecycle_details.go b/pkg/types/lifecycle_details.go new file mode 100644 index 0000000..539c55b --- /dev/null +++ b/pkg/types/lifecycle_details.go @@ -0,0 +1,46 @@ +package types + +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"` +} + +// LifecycleDetailsFromVersionLifecycle converts EOL provider output into +// finding-level lifecycle metadata. +func LifecycleDetailsFromVersionLifecycle(lifecycle *VersionLifecycle) LifecycleDetails { + if lifecycle == nil { + return LifecycleDetails{} + } + standardSupportEnd := lifecycle.DeprecationDate + if standardSupportEnd == nil && lifecycle.ExtendedSupportEnd != nil { + standardSupportEnd = 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, + } +} diff --git a/pkg/types/resource.go b/pkg/types/resource.go index eafbe4a..62b74b3 100644 --- a/pkg/types/resource.go +++ b/pkg/types/resource.go @@ -136,6 +136,9 @@ 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"` + // EOLDate is when the current version reaches End-of-Life EOLDate *time.Time diff --git a/pkg/workflow/detection/activities.go b/pkg/workflow/detection/activities.go index 52a2441..694275a 100644 --- a/pkg/workflow/detection/activities.go +++ b/pkg/workflow/detection/activities.go @@ -254,17 +254,18 @@ func (a *Activities) DetectDrift(ctx context.Context, input DetectInput) (*Detec // Create finding. Name, account, and region (when configured) are // part of resource.Extra and propagate through verbatim. finding := &types.Finding{ - ResourceID: resource.ID, - ResourceType: resource.Type, - Service: resource.Service, - CloudProvider: resource.CloudProvider, - CurrentVersion: resource.CurrentVersion, - Engine: resource.Engine, - Status: status, - Message: message, - EOLDate: lifecycle.EOLDate, - Tags: resource.Tags, - Extra: resource.Extra, + ResourceID: resource.ID, + ResourceType: resource.Type, + Service: resource.Service, + CloudProvider: resource.CloudProvider, + CurrentVersion: resource.CurrentVersion, + Engine: resource.Engine, + Status: status, + Message: message, + EOLDate: lifecycle.EOLDate, + Tags: resource.Tags, + Extra: resource.Extra, + LifecycleDetails: types.LifecycleDetailsFromVersionLifecycle(lifecycle), } findings = append(findings, finding) diff --git a/pkg/workflow/detection/activities_test.go b/pkg/workflow/detection/activities_test.go index 67605c1..bc5d55d 100644 --- a/pkg/workflow/detection/activities_test.go +++ b/pkg/workflow/detection/activities_test.go @@ -2,6 +2,7 @@ package detection import ( "testing" + "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -332,6 +333,61 @@ func TestDetectDrift_PropagatesExtra(t *testing.T) { assert.Equal(t, "engineering-prod", detect.Findings[0].Extra["cost_center"]) } +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) + fetchedAt := time.Date(2026, time.May, 12, 0, 0, 0, 0, time.UTC) + resources := []*types.Resource{ + { + ID: "arn:aws:rds:us-east-1:123:db:mysql-57", + Type: types.ResourceType("rds-mysql"), + CloudProvider: types.CloudProviderAWS, + Engine: "mysql", + CurrentVersion: "5.7.44", + }, + } + lifecycles := map[string]*types.VersionLifecycle{ + "mysql:5.7.44": { + Version: "5.7", + Engine: "mysql", + Source: "endoflife-date-api", + DeprecationDate: &standardSupportEnd, + ExtendedSupportEnd: &extendedSupportEnd, + EOLDate: &extendedSupportEnd, + FetchedAt: fetchedAt, + IsSupported: true, + IsDeprecated: true, + IsExtendedSupport: true, + }, + } + act := newTestActivities(resources, lifecycles) + env := newActivityEnv() + env.RegisterActivity(act.DetectDrift) + + result, err := env.ExecuteActivity(act.DetectDrift, DetectInput{ + Resources: resources, + VersionLifecycles: lifecycles, + }) + require.NoError(t, err) + + var detect DetectResult + require.NoError(t, result.Get(&detect)) + require.Len(t, detect.Findings, 1) + + details := detect.Findings[0].LifecycleDetails + 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) + assert.Equal(t, standardSupportEnd, *details.StandardSupportEnd) + assert.Equal(t, extendedSupportEnd, *details.ExtendedSupportEnd) + assert.Equal(t, extendedSupportEnd, *details.EOLDate) + assert.Equal(t, fetchedAt, details.FetchedAt) + assert.True(t, details.IsExtendedSupport) +} + func TestDetectDrift_UnknownVersion(t *testing.T) { resources := []*types.Resource{ {ID: "r1", Engine: "aurora-mysql", CurrentVersion: "99.0.0", Type: types.ResourceTypeAurora},