From 0bb3d77f7bca9abca2ab905b6738e58e9931f4da Mon Sep 17 00:00:00 2001 From: Youssef <53960652+yti93@users.noreply.github.com> Date: Wed, 5 Aug 2026 13:47:58 -0700 Subject: [PATCH 01/18] docs: design lifecycle unknown attribution Amp-Thread-ID: https://ampcode.com/threads/T-019fd393-8713-704d-bd35-038f82f8dd95 Co-authored-by: Amp --- ...05-lifecycle-unknown-attribution-design.md | 148 ++++++++++++++++++ 1 file changed, 148 insertions(+) create mode 100644 docs/superpowers/specs/2026-08-05-lifecycle-unknown-attribution-design.md diff --git a/docs/superpowers/specs/2026-08-05-lifecycle-unknown-attribution-design.md b/docs/superpowers/specs/2026-08-05-lifecycle-unknown-attribution-design.md new file mode 100644 index 0000000..5e949b2 --- /dev/null +++ b/docs/superpowers/specs/2026-08-05-lifecycle-unknown-attribution-design.md @@ -0,0 +1,148 @@ +# Lifecycle UNKNOWN Attribution and Override Provenance + +## Goal + +Make lifecycle `UNKNOWN` findings actionable without introducing unbounded +Prometheus labels. Operators must be able to distinguish unsupported products, +missing or malformed cycles, source failures, empty inventory versions, and +classification gaps. Local endoflife.date overrides must also identify their +origin and carry machine-readable ownership and review metadata. + +## Scope + +This change covers the endoflife.date client and provider, detection findings, +snapshot drill-down, application metrics, the local nginx override, and local +override metadata validation. It preserves existing graceful scan behavior and +does not add engine, version, product, URL, owner, or error text to metric +labels. + +Overdue override reviews produce warnings only. Invalid manifests, missing +files, malformed lifecycle data, or inconsistent metadata remain validation +errors. + +## Lifecycle attribution model + +Add closed string types to the lifecycle domain: + +- Unknown causes: `product_not_found`, `cycle_not_found`, `source_error`, + `malformed_cycle`, `empty_inventory_version`, `lifecycle_mismatch`, + `indeterminate_lifecycle`, and compatibility fallback `unattributed`. +- Data sources: `endoflife_date`, `local_override`, and `unknown`. + +`VersionLifecycle` gains `UnknownCause` and `DataSource`. The existing `Source` +field remains the provider identity (`endoflife-date-api`) for compatibility. +`LifecycleDetails` gains optional `unknown_cause` and `data_source` fields so +each snapshot finding retains cause, source, engine, and version together. + +Provider attribution takes precedence. For an `UNKNOWN` classification without +a provider cause, detection assigns the cause as follows: + +1. Blank inventory version: `empty_inventory_version`. +2. Blank lifecycle version: `cycle_not_found`. +3. Lifecycle version does not match inventory version: `lifecycle_mismatch`. +4. Matching lifecycle has no RED, YELLOW, or GREEN signal: + `indeterminate_lifecycle`. +5. An old or invalid payload that cannot be classified: `unattributed`. + +Detection annotates a copy of the lifecycle value. Cached provider lifecycle +pointers are never mutated. + +## Client and provider flow + +The endoflife.date client returns product cycles plus bounded response metadata: +data source and fetch timestamp. The direct upstream client defaults to +`endoflife_date`; a custom endpoint defaults to `unknown`. A trusted +`X-Version-Guard-EOL-Source` response header may select `endoflife_date` or +`local_override`; arbitrary values normalize to `unknown`. + +Response metadata is retained on errors. The provider maps outcomes as follows: + +| Outcome | Cause | +| --- | --- | +| Product HTTP 404 | `product_not_found` | +| Successful response without a matching cycle | `cycle_not_found` | +| Transport, non-404 HTTP, or response decode failure | `source_error` | +| Matching cycle fails lifecycle validation/adaptation | `malformed_cycle` | + +Malformed-cycle attribution is precise: the provider tracks rejected cycle +identifiers and emits `malformed_cycle` only when a rejected cycle matches the +requested inventory version. An unrelated malformed cycle does not change a +missing version from `cycle_not_found`. + +Providers may return partial lifecycle diagnostics with a non-nil error. +`FetchEOLData` retains that lifecycle while logging the error. If another +provider returns only an error, the activity creates a bounded `source_error` +lifecycle instead of dropping the lookup entirely. + +## Override source and provenance + +The nginx override adds `X-Version-Guard-EOL-Source: local_override` when a +static override file is served and `endoflife_date` when a request is proxied. +It hides any upstream copy of that header before setting its own value. + +`deploy/endoflife-override/manifest.json` contains one entry per override: + +- `product` +- `path` +- `reason` +- `owner` +- `source_url` +- `reviewed_on` +- `review_due_on` + +The manifest has `schema_version: 1`. Validation requires unique products and +paths, HTTPS source URLs, strict `YYYY-MM-DD` dates, a review due date no more +than 30 calendar days after review, a one-to-one relationship between manifest +entries and `api/*.json`, and valid lifecycle arrays with non-empty cycle IDs. +The UTC due date itself is valid. A date after `review_due_on` emits a warning +but does not fail tests or CI. + +Validation is local and deterministic apart from the injected current date. It +does not call upstream URLs. Review means confirming whether the upstream source +has landed or changed and whether the local JSON remains necessary and accurate. + +## Metrics + +Keep the existing `version_guard_detection_resources` metric unchanged and add: + +- `version_guard_detection_unknown_resources{resource_type,cause}`: latest + count of UNKNOWN findings by closed cause. +- `version_guard_detection_lifecycle_resources{resource_type,source}`: latest + count of findings by closed lifecycle data source. + +All known cause and source series are reset to zero on every resource-type scan +before observed values are recorded, preventing stale gauge values. Empty or +invalid UNKNOWN causes normalize to `unattributed`; empty or invalid sources +normalize to `unknown`. + +The detailed drill-down remains in snapshot findings. No aggregate +engine/version report or metric is added. + +## Compatibility + +Activity names, workflow ordering, and activity input/output types remain +unchanged. New fields travel through the existing lifecycle map and finding EOL +block. They are additive and zero-value compatible with old Temporal payloads, +so no workflow version patch is required. + +The snapshot remains schema `v4`: the optional fields extend the existing `eol` +object and do not alter the top-level contract. Existing `Source` values remain +unchanged. + +## Verification + +Tests cover: + +- Product 404, missing cycle, source error, malformed matching cycle, and blank + inventory version. +- Lifecycle mismatch, indeterminate lifecycle, provider-cause precedence, and + compatibility fallback. +- Upstream, local-override, custom/unknown, and invalid-header source handling. +- Cause/source propagation into findings and snapshot JSON. +- Exact metric labels and counts, including zero-reset behavior. +- Manifest parsing, duplicate or missing entries/files, invalid URLs or dates, + review intervals over 30 days, and overdue warning behavior. +- Nginx configuration contract for local and proxied source headers. + +Focused package tests run during implementation, followed by `make test` and the +repository's relevant format/lint checks before handoff. From aece3297f06822c92190ff8ad08a516f47ede6d2 Mon Sep 17 00:00:00 2001 From: Youssef <53960652+yti93@users.noreply.github.com> Date: Wed, 5 Aug 2026 13:51:39 -0700 Subject: [PATCH 02/18] docs: plan lifecycle unknown attribution Amp-Thread-ID: https://ampcode.com/threads/T-019fd393-8713-704d-bd35-038f82f8dd95 Co-authored-by: Amp --- ...026-08-05-lifecycle-unknown-attribution.md | 946 ++++++++++++++++++ 1 file changed, 946 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-05-lifecycle-unknown-attribution.md diff --git a/docs/superpowers/plans/2026-08-05-lifecycle-unknown-attribution.md b/docs/superpowers/plans/2026-08-05-lifecycle-unknown-attribution.md new file mode 100644 index 0000000..1e137a5 --- /dev/null +++ b/docs/superpowers/plans/2026-08-05-lifecycle-unknown-attribution.md @@ -0,0 +1,946 @@ +# Lifecycle UNKNOWN Attribution Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Attribute every lifecycle `UNKNOWN` finding to a bounded actionable cause, expose safe aggregate metrics and snapshot drill-down, and govern local endoflife.date overrides with source and review metadata. + +**Architecture:** Add closed cause/source values to the existing lifecycle object, enrich the endoflife.date client result with bounded response metadata, and preserve partial diagnostics through the provider and detection activity. Aggregate only cause and source in Prometheus; retain engine/version detail in the existing snapshot finding. Nginx marks local versus proxied responses, while a standalone manifest validator enforces override metadata structure and warns—without failing—when review is overdue. + +**Tech Stack:** Go 1.24, Temporal Go SDK payloads, Prometheus client_golang, nginx, JSON, testify, standard `testing`/`httptest`. + +## Global Constraints + +- Preserve all Temporal workflow/activity names, ordering, and input/output types. +- Keep snapshot schema `v4`; new lifecycle fields are optional additions inside `eol`. +- Keep existing `VersionLifecycle.Source` semantics and value `endoflife-date-api`. +- Never put engine, version, product, URL, owner, or error text in Prometheus labels. +- Normalize arbitrary or absent causes to `unattributed` and data sources to `unknown`. +- Never mutate lifecycle pointers returned from the provider cache. +- An overdue override review emits a warning only; malformed or inconsistent override metadata is an error. +- Use focused package tests while iterating, then repository Makefile targets. + +## File Structure + +- `pkg/types/resource.go`: closed lifecycle cause/source types and lifecycle fields. +- `pkg/types/lifecycle_details.go`: additive snapshot-facing propagation. +- `pkg/policy/default.go`: pure UNKNOWN cause fallback based on classification semantics. +- `pkg/eol/endoflife/client.go`: product response envelope and trusted source-header handling. +- `pkg/eol/endoflife/provider.go`: product/cycle/source failure attribution and precise malformed-cycle tracking. +- `pkg/eol/provider.go`: document partial diagnostic lifecycle returns. +- `pkg/workflow/detection/activities.go`: preserve partial failures, avoid empty-version provider calls, annotate lifecycle copies, and aggregate breakdowns. +- `pkg/telemetry/metrics.go`: bounded cause/source gauges with stale-series reset. +- `deploy/endoflife-override/nginx.conf`: authoritative response-origin header. +- `deploy/endoflife-override/manifest.json`: override ownership, source, and review metadata. +- `deploy/endoflife-override/manifest.go`: deterministic parser and validator. +- `deploy/endoflife-override/*_test.go`: manifest and nginx contract tests. +- Existing package tests: lock client, provider, policy, detection, metrics, and snapshot behavior. + +--- + +### Task 1: Define and propagate the lifecycle attribution contract + +**Files:** +- Modify: `pkg/types/resource.go` +- Modify: `pkg/types/lifecycle_details.go` +- Modify: `pkg/types/resource_test.go` +- Modify: `pkg/policy/default.go` +- Modify: `pkg/policy/default_test.go` + +**Interfaces:** +- Produces: `types.LifecycleUnknownCause`, `types.LifecycleDataSource`, `types.KnownLifecycleUnknownCauses()`, `types.KnownLifecycleDataSources()`, and `policy.UnknownCause(resource, lifecycle, status)`. +- Consumed by: endoflife.date client/provider, detection, telemetry, and snapshot tasks. + +- [ ] **Step 1: Write failing lifecycle propagation tests** + +Add tests that create a `VersionLifecycle` with provider source, data source, and +cause, then assert `LifecycleDetailsFromVersionLifecycle` preserves all three: + +```go +func TestLifecycleDetailsFromVersionLifecycle_PropagatesAttribution(t *testing.T) { + lifecycle := &VersionLifecycle{ + Source: "endoflife-date-api", + DataSource: LifecycleDataSourceLocalOverride, + UnknownCause: LifecycleUnknownCauseCycleNotFound, + } + + details := LifecycleDetailsFromVersionLifecycle(lifecycle) + + assert.Equal(t, "endoflife-date-api", details.Source) + assert.Equal(t, LifecycleDataSourceLocalOverride, details.DataSource) + assert.Equal(t, LifecycleUnknownCauseCycleNotFound, details.UnknownCause) +} +``` + +Add table tests asserting the known-value functions return every enum exactly +once and in stable order. + +- [ ] **Step 2: Run the type tests and verify red state** + +Run: `go test ./pkg/types -run 'TestLifecycleDetailsFromVersionLifecycle_PropagatesAttribution|TestKnownLifecycle' -count=1` + +Expected: compile failure because attribution types and fields do not exist. + +- [ ] **Step 3: Implement the closed domain values and propagation** + +Add these definitions to `pkg/types/resource.go`: + +```go +type LifecycleUnknownCause string + +const ( + LifecycleUnknownCauseProductNotFound LifecycleUnknownCause = "product_not_found" + LifecycleUnknownCauseCycleNotFound LifecycleUnknownCause = "cycle_not_found" + LifecycleUnknownCauseSourceError LifecycleUnknownCause = "source_error" + LifecycleUnknownCauseMalformedCycle LifecycleUnknownCause = "malformed_cycle" + LifecycleUnknownCauseEmptyInventoryVersion LifecycleUnknownCause = "empty_inventory_version" + LifecycleUnknownCauseLifecycleMismatch LifecycleUnknownCause = "lifecycle_mismatch" + LifecycleUnknownCauseIndeterminate LifecycleUnknownCause = "indeterminate_lifecycle" + LifecycleUnknownCauseUnattributed LifecycleUnknownCause = "unattributed" +) + +type LifecycleDataSource string + +const ( + LifecycleDataSourceEndOfLifeDate LifecycleDataSource = "endoflife_date" + LifecycleDataSourceLocalOverride LifecycleDataSource = "local_override" + LifecycleDataSourceUnknown LifecycleDataSource = "unknown" +) + +func KnownLifecycleUnknownCauses() []LifecycleUnknownCause { + return []LifecycleUnknownCause{ + LifecycleUnknownCauseProductNotFound, + LifecycleUnknownCauseCycleNotFound, + LifecycleUnknownCauseSourceError, + LifecycleUnknownCauseMalformedCycle, + LifecycleUnknownCauseEmptyInventoryVersion, + LifecycleUnknownCauseLifecycleMismatch, + LifecycleUnknownCauseIndeterminate, + LifecycleUnknownCauseUnattributed, + } +} + +func KnownLifecycleDataSources() []LifecycleDataSource { + return []LifecycleDataSource{ + LifecycleDataSourceEndOfLifeDate, + LifecycleDataSourceLocalOverride, + LifecycleDataSourceUnknown, + } +} +``` + +Add `DataSource` and `UnknownCause` to `VersionLifecycle`. Add these fields to +`LifecycleDetails` and copy them in `LifecycleDetailsFromVersionLifecycle`: + +```go +DataSource LifecycleDataSource `json:"data_source,omitempty"` +UnknownCause LifecycleUnknownCause `json:"unknown_cause,omitempty"` +``` + +- [ ] **Step 4: Write failing policy attribution tests** + +Add a table-driven test covering provider-cause precedence, blank inventory +version, empty lifecycle version, mismatch, indeterminate lifecycle, +non-UNKNOWN status, and nil lifecycle: + +```go +func TestUnknownCause(t *testing.T) { + tests := []struct { + name string + resource *types.Resource + lifecycle *types.VersionLifecycle + status types.Status + want types.LifecycleUnknownCause + }{ + { + name: "provider cause wins", + resource: &types.Resource{CurrentVersion: "8.0.35"}, + lifecycle: &types.VersionLifecycle{UnknownCause: types.LifecycleUnknownCauseProductNotFound}, + status: types.StatusUnknown, + want: types.LifecycleUnknownCauseProductNotFound, + }, + { + name: "empty inventory version", + resource: &types.Resource{CurrentVersion: " "}, + lifecycle: &types.VersionLifecycle{}, + status: types.StatusUnknown, + want: types.LifecycleUnknownCauseEmptyInventoryVersion, + }, + { + name: "cycle absent", + resource: &types.Resource{CurrentVersion: "8.0.35"}, + lifecycle: &types.VersionLifecycle{}, + status: types.StatusUnknown, + want: types.LifecycleUnknownCauseCycleNotFound, + }, + { + name: "lifecycle mismatch", + resource: &types.Resource{CurrentVersion: "8.0.35"}, + lifecycle: &types.VersionLifecycle{Version: "5.7"}, + status: types.StatusUnknown, + want: types.LifecycleUnknownCauseLifecycleMismatch, + }, + { + name: "indeterminate lifecycle", + resource: &types.Resource{CurrentVersion: "8.0.35"}, + lifecycle: &types.VersionLifecycle{Version: "8.0"}, + status: types.StatusUnknown, + want: types.LifecycleUnknownCauseIndeterminate, + }, + { + name: "known status has no cause", + resource: &types.Resource{CurrentVersion: "8.0.35"}, + lifecycle: &types.VersionLifecycle{Version: "8.0", IsSupported: true}, + status: types.StatusGreen, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, UnknownCause(tt.resource, tt.lifecycle, tt.status)) + }) + } +} +``` + +Nil lifecycle with UNKNOWN must return `unattributed`, not panic. + +- [ ] **Step 5: Run the policy test and verify red state** + +Run: `go test ./pkg/policy -run TestUnknownCause -count=1` + +Expected: compile failure because `UnknownCause` does not exist. + +- [ ] **Step 6: Implement the pure policy helper** + +Add to `pkg/policy/default.go`, reusing the package-private `versionMatches`: + +```go +func UnknownCause( + resource *types.Resource, + lifecycle *types.VersionLifecycle, + status types.Status, +) types.LifecycleUnknownCause { + if status != types.StatusUnknown { + return "" + } + if lifecycle != nil && lifecycle.UnknownCause != "" { + return lifecycle.UnknownCause + } + if resource == nil || lifecycle == nil { + return types.LifecycleUnknownCauseUnattributed + } + if strings.TrimSpace(resource.CurrentVersion) == "" { + return types.LifecycleUnknownCauseEmptyInventoryVersion + } + if strings.TrimSpace(lifecycle.Version) == "" { + return types.LifecycleUnknownCauseCycleNotFound + } + if !versionMatches(lifecycle.Version, resource.CurrentVersion) { + return types.LifecycleUnknownCauseLifecycleMismatch + } + return types.LifecycleUnknownCauseIndeterminate +} +``` + +- [ ] **Step 7: Run focused tests and commit** + +Run: `go test ./pkg/types ./pkg/policy -count=1` + +Expected: PASS. + +```bash +git add pkg/types/resource.go pkg/types/lifecycle_details.go pkg/types/resource_test.go pkg/policy/default.go pkg/policy/default_test.go +git commit -m "feat: define lifecycle unknown attribution" +``` + +--- + +### Task 2: Enrich endoflife.date client responses with bounded source metadata + +**Files:** +- Modify: `pkg/eol/endoflife/client.go` +- Modify: `pkg/eol/endoflife/client_test.go` +- Modify: `pkg/eol/endoflife/mock_client.go` +- Modify: provider tests and fixtures that implement `Client` + +**Interfaces:** +- Consumes: `types.LifecycleDataSource` from Task 1. +- Produces: `ProductCyclesResult{Cycles, DataSource, FetchedAt}` and `EOLSourceHeader`. +- Consumed by: `Provider.ListAllVersions` and provider diagnostic attribution in Task 3. + +- [ ] **Step 1: Write failing client source tests** + +Extend client tests with these cases: + +```go +func TestRealHTTPClient_ProductCyclesResultSource(t *testing.T) { + tests := []struct { + name string + baseURL func(string) string + header string + wantSource types.LifecycleDataSource + }{ + { + name: "custom endpoint with local override header", + baseURL: func(serverURL string) string { return serverURL }, + header: "local_override", + wantSource: types.LifecycleDataSourceLocalOverride, + }, + { + name: "custom endpoint without header", + baseURL: func(serverURL string) string { return serverURL }, + wantSource: types.LifecycleDataSourceUnknown, + }, + { + name: "invalid source header", + baseURL: func(serverURL string) string { return serverURL }, + header: "attacker-controlled-value", + wantSource: types.LifecycleDataSourceUnknown, + }, + } + + // Each server returns [] with the optional source header. Assert the + // result source and that FetchedAt is non-zero. +} +``` + +Update the typed 404 test to assert that result metadata remains available with +the error. Add a direct-constructor unit test against a rewritten test transport +so `NewRealHTTPClient` defaults to `endoflife_date` without making a network +request. + +- [ ] **Step 2: Run client tests and verify red state** + +Run: `go test ./pkg/eol/endoflife -run 'TestRealHTTPClient_(ProductCyclesResultSource|404ReturnsTypedError)' -count=1` + +Expected: compile failures because `GetProductCycles` still returns a slice. + +- [ ] **Step 3: Implement the result envelope and trusted header parser** + +Change the client contract: + +```go +const EOLSourceHeader = "X-Version-Guard-EOL-Source" + +type ProductCyclesResult struct { + Cycles []*ProductCycle + FetchedAt time.Time + DataSource types.LifecycleDataSource +} + +type Client interface { + GetProductCycles(ctx context.Context, product string) (ProductCyclesResult, error) +} +``` + +Add a `defaultDataSource` field to `RealHTTPClient`. The default constructor uses +`endoflife_date`; `NewRealHTTPClientWithConfig` uses `unknown` unless `baseURL` +is empty and falls back to the direct upstream URL. + +Normalize only known header values: + +```go +func lifecycleDataSource(value string, fallback types.LifecycleDataSource) types.LifecycleDataSource { + switch types.LifecycleDataSource(strings.ToLower(strings.TrimSpace(value))) { + case types.LifecycleDataSourceEndOfLifeDate: + return types.LifecycleDataSourceEndOfLifeDate + case types.LifecycleDataSourceLocalOverride: + return types.LifecycleDataSourceLocalOverride + default: + return fallback + } +} +``` + +Create the result before performing the request. Once a response exists, update +its source from the header. Return the result alongside every error, including +404, body read, status, and decode errors. + +- [ ] **Step 4: Update mocks and call sites to the new interface** + +Change `MockClient.GetProductCyclesFunc` and all inline test clients from: + +```go +func(context.Context, string) ([]*ProductCycle, error) +``` + +to: + +```go +func(context.Context, string) (ProductCyclesResult, error) +``` + +Successful fixtures return: + +```go +return ProductCyclesResult{ + Cycles: cycles, + DataSource: types.LifecycleDataSourceEndOfLifeDate, + FetchedAt: time.Now(), +}, nil +``` + +Do not alter provider semantics in this task beyond compiling against +`result.Cycles`; Task 3 adds attribution. + +- [ ] **Step 5: Run focused tests and commit** + +Run: `go test ./pkg/eol/endoflife -count=1` + +Expected: PASS with existing provider behavior preserved. + +```bash +git add pkg/eol/endoflife/client.go pkg/eol/endoflife/client_test.go pkg/eol/endoflife/mock_client.go pkg/eol/endoflife/*_test.go +git commit -m "feat: report lifecycle response source" +``` + +--- + +### Task 3: Attribute provider outcomes precisely + +**Files:** +- Modify: `pkg/eol/provider.go` +- Modify: `pkg/eol/endoflife/provider.go` +- Modify: `pkg/eol/endoflife/provider_test.go` +- Modify: `pkg/eol/endoflife/provider_404_test.go` + +**Interfaces:** +- Consumes: `ProductCyclesResult`, lifecycle cause/source types. +- Produces: provider lifecycles carrying `product_not_found`, `cycle_not_found`, `malformed_cycle`, or `source_error`, including partial diagnostic lifecycle plus error. +- Consumed by: detection activity in Task 4. + +- [ ] **Step 1: Write failing provider attribution tests** + +Add or extend tests for: + +```go +func TestProvider_GetVersionLifecycle_Product404(t *testing.T) { + // Mock returns a local_override result plus wrapped ErrProductNotFound. + // Assert no final error, empty Version, product_not_found, local_override, + // provider Source, and preserved FetchedAt. +} + +func TestProvider_VersionNotFound(t *testing.T) { + // Successful cycles do not match 99.99. + // Assert cycle_not_found and endoflife_date. +} + +func TestProvider_SourceErrorReturnsDiagnosticLifecycle(t *testing.T) { + // Mock returns metadata plus a 500 error. + // Assert lifecycle is non-nil, cause is source_error, source is preserved, + // and error remains non-nil. +} + +func TestProvider_MalformedMatchingCycle(t *testing.T) { + // A cycle "8.0" has eol: "not-a-date" and requested version is 8.0.35. + // Assert malformed_cycle. Add a second malformed unrelated cycle and + // request 9.0; assert cycle_not_found. +} +``` + +Add a valid-cycle-wins case where malformed `8` and valid `8.0` can both prefix +match `8.0.35`; expect the valid lifecycle. + +- [ ] **Step 2: Run provider tests and verify red state** + +Run: `go test ./pkg/eol/endoflife -run 'TestProvider_(GetVersionLifecycle_Product404|VersionNotFound|SourceErrorReturnsDiagnosticLifecycle|MalformedMatchingCycle)' -count=1` + +Expected: assertion failures because causes/source diagnostics are absent. + +- [ ] **Step 3: Extend cached product metadata** + +Change the cache entry to retain source/fetch/cause and malformed cycle IDs: + +```go +type cachedVersions struct { + versions []*types.VersionLifecycle + malformedCycles []string + fetchedAt time.Time + dataSource types.LifecycleDataSource + productCause types.LifecycleUnknownCause +} +``` + +Return a copy of the matching valid lifecycle with response metadata applied. +For an absent match, inspect `malformedCycles` with the same exact/prefix match +rules used for valid cycles and return a new diagnostic lifecycle. + +- [ ] **Step 4: Add narrow ProductCycle validation** + +Before adapter conversion, reject nil cycles, blank cycle IDs, and invalid +date-or-boolean strings for `support`, `eol`, `extendedSupport`, and `lts`: + +```go +func validateProductCycle(cycle *ProductCycle) error { + if cycle == nil { + return errors.New("cycle is nil") + } + if strings.TrimSpace(cycle.Cycle) == "" { + return errors.New("cycle identifier is empty") + } + for name, value := range map[string]any{ + "support": cycle.Support, + "eol": cycle.EOL, + "extendedSupport": cycle.ExtendedSupport, + "lts": cycle.LTS, + } { + if err := validateDateOrBoolean(value); err != nil { + return errors.Wrapf(err, "%s for cycle %q", name, cycle.Cycle) + } + } + return nil +} +``` + +`validateDateOrBoolean` accepts nil, booleans, empty strings, `"true"`, +`"false"`, and strict `YYYY-MM-DD`; it rejects all other types and strings. +Record a rejected non-empty cycle ID instead of adding it to valid versions. + +- [ ] **Step 5: Preserve partial source failures and product 404 metadata** + +For 404, cache an empty entry with `product_not_found` and return it as graceful +UNKNOWN. For non-404 errors, return a lifecycle and the wrapped error: + +```go +return &types.VersionLifecycle{ + Engine: engine, + Source: p.Name(), + DataSource: result.DataSource, + FetchedAt: result.FetchedAt, + UnknownCause: types.LifecycleUnknownCauseSourceError, +}, errors.Wrapf(err, "failed to fetch cycles for product %s", product) +``` + +Update the `eol.Provider` interface comment to state that implementations may +return a non-nil diagnostic lifecycle with a non-nil error and callers should +preserve it. + +- [ ] **Step 6: Run focused tests and commit** + +Run: `go test ./pkg/eol/... -count=1` + +Expected: PASS. + +```bash +git add pkg/eol/provider.go pkg/eol/endoflife/provider.go pkg/eol/endoflife/provider_test.go pkg/eol/endoflife/provider_404_test.go +git commit -m "feat: attribute lifecycle provider failures" +``` + +--- + +### Task 4: Preserve diagnostics in findings and expose bounded metrics + +**Files:** +- Modify: `pkg/workflow/detection/activities.go` +- Modify: `pkg/workflow/detection/activities_test.go` +- Modify: `pkg/telemetry/metrics.go` +- Modify: `pkg/telemetry/metrics_test.go` +- Modify: `pkg/snapshot/builder_test.go` + +**Interfaces:** +- Consumes: provider diagnostic lifecycle, `policy.UnknownCause`, known cause/source lists. +- Produces: finding `eol.unknown_cause`/`eol.data_source`, `RecordDetectionBreakdown(resourceType, unknownCounts, sourceCounts)`, and two bounded gauge families. + +- [ ] **Step 1: Write failing detection tests** + +Add a counting provider test double and cover: + +```go +func TestFetchEOLData_EmptyVersionDoesNotCallProvider(t *testing.T) { + // Resource CurrentVersion is whitespace. + // Assert provider call count is zero and lifecycle cause is + // empty_inventory_version with data_source unknown. +} + +func TestFetchEOLData_PreservesDiagnosticLifecycleOnError(t *testing.T) { + // Provider returns a source_error lifecycle and error. + // Assert the lifecycle remains in VersionLifecycles. +} + +func TestDetectDrift_AnnotatesLifecycleCopy(t *testing.T) { + // Pass a mismatch lifecycle pointer. + // Assert finding cause is lifecycle_mismatch and original pointer cause + // remains empty. +} +``` + +Extend lifecycle detail propagation assertions to include source and cause. + +- [ ] **Step 2: Run detection tests and verify red state** + +Run: `go test ./pkg/workflow/detection -run 'Test(FetchEOLData|DetectDrift).*' -count=1` + +Expected: new attribution assertions fail. + +- [ ] **Step 3: Implement detection preservation and copy annotation** + +In `FetchEOLData`, synthesize empty-version diagnostics before calling the +provider. On provider errors, retain a non-nil lifecycle; if nil, create: + +```go +lifecycle = &types.VersionLifecycle{ + Engine: resource.Engine, + Source: provider.Name(), + DataSource: types.LifecycleDataSourceUnknown, + UnknownCause: types.LifecycleUnknownCauseSourceError, +} +``` + +In `DetectDrift`, copy before annotating: + +```go +annotated := *lifecycle +status := a.Policy.Classify(resource, &annotated) +annotated.UnknownCause = policy.UnknownCause(resource, &annotated, status) +lifecycleDetails := types.LifecycleDetailsFromVersionLifecycle(&annotated) +``` + +For known statuses, the helper clears the cause. + +- [ ] **Step 4: Write failing metric tests** + +Add exact OpenMetrics expectations: + +```go +func TestRecordDetectionBreakdown(t *testing.T) { + ResetForTest() + RecordDetectionBreakdown( + "aurora-mysql", + map[types.LifecycleUnknownCause]int{ + types.LifecycleUnknownCauseCycleNotFound: 2, + }, + map[types.LifecycleDataSource]int{ + types.LifecycleDataSourceLocalOverride: 3, + }, + ) + + // CollectAndCompare asserts only resource_type,cause on the first gauge + // and resource_type,source on the second. Every known value exists; all + // unobserved values are zero. +} +``` + +Add a second-call test that records a nonzero cause, then records empty maps and +asserts the former series is zero. Add invalid/empty inputs and assert they roll +into `unattributed`/`unknown`. + +- [ ] **Step 5: Run telemetry tests and verify red state** + +Run: `go test ./pkg/telemetry -run TestRecordDetectionBreakdown -count=1` + +Expected: compile failure because metrics and recorder do not exist. + +- [ ] **Step 6: Implement bounded gauges and normalization** + +Add and register: + +```go +detectionUnknownResources = prometheus.NewGaugeVec(prometheus.GaugeOpts{ + Name: "version_guard_detection_unknown_resources", + Help: "Latest Version Guard UNKNOWN resource counts by resource type and cause.", +}, []string{"resource_type", "cause"}) + +detectionLifecycleResources = prometheus.NewGaugeVec(prometheus.GaugeOpts{ + Name: "version_guard_detection_lifecycle_resources", + Help: "Latest Version Guard detection resource counts by resource type and lifecycle data source.", +}, []string{"resource_type", "source"}) +``` + +`RecordDetectionBreakdown` first sets every known cause/source to zero, then +records normalized observed values. Add both vectors to `Register` and +`ResetForTest`. + +- [ ] **Step 7: Derive breakdowns from findings** + +In `EmitMetrics`, build maps while calculating the existing summary: + +```go +unknownCounts := make(map[types.LifecycleUnknownCause]int) +sourceCounts := make(map[types.LifecycleDataSource]int) +for _, finding := range findings { + sourceCounts[finding.EOL.DataSource]++ + if finding.Status == types.StatusUnknown { + unknownCounts[finding.EOL.UnknownCause]++ + } +} +telemetry.RecordDetectionSummary(input.ResourceType, summary) +telemetry.RecordDetectionBreakdown(input.ResourceType, unknownCounts, sourceCounts) +``` + +Normalization stays inside telemetry so all callers receive cardinality safety. + +- [ ] **Step 8: Lock the additive snapshot contract** + +Extend `TestBuilder_CurrentSchemaBreakWireShape` with an UNKNOWN finding and: + +```go +assert.Equal(t, "cycle_not_found", eol["unknown_cause"]) +assert.Equal(t, "local_override", eol["data_source"]) +assert.Equal(t, "aurora-postgresql", eol["engine"]) +``` + +Keep the expected snapshot version at `v4`. + +- [ ] **Step 9: Run focused tests and commit** + +Run: `go test ./pkg/workflow/detection ./pkg/telemetry ./pkg/snapshot -count=1` + +Expected: PASS. + +```bash +git add pkg/workflow/detection/activities.go pkg/workflow/detection/activities_test.go pkg/telemetry/metrics.go pkg/telemetry/metrics_test.go pkg/snapshot/builder_test.go +git commit -m "feat: expose lifecycle attribution metrics" +``` + +--- + +### Task 5: Add override source headers and manifest governance + +**Files:** +- Modify: `deploy/endoflife-override/nginx.conf` +- Modify: `deploy/endoflife-override/README.md` +- Create: `deploy/endoflife-override/manifest.json` +- Create: `deploy/endoflife-override/manifest.go` +- Create: `deploy/endoflife-override/manifest_test.go` +- Create: `deploy/endoflife-override/nginx_test.go` + +**Interfaces:** +- Consumes: `EOLSourceHeader` values `local_override` and `endoflife_date`. +- Produces: validated manifest schema version 1 and authoritative nginx source headers. + +- [ ] **Step 1: Write failing manifest validation tests** + +Implement tests around an internal function with injected filesystem root, UTC +date, and warning writer: + +```go +func validateManifest(root string, now time.Time, warnings io.Writer) error +``` + +Table cases must cover: + +```go +tests := []struct { + name string + mutate func(root string) + wantErr string + wantWarning string +}{ + {name: "valid manifest"}, + {name: "duplicate product", mutate: duplicateProduct, wantErr: "duplicate product"}, + {name: "missing API file entry", mutate: addUnlistedAPIFile, wantErr: "has no manifest entry"}, + {name: "entry references missing file", mutate: removeReferencedFile, wantErr: "does not exist"}, + {name: "invalid source URL", mutate: useHTTPSourceURL, wantErr: "must use https"}, + {name: "invalid review date", mutate: useInvalidDate, wantErr: "YYYY-MM-DD"}, + {name: "review interval over 30 days", mutate: extendReviewInterval, wantErr: "exceeds 30 days"}, + {name: "overdue review warns", mutate: makeOverdue, wantWarning: "review overdue"}, +} +``` + +The overdue case must assert `NoError` and warning output. Use `t.TempDir()` and +copy fixture files; never mutate repository fixtures during tests. + +Add a repository-fixture test so normal `go test ./...` validates the checked-in +manifest on every CI run while preserving warn-only expiry behavior: + +```go +func TestRepositoryManifest(t *testing.T) { + var warnings bytes.Buffer + require.NoError(t, validateManifest(".", time.Now().UTC(), &warnings)) + if warnings.Len() > 0 { + t.Log(strings.TrimSpace(warnings.String())) + } +} +``` + +- [ ] **Step 2: Run manifest tests and verify red state** + +Run: `go test ./deploy/endoflife-override -run TestValidateManifest -count=1` + +Expected: compile failure because validator does not exist. + +- [ ] **Step 3: Add the schema-versioned manifest** + +Create `manifest.json`: + +```json +{ + "schema_version": 1, + "overrides": [ + { + "product": "amazon-aurora-mysql", + "path": "api/amazon-aurora-mysql.json", + "reason": "Product pending upstream inclusion", + "owner": "@block/block-platform", + "source_url": "https://github.com/endoflife-date/endoflife.date/pull/9534", + "reviewed_on": "2026-08-05", + "review_due_on": "2026-09-04" + }, + { + "product": "amazon-opensearch", + "path": "api/amazon-opensearch.json", + "reason": "Required cycles are missing upstream", + "owner": "@block/block-platform", + "source_url": "https://github.com/endoflife-date/endoflife.date/pull/9919", + "reviewed_on": "2026-08-05", + "review_due_on": "2026-09-04" + } + ] +} +``` + +- [ ] **Step 4: Implement deterministic validation** + +Define private manifest structs and validate: + +1. `schema_version == 1`. +2. Required strings are non-empty. +3. Products and paths are unique. +4. `source_url` parses and uses HTTPS. +5. Dates use strict `2006-01-02` and due date is not before review date or more + than 30 days after it. +6. Every manifest path exists under root and stays under `api/`. +7. Every `api/*.json` has exactly one manifest entry and vice versa. +8. Each API file is a top-level `[]ProductCycle`; every cycle passes the same + exported or package-shared lifecycle validation used by the provider. +9. `now.UTC()` after the due date writes a warning and does not return an error. + +Avoid copying lifecycle validation logic: expose the smallest reusable +`endoflife.ValidateProductCycle` function from Task 3 and call it here. + +- [ ] **Step 5: Write and pass nginx contract tests** + +The test reads `nginx.conf` and asserts the local location and named upstream +location each own one trusted header statement, and the upstream location hides +incoming copies: + +```go +assert.Contains(t, config, "add_header X-Version-Guard-EOL-Source local_override always;") +assert.Contains(t, config, "proxy_hide_header X-Version-Guard-EOL-Source;") +assert.Contains(t, config, "add_header X-Version-Guard-EOL-Source endoflife_date always;") +``` + +Then update nginx: + +```nginx +location /api/ { + root /data; + try_files $uri @upstream; + add_header X-Version-Guard-EOL-Source local_override always; +} + +location @upstream { + proxy_pass https://endoflife.date; + proxy_set_header Host endoflife.date; + proxy_set_header User-Agent "version-guard/1.0"; + proxy_ssl_server_name on; + proxy_hide_header X-Version-Guard-EOL-Source; + add_header X-Version-Guard-EOL-Source endoflife_date always; +} +``` + +- [ ] **Step 6: Update override operating documentation** + +Replace the hand-maintained override table with instructions to update +`manifest.json` whenever adding, reviewing, or removing an override. State: + +- review interval is at most 30 days; +- due-date expiration warns but does not fail CI; +- malformed/missing metadata still fails validation; +- source URL and owner are required; +- nginx source headers flow into snapshot findings and source metrics. + +- [ ] **Step 7: Run focused tests and commit** + +Run: `go test ./deploy/endoflife-override ./pkg/eol/endoflife -count=1` + +Expected: PASS. The real manifest may print no warning because its due date is +in the future on 2026-08-05. + +```bash +git add deploy/endoflife-override pkg/eol/endoflife/provider.go +git commit -m "feat: govern local lifecycle overrides" +``` + +--- + +### Task 6: Integrate documentation and run repository verification + +**Files:** +- Modify: `README.md` +- Modify: `ARCHITECTURE.md` +- Modify: `USAGE.md` +- Modify: relevant files from Tasks 1–5 only if verification finds defects + +**Interfaces:** +- Consumes: final metric names, lifecycle fields, cause/source values, and manifest workflow. +- Produces: user-facing operating contract aligned with implemented behavior. + +- [ ] **Step 1: Update metric and UNKNOWN documentation** + +Document both new metrics with their exact labels. Replace claims that UNKNOWN +means only “version not found” with the bounded cause list. Explain that +snapshots contain `eol.unknown_cause`, `eol.data_source`, engine, and version for +drill-down while Prometheus does not label engine/version. + +- [ ] **Step 2: Update override and architecture documentation** + +Document that direct upstream responses resolve to `endoflife_date`, nginx local +files to `local_override`, and untrusted/custom endpoints without the header to +`unknown`. Link to the override manifest and validation policy. + +- [ ] **Step 3: Run formatting** + +Run: `make fmt-all` + +Expected: exit 0. Review `git diff` and ensure formatting did not alter unrelated +files. + +- [ ] **Step 4: Run targeted race-sensitive packages** + +Run: `go test -race ./pkg/eol/endoflife ./pkg/workflow/detection ./pkg/telemetry ./deploy/endoflife-override -count=1` + +Expected: PASS with no race reports. + +- [ ] **Step 5: Run repository test suite** + +Run: `make test` + +Expected: all packages PASS. + +- [ ] **Step 6: Run repository lint/check target** + +Run: `make check` + +Expected: build, tests, formatting checks, and lint pass. If chart files are +unchanged, chart-testing is not required. + +- [ ] **Step 7: Inspect the final diff for compatibility and scope** + +Run: + +```bash +git diff --check origin/main...HEAD +git diff --stat origin/main...HEAD +git status --short +``` + +Expected: no whitespace errors; changes are limited to the design/plan, +lifecycle attribution, metrics, override governance, tests, and aligned docs. + +- [ ] **Step 8: Commit final documentation or verification fixes** + +```bash +git add README.md ARCHITECTURE.md USAGE.md +git commit -m "docs: explain lifecycle unknown diagnostics" +``` + +If verification required code fixes, include only those related files and use a +message describing the corrected behavior. + +- [ ] **Step 9: Perform one independent full-diff review before PR creation** + +Review `git diff origin/main...HEAD` for cause precedence, stale metrics, cache +mutation, arbitrary label values, Temporal payload compatibility, manifest +warning semantics, and unrelated edits. Apply and verify any concrete fixes in +one final commit before pushing. From 9791414e971ac7301293b6c071770a93a37fc64d Mon Sep 17 00:00:00 2001 From: Youssef <53960652+yti93@users.noreply.github.com> Date: Wed, 5 Aug 2026 13:52:51 -0700 Subject: [PATCH 03/18] docs: share lifecycle cycle validation Amp-Thread-ID: https://ampcode.com/threads/T-019fd393-8713-704d-bd35-038f82f8dd95 Co-authored-by: Amp --- .../plans/2026-08-05-lifecycle-unknown-attribution.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/superpowers/plans/2026-08-05-lifecycle-unknown-attribution.md b/docs/superpowers/plans/2026-08-05-lifecycle-unknown-attribution.md index 1e137a5..95724d4 100644 --- a/docs/superpowers/plans/2026-08-05-lifecycle-unknown-attribution.md +++ b/docs/superpowers/plans/2026-08-05-lifecycle-unknown-attribution.md @@ -471,7 +471,7 @@ Before adapter conversion, reject nil cycles, blank cycle IDs, and invalid date-or-boolean strings for `support`, `eol`, `extendedSupport`, and `lts`: ```go -func validateProductCycle(cycle *ProductCycle) error { +func ValidateProductCycle(cycle *ProductCycle) error { if cycle == nil { return errors.New("cycle is nil") } @@ -492,7 +492,9 @@ func validateProductCycle(cycle *ProductCycle) error { } ``` -`validateDateOrBoolean` accepts nil, booleans, empty strings, `"true"`, +`ValidateProductCycle` is exported so the override manifest validator can reuse +the runtime contract without duplicating it. `validateDateOrBoolean` accepts +nil, booleans, empty strings, `"true"`, `"false"`, and strict `YYYY-MM-DD`; it rejects all other types and strings. Record a rejected non-empty cycle ID instead of adding it to valid versions. From 13b6c2317bddb3400967faec9a87e12d0e9cb3cf Mon Sep 17 00:00:00 2001 From: Youssef <53960652+yti93@users.noreply.github.com> Date: Wed, 5 Aug 2026 13:54:43 -0700 Subject: [PATCH 04/18] feat: define lifecycle unknown attribution Amp-Thread-ID: https://ampcode.com/threads/T-019fd393-8713-704d-bd35-038f82f8dd95 Co-authored-by: Amp --- pkg/policy/default.go | 26 +++++++++++++ pkg/policy/default_test.go | 66 +++++++++++++++++++++++++++++++ pkg/types/lifecycle_details.go | 36 +++++++++-------- pkg/types/resource.go | 46 ++++++++++++++++++++++ pkg/types/resource_test.go | 71 ++++++++++++++++++++++++++++++++++ 5 files changed, 229 insertions(+), 16 deletions(-) diff --git a/pkg/policy/default.go b/pkg/policy/default.go index df9f731..63b35d2 100644 --- a/pkg/policy/default.go +++ b/pkg/policy/default.go @@ -64,6 +64,32 @@ func (p *DefaultPolicy) Classify(resource *types.Resource, lifecycle *types.Vers return types.StatusUnknown } +func UnknownCause( + resource *types.Resource, + lifecycle *types.VersionLifecycle, + status types.Status, +) types.LifecycleUnknownCause { + if status != types.StatusUnknown { + return "" + } + if lifecycle != nil && lifecycle.UnknownCause != "" { + return lifecycle.UnknownCause + } + if resource == nil || lifecycle == nil { + return types.LifecycleUnknownCauseUnattributed + } + if strings.TrimSpace(resource.CurrentVersion) == "" { + return types.LifecycleUnknownCauseEmptyInventoryVersion + } + if strings.TrimSpace(lifecycle.Version) == "" { + return types.LifecycleUnknownCauseCycleNotFound + } + if !versionMatches(lifecycle.Version, resource.CurrentVersion) { + return types.LifecycleUnknownCauseLifecycleMismatch + } + return types.LifecycleUnknownCauseIndeterminate +} + // isRedStatus checks if the lifecycle indicates a RED status func (p *DefaultPolicy) isRedStatus(lifecycle *types.VersionLifecycle) bool { // Past End-of-Life diff --git a/pkg/policy/default_test.go b/pkg/policy/default_test.go index fceac36..4038e9f 100644 --- a/pkg/policy/default_test.go +++ b/pkg/policy/default_test.go @@ -4,9 +4,75 @@ import ( "testing" "time" + "github.com/stretchr/testify/assert" + "github.com/block/Version-Guard/pkg/types" ) +func TestUnknownCause(t *testing.T) { + tests := []struct { + name string + resource *types.Resource + lifecycle *types.VersionLifecycle + status types.Status + want types.LifecycleUnknownCause + }{ + { + name: "provider cause wins", + resource: &types.Resource{CurrentVersion: "8.0.35"}, + lifecycle: &types.VersionLifecycle{UnknownCause: types.LifecycleUnknownCauseProductNotFound}, + status: types.StatusUnknown, + want: types.LifecycleUnknownCauseProductNotFound, + }, + { + name: "empty inventory version", + resource: &types.Resource{CurrentVersion: " "}, + lifecycle: &types.VersionLifecycle{}, + status: types.StatusUnknown, + want: types.LifecycleUnknownCauseEmptyInventoryVersion, + }, + { + name: "cycle absent", + resource: &types.Resource{CurrentVersion: "8.0.35"}, + lifecycle: &types.VersionLifecycle{}, + status: types.StatusUnknown, + want: types.LifecycleUnknownCauseCycleNotFound, + }, + { + name: "lifecycle mismatch", + resource: &types.Resource{CurrentVersion: "8.0.35"}, + lifecycle: &types.VersionLifecycle{Version: "5.7"}, + status: types.StatusUnknown, + want: types.LifecycleUnknownCauseLifecycleMismatch, + }, + { + name: "indeterminate lifecycle", + resource: &types.Resource{CurrentVersion: "8.0.35"}, + lifecycle: &types.VersionLifecycle{Version: "8.0"}, + status: types.StatusUnknown, + want: types.LifecycleUnknownCauseIndeterminate, + }, + { + name: "known status has no cause", + resource: &types.Resource{CurrentVersion: "8.0.35"}, + lifecycle: &types.VersionLifecycle{Version: "8.0", IsSupported: true}, + status: types.StatusGreen, + }, + { + name: "nil lifecycle", + resource: &types.Resource{CurrentVersion: "8.0.35"}, + status: types.StatusUnknown, + want: types.LifecycleUnknownCauseUnattributed, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, UnknownCause(tt.resource, tt.lifecycle, tt.status)) + }) + } +} + func TestDefaultPolicy_Classify_EOLVersion(t *testing.T) { policy := NewDefaultPolicy() diff --git a/pkg/types/lifecycle_details.go b/pkg/types/lifecycle_details.go index e1c74b6..452be41 100644 --- a/pkg/types/lifecycle_details.go +++ b/pkg/types/lifecycle_details.go @@ -5,22 +5,24 @@ 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"` - DeprecatedSupportEnd *time.Time `json:"deprecated_support_end,omitempty"` - EOLDate *time.Time `json:"eol_date,omitempty"` - ExtendedSupportEnd *time.Time `json:"extended_support_end,omitempty"` - ActionableDate *time.Time `json:"actionable_date,omitempty"` - ReleaseDate *time.Time `json:"release_date,omitempty"` - LatestReleaseDate *time.Time `json:"latest_release_date,omitempty"` - LTSDate *time.Time `json:"lts_date,omitempty"` - FetchedAt time.Time `json:"fetched_at,omitempty"` - Version string `json:"version,omitempty"` - Engine string `json:"engine,omitempty"` - Source string `json:"source,omitempty"` - IsSupported bool `json:"is_supported"` - IsDeprecated bool `json:"is_deprecated"` - IsExtendedSupport bool `json:"is_extended_support"` - IsEOL bool `json:"is_eol"` + StandardSupportEnd *time.Time `json:"standard_support_end,omitempty"` + DeprecatedSupportEnd *time.Time `json:"deprecated_support_end,omitempty"` + EOLDate *time.Time `json:"eol_date,omitempty"` + ExtendedSupportEnd *time.Time `json:"extended_support_end,omitempty"` + ActionableDate *time.Time `json:"actionable_date,omitempty"` + ReleaseDate *time.Time `json:"release_date,omitempty"` + LatestReleaseDate *time.Time `json:"latest_release_date,omitempty"` + LTSDate *time.Time `json:"lts_date,omitempty"` + FetchedAt time.Time `json:"fetched_at,omitempty"` + Version string `json:"version,omitempty"` + Engine string `json:"engine,omitempty"` + Source string `json:"source,omitempty"` + DataSource LifecycleDataSource `json:"data_source,omitempty"` + UnknownCause LifecycleUnknownCause `json:"unknown_cause,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 @@ -52,6 +54,8 @@ func LifecycleDetailsFromVersionLifecycle(lifecycle *VersionLifecycle) Lifecycle Version: lifecycle.Version, Engine: lifecycle.Engine, Source: lifecycle.Source, + DataSource: lifecycle.DataSource, + UnknownCause: lifecycle.UnknownCause, IsSupported: lifecycle.IsSupported, IsDeprecated: lifecycle.IsDeprecated, IsExtendedSupport: lifecycle.IsExtendedSupport, diff --git a/pkg/types/resource.go b/pkg/types/resource.go index dec49d9..a539b95 100644 --- a/pkg/types/resource.go +++ b/pkg/types/resource.go @@ -2,6 +2,48 @@ package types import "time" +type LifecycleUnknownCause string + +const ( + LifecycleUnknownCauseProductNotFound LifecycleUnknownCause = "product_not_found" + LifecycleUnknownCauseCycleNotFound LifecycleUnknownCause = "cycle_not_found" + LifecycleUnknownCauseSourceError LifecycleUnknownCause = "source_error" + LifecycleUnknownCauseMalformedCycle LifecycleUnknownCause = "malformed_cycle" + LifecycleUnknownCauseEmptyInventoryVersion LifecycleUnknownCause = "empty_inventory_version" + LifecycleUnknownCauseLifecycleMismatch LifecycleUnknownCause = "lifecycle_mismatch" + LifecycleUnknownCauseIndeterminate LifecycleUnknownCause = "indeterminate_lifecycle" + LifecycleUnknownCauseUnattributed LifecycleUnknownCause = "unattributed" +) + +type LifecycleDataSource string + +const ( + LifecycleDataSourceEndOfLifeDate LifecycleDataSource = "endoflife_date" + LifecycleDataSourceLocalOverride LifecycleDataSource = "local_override" + LifecycleDataSourceUnknown LifecycleDataSource = "unknown" +) + +func KnownLifecycleUnknownCauses() []LifecycleUnknownCause { + return []LifecycleUnknownCause{ + LifecycleUnknownCauseProductNotFound, + LifecycleUnknownCauseCycleNotFound, + LifecycleUnknownCauseSourceError, + LifecycleUnknownCauseMalformedCycle, + LifecycleUnknownCauseEmptyInventoryVersion, + LifecycleUnknownCauseLifecycleMismatch, + LifecycleUnknownCauseIndeterminate, + LifecycleUnknownCauseUnattributed, + } +} + +func KnownLifecycleDataSources() []LifecycleDataSource { + return []LifecycleDataSource{ + LifecycleDataSourceEndOfLifeDate, + LifecycleDataSourceLocalOverride, + LifecycleDataSourceUnknown, + } +} + // ResourceType represents the type of cloud resource. Production code // uses YAML-declared config IDs (e.g. "aurora-mysql", "eks") as // ResourceType values; the named constants below are retained only as @@ -109,6 +151,10 @@ type VersionLifecycle struct { // Source indicates where this lifecycle data came from (e.g., "aws-rds-api", "endoflife.date") Source string + DataSource LifecycleDataSource + + UnknownCause LifecycleUnknownCause + // IsEOL indicates if the version is past End-of-Life IsEOL bool diff --git a/pkg/types/resource_test.go b/pkg/types/resource_test.go index 40f19d9..b1a289f 100644 --- a/pkg/types/resource_test.go +++ b/pkg/types/resource_test.go @@ -30,6 +30,77 @@ func TestResourceType_String(t *testing.T) { } } +func TestLifecycleDetailsFromVersionLifecycle_PropagatesAttribution(t *testing.T) { + lifecycle := &VersionLifecycle{ + Source: "endoflife-date-api", + DataSource: LifecycleDataSourceLocalOverride, + UnknownCause: LifecycleUnknownCauseCycleNotFound, + } + + details := LifecycleDetailsFromVersionLifecycle(lifecycle) + + assert.Equal(t, "endoflife-date-api", details.Source) + assert.Equal(t, LifecycleDataSourceLocalOverride, details.DataSource) + assert.Equal(t, LifecycleUnknownCauseCycleNotFound, details.UnknownCause) +} + +func TestKnownLifecycleValues(t *testing.T) { + tests := []struct { + name string + got []string + want []string + }{ + { + name: "unknown causes", + got: func() []string { + values := KnownLifecycleUnknownCauses() + result := make([]string, len(values)) + for i, value := range values { + result[i] = string(value) + } + return result + }(), + want: []string{ + "product_not_found", + "cycle_not_found", + "source_error", + "malformed_cycle", + "empty_inventory_version", + "lifecycle_mismatch", + "indeterminate_lifecycle", + "unattributed", + }, + }, + { + name: "data sources", + got: func() []string { + values := KnownLifecycleDataSources() + result := make([]string, len(values)) + for i, value := range values { + result[i] = string(value) + } + return result + }(), + want: []string{"endoflife_date", "local_override", "unknown"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, tt.got) + assert.Len(t, tt.got, len(mapValues(tt.got))) + }) + } +} + +func mapValues(values []string) map[string]struct{} { + result := make(map[string]struct{}, len(values)) + for _, value := range values { + result[value] = struct{}{} + } + return result +} + // TestStatBucket_JSONShape locks the StatBucket wire keys. Every // per-grouping bucket (ByResourceType / ByService / ByCloudProvider) // rolls up through this struct, so changing any key here ripples to From bd036f52b105740f09f1232783fc82b2a71b55ee Mon Sep 17 00:00:00 2001 From: Youssef <53960652+yti93@users.noreply.github.com> Date: Wed, 5 Aug 2026 14:00:01 -0700 Subject: [PATCH 05/18] feat: report lifecycle response source Amp-Thread-ID: https://ampcode.com/threads/T-019fd393-8713-704d-bd35-038f82f8dd95 Co-authored-by: Amp --- pkg/eol/endoflife/client.go | 64 ++++++++++---- pkg/eol/endoflife/client_test.go | 117 ++++++++++++++++++++++--- pkg/eol/endoflife/integration_test.go | 8 +- pkg/eol/endoflife/mock_client.go | 6 +- pkg/eol/endoflife/provider.go | 4 +- pkg/eol/endoflife/provider_404_test.go | 16 ++-- pkg/eol/endoflife/provider_test.go | 56 +++++++----- 7 files changed, 202 insertions(+), 69 deletions(-) diff --git a/pkg/eol/endoflife/client.go b/pkg/eol/endoflife/client.go index e7f3a0f..1fd9ede 100644 --- a/pkg/eol/endoflife/client.go +++ b/pkg/eol/endoflife/client.go @@ -6,8 +6,10 @@ import ( "fmt" "io" "net/http" + "strings" "time" + "github.com/block/Version-Guard/pkg/types" "github.com/pkg/errors" ) @@ -17,6 +19,9 @@ const ( // DefaultTimeout for HTTP requests DefaultTimeout = 10 * time.Second + + // EOLSourceHeader identifies the trusted lifecycle source selected by a proxy. + EOLSourceHeader = "X-Version-Guard-EOL-Source" ) // ErrProductNotFound is returned by GetProductCycles when the upstream @@ -33,7 +38,14 @@ var ErrProductNotFound = errors.New("endoflife.date product not found") // This allows us to mock the HTTP client for testing type Client interface { // GetProductCycles retrieves all lifecycle cycles for a product - GetProductCycles(ctx context.Context, product string) ([]*ProductCycle, error) + GetProductCycles(ctx context.Context, product string) (ProductCyclesResult, error) +} + +// ProductCyclesResult contains lifecycle cycles and metadata about their source. +type ProductCyclesResult struct { + Cycles []*ProductCycle + FetchedAt time.Time + DataSource types.LifecycleDataSource } // ProductCycle represents a single version/cycle from endoflife.date API @@ -53,8 +65,9 @@ type ProductCycle struct { // RealHTTPClient is the production implementation of Client using net/http type RealHTTPClient struct { - httpClient *http.Client - baseURL string + httpClient *http.Client + baseURL string + defaultDataSource types.LifecycleDataSource } // NewRealHTTPClient creates a new real HTTP client for endoflife.date API @@ -63,31 +76,50 @@ func NewRealHTTPClient() *RealHTTPClient { httpClient: &http.Client{ Timeout: DefaultTimeout, }, - baseURL: BaseURL, + baseURL: BaseURL, + defaultDataSource: types.LifecycleDataSourceEndOfLifeDate, } } // NewRealHTTPClientWithConfig creates a new client with custom configuration func NewRealHTTPClientWithConfig(httpClient *http.Client, baseURL string) *RealHTTPClient { + defaultDataSource := types.LifecycleDataSourceUnknown if httpClient == nil { httpClient = &http.Client{Timeout: DefaultTimeout} } if baseURL == "" { baseURL = BaseURL + defaultDataSource = types.LifecycleDataSourceEndOfLifeDate } return &RealHTTPClient{ - httpClient: httpClient, - baseURL: baseURL, + httpClient: httpClient, + baseURL: baseURL, + defaultDataSource: defaultDataSource, + } +} + +func lifecycleDataSource(value string, fallback types.LifecycleDataSource) types.LifecycleDataSource { + switch types.LifecycleDataSource(strings.ToLower(strings.TrimSpace(value))) { + case types.LifecycleDataSourceEndOfLifeDate: + return types.LifecycleDataSourceEndOfLifeDate + case types.LifecycleDataSourceLocalOverride: + return types.LifecycleDataSourceLocalOverride + default: + return fallback } } // GetProductCycles retrieves all lifecycle cycles for a product from endoflife.date API -func (c *RealHTTPClient) GetProductCycles(ctx context.Context, product string) ([]*ProductCycle, error) { +func (c *RealHTTPClient) GetProductCycles(ctx context.Context, product string) (ProductCyclesResult, error) { + result := ProductCyclesResult{ + FetchedAt: time.Now(), + DataSource: c.defaultDataSource, + } url := fmt.Sprintf("%s/%s.json", c.baseURL, product) req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, http.NoBody) if err != nil { - return nil, errors.Wrap(err, "failed to create request") + return result, errors.Wrap(err, "failed to create request") } // Set user agent for attribution @@ -95,9 +127,10 @@ func (c *RealHTTPClient) GetProductCycles(ctx context.Context, product string) ( resp, err := c.httpClient.Do(req) if err != nil { - return nil, errors.Wrapf(err, "failed to fetch data from %s", url) + return result, errors.Wrapf(err, "failed to fetch data from %s", url) } defer resp.Body.Close() + result.DataSource = lifecycleDataSource(resp.Header.Get(EOLSourceHeader), result.DataSource) if resp.StatusCode != http.StatusOK { // 404 is a meaningful signal: the product slug doesn't exist on @@ -105,19 +138,18 @@ func (c *RealHTTPClient) GetProductCycles(ctx context.Context, product string) ( // errors.Is(err, ErrProductNotFound) without sniffing the // message text. if resp.StatusCode == http.StatusNotFound { - return nil, errors.Wrapf(ErrProductNotFound, "product %q", product) + return result, errors.Wrapf(ErrProductNotFound, "product %q", product) } body, err := io.ReadAll(resp.Body) if err != nil { - return nil, errors.Errorf("unexpected status code %d (failed to read response body)", resp.StatusCode) + return result, errors.Errorf("unexpected status code %d (failed to read response body)", resp.StatusCode) } - return nil, errors.Errorf("unexpected status code %d: %s", resp.StatusCode, string(body)) + return result, errors.Errorf("unexpected status code %d: %s", resp.StatusCode, string(body)) } - var cycles []*ProductCycle - if err := json.NewDecoder(resp.Body).Decode(&cycles); err != nil { - return nil, errors.Wrap(err, "failed to decode response") + if err := json.NewDecoder(resp.Body).Decode(&result.Cycles); err != nil { + return result, errors.Wrap(err, "failed to decode response") } - return cycles, nil + return result, nil } diff --git a/pkg/eol/endoflife/client_test.go b/pkg/eol/endoflife/client_test.go index 8999b46..fd482e5 100644 --- a/pkg/eol/endoflife/client_test.go +++ b/pkg/eol/endoflife/client_test.go @@ -3,12 +3,22 @@ package endoflife import ( "context" "errors" + "io" "net/http" "net/http/httptest" + "strings" "testing" "time" + + "github.com/block/Version-Guard/pkg/types" ) +type roundTripFunc func(*http.Request) (*http.Response, error) + +func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { + return f(req) +} + func TestRealHTTPClient_GetProductCycles(t *testing.T) { tests := []struct { name string @@ -83,7 +93,7 @@ func TestRealHTTPClient_GetProductCycles(t *testing.T) { ) // Execute - cycles, err := client.GetProductCycles(context.Background(), tt.product) + result, err := client.GetProductCycles(context.Background(), tt.product) // Verify if (err != nil) != tt.wantErr { @@ -91,23 +101,23 @@ func TestRealHTTPClient_GetProductCycles(t *testing.T) { return } - if !tt.wantErr && len(cycles) != tt.wantCycles { - t.Errorf("GetProductCycles() got %d cycles, want %d", len(cycles), tt.wantCycles) + if !tt.wantErr && len(result.Cycles) != tt.wantCycles { + t.Errorf("GetProductCycles() got %d cycles, want %d", len(result.Cycles), tt.wantCycles) } // Verify first cycle if successful if !tt.wantErr && tt.wantCycles > 0 { - if cycles[0].Cycle != "1.31" { - t.Errorf("First cycle = %s, want 1.31", cycles[0].Cycle) + if result.Cycles[0].Cycle != "1.31" { + t.Errorf("First cycle = %s, want 1.31", result.Cycles[0].Cycle) } - if cycles[0].ReleaseDate != "2024-11-19" { - t.Errorf("First cycle release date = %s, want 2024-11-19", cycles[0].ReleaseDate) + if result.Cycles[0].ReleaseDate != "2024-11-19" { + t.Errorf("First cycle release date = %s, want 2024-11-19", result.Cycles[0].ReleaseDate) } - if cycles[0].LatestReleaseDate != "2025-01-15" { - t.Errorf("First cycle latest release date = %s, want 2025-01-15", cycles[0].LatestReleaseDate) + if result.Cycles[0].LatestReleaseDate != "2025-01-15" { + t.Errorf("First cycle latest release date = %s, want 2025-01-15", result.Cycles[0].LatestReleaseDate) } - if cycles[0].LTS != "2025-02-01" { - t.Errorf("First cycle lts = %v, want 2025-02-01", cycles[0].LTS) + if result.Cycles[0].LTS != "2025-02-01" { + t.Errorf("First cycle lts = %v, want 2025-02-01", result.Cycles[0].LTS) } } }) @@ -120,13 +130,14 @@ func TestRealHTTPClient_GetProductCycles(t *testing.T) { // without sniffing the message text. func TestRealHTTPClient_404ReturnsTypedError(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set(EOLSourceHeader, string(types.LifecycleDataSourceLocalOverride)) w.WriteHeader(http.StatusNotFound) _, _ = w.Write([]byte(`{"error":"Product not found"}`)) })) defer server.Close() client := NewRealHTTPClientWithConfig(&http.Client{Timeout: 5 * time.Second}, server.URL) - _, err := client.GetProductCycles(context.Background(), "non-existent") + result, err := client.GetProductCycles(context.Background(), "non-existent") if err == nil { t.Fatal("expected error for 404, got nil") @@ -134,6 +145,88 @@ func TestRealHTTPClient_404ReturnsTypedError(t *testing.T) { if !errors.Is(err, ErrProductNotFound) { t.Errorf("404 should wrap ErrProductNotFound, got %v", err) } + if result.DataSource != types.LifecycleDataSourceLocalOverride { + t.Errorf("DataSource = %q, want %q", result.DataSource, types.LifecycleDataSourceLocalOverride) + } + if result.FetchedAt.IsZero() { + t.Error("FetchedAt should be non-zero for 404 response") + } +} + +func TestRealHTTPClient_ProductCyclesResultSource(t *testing.T) { + tests := []struct { + name string + baseURL func(string) string + header string + wantSource types.LifecycleDataSource + }{ + { + name: "custom endpoint with local override header", + baseURL: func(serverURL string) string { return serverURL }, + header: "local_override", + wantSource: types.LifecycleDataSourceLocalOverride, + }, + { + name: "custom endpoint without header", + baseURL: func(serverURL string) string { return serverURL }, + wantSource: types.LifecycleDataSourceUnknown, + }, + { + name: "invalid source header", + baseURL: func(serverURL string) string { return serverURL }, + header: "attacker-controlled-value", + wantSource: types.LifecycleDataSourceUnknown, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + if tt.header != "" { + w.Header().Set(EOLSourceHeader, tt.header) + } + _, _ = w.Write([]byte(`[]`)) + })) + defer server.Close() + + client := NewRealHTTPClientWithConfig(nil, tt.baseURL(server.URL)) + result, err := client.GetProductCycles(context.Background(), "test") + if err != nil { + t.Fatalf("GetProductCycles() error = %v", err) + } + if result.DataSource != tt.wantSource { + t.Errorf("DataSource = %q, want %q", result.DataSource, tt.wantSource) + } + if result.FetchedAt.IsZero() { + t.Error("FetchedAt should be non-zero") + } + }) + } +} + +func TestNewRealHTTPClient_DefaultDataSource(t *testing.T) { + client := NewRealHTTPClient() + client.httpClient.Transport = roundTripFunc(func(req *http.Request) (*http.Response, error) { + req.URL.Scheme = "http" + req.URL.Host = "example.test" + return &http.Response{ + StatusCode: http.StatusOK, + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader(`[]`)), + Request: req, + }, nil + }) + + result, err := client.GetProductCycles(context.Background(), "test") + if err != nil { + t.Fatalf("GetProductCycles() error = %v", err) + } + if result.DataSource != types.LifecycleDataSourceEndOfLifeDate { + t.Errorf("DataSource = %q, want %q", result.DataSource, types.LifecycleDataSourceEndOfLifeDate) + } + if result.FetchedAt.IsZero() { + t.Error("FetchedAt should be non-zero") + } } func TestRealHTTPClient_UserAgent(t *testing.T) { diff --git a/pkg/eol/endoflife/integration_test.go b/pkg/eol/endoflife/integration_test.go index 03ce6b1..04418ed 100644 --- a/pkg/eol/endoflife/integration_test.go +++ b/pkg/eol/endoflife/integration_test.go @@ -22,19 +22,19 @@ func TestRealAPIIntegration(t *testing.T) { defer cancel() // Test amazon-eks product - cycles, err := client.GetProductCycles(ctx, "amazon-eks") + result, err := client.GetProductCycles(ctx, "amazon-eks") if err != nil { t.Fatalf("Failed to fetch EKS cycles: %v", err) } - if len(cycles) == 0 { + if len(result.Cycles) == 0 { t.Fatal("Expected at least one EKS version, got none") } - t.Logf("Fetched %d EKS versions from endoflife.date", len(cycles)) + t.Logf("Fetched %d EKS versions from endoflife.date", len(result.Cycles)) // Verify first few versions have expected structure - for i, cycle := range cycles { + for i, cycle := range result.Cycles { if i >= 5 { break } diff --git a/pkg/eol/endoflife/mock_client.go b/pkg/eol/endoflife/mock_client.go index fa80abf..f59208a 100644 --- a/pkg/eol/endoflife/mock_client.go +++ b/pkg/eol/endoflife/mock_client.go @@ -6,13 +6,13 @@ import ( // MockClient is a mock implementation of Client for testing type MockClient struct { - GetProductCyclesFunc func(ctx context.Context, product string) ([]*ProductCycle, error) + GetProductCyclesFunc func(ctx context.Context, product string) (ProductCyclesResult, error) } // GetProductCycles calls the mock function -func (m *MockClient) GetProductCycles(ctx context.Context, product string) ([]*ProductCycle, error) { +func (m *MockClient) GetProductCycles(ctx context.Context, product string) (ProductCyclesResult, error) { if m.GetProductCyclesFunc != nil { return m.GetProductCyclesFunc(ctx, product) } - return nil, nil + return ProductCyclesResult{}, nil } diff --git a/pkg/eol/endoflife/provider.go b/pkg/eol/endoflife/provider.go index 3c6b972..12f645b 100644 --- a/pkg/eol/endoflife/provider.go +++ b/pkg/eol/endoflife/provider.go @@ -199,7 +199,7 @@ func (p *Provider) ListAllVersions(ctx context.Context, engine string) ([]*types // Cache miss or expired - use singleflight to prevent thundering herd result, err, _ := p.group.Do(cacheKey, func() (interface{}, error) { // Fetch from endoflife.date API (only one goroutine executes this) - cycles, err := p.client.GetProductCycles(ctx, product) + result, err := p.client.GetProductCycles(ctx, product) if err != nil { // 404 (product not yet on endoflife.date — new product or // pending PR like aurora-mysql) is treated as an empty @@ -227,7 +227,7 @@ func (p *Provider) ListAllVersions(ctx context.Context, engine string) ([]*types // Convert to our types var versions []*types.VersionLifecycle - for _, cycle := range cycles { + for _, cycle := range result.Cycles { lifecycle, err := p.convertCycle(engine, product, cycle) if err != nil { // Skip cycles we can't parse, but log a warning diff --git a/pkg/eol/endoflife/provider_404_test.go b/pkg/eol/endoflife/provider_404_test.go index 383b1fc..4b08147 100644 --- a/pkg/eol/endoflife/provider_404_test.go +++ b/pkg/eol/endoflife/provider_404_test.go @@ -17,8 +17,8 @@ import ( // return an UNKNOWN lifecycle, not error out. func TestProvider_GetVersionLifecycle_Product404(t *testing.T) { mockClient := &MockClient{ - GetProductCyclesFunc: func(_ context.Context, product string) ([]*ProductCycle, error) { - return nil, pkgerrors.Wrapf(ErrProductNotFound, "product %q", product) + GetProductCyclesFunc: func(_ context.Context, product string) (ProductCyclesResult, error) { + return ProductCyclesResult{}, pkgerrors.Wrapf(ErrProductNotFound, "product %q", product) }, } @@ -40,8 +40,8 @@ func TestProvider_GetVersionLifecycle_Product404(t *testing.T) { // empty list (not error) for ErrProductNotFound. func TestProvider_ListAllVersions_Product404(t *testing.T) { mockClient := &MockClient{ - GetProductCyclesFunc: func(_ context.Context, product string) ([]*ProductCycle, error) { - return nil, pkgerrors.Wrapf(ErrProductNotFound, "product %q", product) + GetProductCyclesFunc: func(_ context.Context, product string) (ProductCyclesResult, error) { + return ProductCyclesResult{}, pkgerrors.Wrapf(ErrProductNotFound, "product %q", product) }, } @@ -61,9 +61,9 @@ func TestProvider_ListAllVersions_Product404(t *testing.T) { func TestProvider_ListAllVersions_404IsCached(t *testing.T) { var calls atomic.Int32 mockClient := &MockClient{ - GetProductCyclesFunc: func(_ context.Context, product string) ([]*ProductCycle, error) { + GetProductCyclesFunc: func(_ context.Context, product string) (ProductCyclesResult, error) { calls.Add(1) - return nil, pkgerrors.Wrapf(ErrProductNotFound, "product %q", product) + return ProductCyclesResult{}, pkgerrors.Wrapf(ErrProductNotFound, "product %q", product) }, } @@ -94,8 +94,8 @@ func TestProvider_GetVersionLifecycle_NonProductErrors(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { mockClient := &MockClient{ - GetProductCyclesFunc: func(_ context.Context, _ string) ([]*ProductCycle, error) { - return nil, errors.New(tt.errorMsg) + GetProductCyclesFunc: func(_ context.Context, _ string) (ProductCyclesResult, error) { + return ProductCyclesResult{}, errors.New(tt.errorMsg) }, } diff --git a/pkg/eol/endoflife/provider_test.go b/pkg/eol/endoflife/provider_test.go index c8f1ae8..2a78793 100644 --- a/pkg/eol/endoflife/provider_test.go +++ b/pkg/eol/endoflife/provider_test.go @@ -10,15 +10,23 @@ import ( "github.com/block/Version-Guard/pkg/types" ) +func productCyclesResult(cycles []*ProductCycle) ProductCyclesResult { + return ProductCyclesResult{ + Cycles: cycles, + DataSource: types.LifecycleDataSourceEndOfLifeDate, + FetchedAt: time.Now(), + } +} + func TestProvider_GetVersionLifecycle_PostgreSQL(t *testing.T) { // Mock client with test data (using dates relative to 2026-04-08) // Testing with PostgreSQL which uses STANDARD endoflife.date schema mockClient := &MockClient{ - GetProductCyclesFunc: func(ctx context.Context, product string) ([]*ProductCycle, error) { + GetProductCyclesFunc: func(ctx context.Context, product string) (ProductCyclesResult, error) { if product != "amazon-rds-postgresql" { t.Errorf("Expected product amazon-rds-postgresql, got %s", product) } - return []*ProductCycle{ + return productCyclesResult([]*ProductCycle{ { // Current version - still in standard support Cycle: "16.2", @@ -43,7 +51,7 @@ func TestProvider_GetVersionLifecycle_PostgreSQL(t *testing.T) { EOL: "2024-11-14", // Past (before 2026-04-08) ExtendedSupport: false, }, - }, nil + }), nil }, } @@ -132,8 +140,8 @@ func TestProvider_GetVersionLifecycle_PostgreSQL(t *testing.T) { func TestProvider_ListAllVersions(t *testing.T) { mockClient := &MockClient{ - GetProductCyclesFunc: func(ctx context.Context, product string) ([]*ProductCycle, error) { - return []*ProductCycle{ + GetProductCyclesFunc: func(ctx context.Context, product string) (ProductCyclesResult, error) { + return productCyclesResult([]*ProductCycle{ { Cycle: "16.2", ReleaseDate: "2024-05-09", @@ -146,7 +154,7 @@ func TestProvider_ListAllVersions(t *testing.T) { Support: "2027-11-11", EOL: "2027-11-11", }, - }, nil + }), nil }, } @@ -176,16 +184,16 @@ func TestProvider_ListAllVersions(t *testing.T) { func TestProvider_Caching(t *testing.T) { callCount := 0 mockClient := &MockClient{ - GetProductCyclesFunc: func(ctx context.Context, product string) ([]*ProductCycle, error) { + GetProductCyclesFunc: func(ctx context.Context, product string) (ProductCyclesResult, error) { callCount++ - return []*ProductCycle{ + return productCyclesResult([]*ProductCycle{ { Cycle: "16.2", ReleaseDate: "2024-05-09", Support: "2028-11-09", EOL: "2028-11-09", }, - }, nil + }), nil }, } @@ -222,16 +230,16 @@ func TestProvider_Caching(t *testing.T) { func TestProvider_CacheExpiration(t *testing.T) { callCount := 0 mockClient := &MockClient{ - GetProductCyclesFunc: func(ctx context.Context, product string) ([]*ProductCycle, error) { + GetProductCyclesFunc: func(ctx context.Context, product string) (ProductCyclesResult, error) { callCount++ - return []*ProductCycle{ + return productCyclesResult([]*ProductCycle{ { Cycle: "16.2", ReleaseDate: "2024-05-09", Support: "2028-11-09", EOL: "2028-11-09", }, - }, nil + }), nil }, } @@ -262,15 +270,15 @@ func TestProvider_CacheExpiration(t *testing.T) { func TestProvider_VersionNotFound(t *testing.T) { mockClient := &MockClient{ - GetProductCyclesFunc: func(ctx context.Context, product string) ([]*ProductCycle, error) { - return []*ProductCycle{ + GetProductCyclesFunc: func(ctx context.Context, product string) (ProductCyclesResult, error) { + return productCyclesResult([]*ProductCycle{ { Cycle: "16.2", ReleaseDate: "2024-05-09", Support: "2028-11-09", EOL: "2028-11-09", }, - }, nil + }), nil }, } @@ -319,18 +327,18 @@ func TestProvider_Engines(t *testing.T) { // product-specific endoflife.date field semantics stay out of Go code. func TestProvider_DeclarativeLifecycle(t *testing.T) { mockClient := &MockClient{ - GetProductCyclesFunc: func(ctx context.Context, product string) ([]*ProductCycle, error) { + GetProductCyclesFunc: func(ctx context.Context, product string) (ProductCyclesResult, error) { if product != "amazon-eks" { t.Errorf("Expected product amazon-eks, got %s", product) } - return []*ProductCycle{ + return productCyclesResult([]*ProductCycle{ { Cycle: "1.32", ReleaseDate: "2024-11-19", EOL: "2026-12-19", ExtendedSupport: "2027-12-19", }, - }, nil + }), nil }, } @@ -570,15 +578,15 @@ func TestProvider_InterfaceCompliance(t *testing.T) { // not currently derive upgrade targets from it. func TestProvider_ListAllVersions_PreservesCycleOrder(t *testing.T) { mockClient := &MockClient{ - GetProductCyclesFunc: func(_ context.Context, _ string) ([]*ProductCycle, error) { + GetProductCyclesFunc: func(_ context.Context, _ string) (ProductCyclesResult, error) { // Deliberately not in semver order — we want to assert // ListAllVersions does NOT reorder. - return []*ProductCycle{ + return productCyclesResult([]*ProductCycle{ {Cycle: "17", ReleaseDate: "2025-02-20", Support: "2030-02-28", EOL: "2030-02-28"}, {Cycle: "16", ReleaseDate: "2024-02-20", Support: "2029-02-28", EOL: "2029-02-28"}, {Cycle: "9.6", ReleaseDate: "2016-09-29", Support: "2021-11-11", EOL: "2021-11-11"}, {Cycle: "12", ReleaseDate: "2019-10-03", Support: "2024-11-14", EOL: "2024-11-14"}, - }, nil + }), nil }, } provider, _ := NewProvider(mockClient, "amazon-rds-postgresql", "", 1*time.Hour, nil) @@ -604,12 +612,12 @@ func TestProvider_ListAllVersions_PreservesCycleOrder(t *testing.T) { // regression that re-introduces shared cache mutation. func TestProvider_GetVersionLifecycle_ConcurrentSafe(t *testing.T) { mockClient := &MockClient{ - GetProductCyclesFunc: func(_ context.Context, _ string) ([]*ProductCycle, error) { - return []*ProductCycle{ + GetProductCyclesFunc: func(_ context.Context, _ string) (ProductCyclesResult, error) { + return productCyclesResult([]*ProductCycle{ {Cycle: "17", ReleaseDate: "2025-02-20", Support: "2030-02-28", EOL: "2030-02-28"}, {Cycle: "16.2", ReleaseDate: "2024-05-09", Support: "2028-11-09", EOL: "2028-11-09"}, {Cycle: "12.18", ReleaseDate: "2020-11-12", Support: "2024-11-14", EOL: "2024-11-14"}, - }, nil + }), nil }, } provider, _ := NewProvider(mockClient, "amazon-rds-postgresql", "", 1*time.Hour, nil) From 5685a8b458ea881b8f919a510d5e0e84032956b3 Mon Sep 17 00:00:00 2001 From: Youssef <53960652+yti93@users.noreply.github.com> Date: Wed, 5 Aug 2026 14:03:00 -0700 Subject: [PATCH 06/18] feat: attribute lifecycle provider failures Amp-Thread-ID: https://ampcode.com/threads/T-019fd393-8713-704d-bd35-038f82f8dd95 Co-authored-by: Amp --- pkg/eol/endoflife/provider.go | 151 +++++++++++++++++++------ pkg/eol/endoflife/provider_404_test.go | 9 +- pkg/eol/endoflife/provider_test.go | 88 +++++++++++++- pkg/eol/provider.go | 4 +- 4 files changed, 215 insertions(+), 37 deletions(-) diff --git a/pkg/eol/endoflife/provider.go b/pkg/eol/endoflife/provider.go index 12f645b..6872e7b 100644 --- a/pkg/eol/endoflife/provider.go +++ b/pkg/eol/endoflife/provider.go @@ -42,8 +42,13 @@ type Provider struct { //nolint:govet // field alignment sacrificed for readability type cachedVersions struct { - versions []*types.VersionLifecycle - fetchedAt time.Time + versions []*types.VersionLifecycle + malformedCycles []string + fetchedAt time.Time + cachedAt time.Time + dataSource types.LifecycleDataSource + productCause types.LifecycleUnknownCause + fetchErr error } // NewProvider creates a new endoflife.date EOL provider bound to a single @@ -126,10 +131,12 @@ func (p *Provider) GetVersionLifecycle(ctx context.Context, engine, version stri engine = strings.ToLower(engine) version = strings.TrimSpace(version) - // Fetch all versions - versions, err := p.ListAllVersions(ctx, engine) + cached, err := p.loadVersions(ctx, engine) if err != nil { - return nil, err + return &types.VersionLifecycle{ + Engine: engine, Source: p.Name(), DataSource: cached.dataSource, + FetchedAt: cached.fetchedAt, UnknownCause: types.LifecycleUnknownCauseSourceError, + }, err } // Find the specific version — try exact match first, then prefix match. @@ -137,10 +144,10 @@ func (p *Provider) GetVersionLifecycle(ctx context.Context, engine, version stri // reports full versions (e.g., "8.0.35", "7.1.0"). var bestMatch *types.VersionLifecycle bestMatchLen := 0 - for _, v := range versions { + for _, v := range cached.versions { cycleVersion := strings.TrimSpace(v.Version) if cycleVersion == version { - return v, nil + return lifecycleWithMetadata(v, engine, cached), nil } if strings.HasPrefix(version, cycleVersion+".") && len(cycleVersion) > bestMatchLen { bestMatch = v @@ -148,7 +155,14 @@ func (p *Provider) GetVersionLifecycle(ctx context.Context, engine, version stri } } if bestMatch != nil { - return bestMatch, nil + return lifecycleWithMetadata(bestMatch, engine, cached), nil + } + cause := cached.productCause + if cause == "" { + cause = types.LifecycleUnknownCauseCycleNotFound + if matchingCycle(cached.malformedCycles, version) { + cause = types.LifecycleUnknownCauseMalformedCycle + } } // Version not found - return unknown lifecycle (empty Version signals missing data) @@ -163,14 +177,34 @@ func (p *Provider) GetVersionLifecycle(ctx context.Context, engine, version stri // losing visibility into resources with incomplete EOL data coverage. // return &types.VersionLifecycle{ - Version: "", // Empty = unknown data, not unsupported version - Engine: engine, - IsSupported: false, - Source: p.Name(), - FetchedAt: time.Now(), + Version: "", // Empty = unknown data, not unsupported version + Engine: engine, + IsSupported: false, + Source: p.Name(), + FetchedAt: cached.fetchedAt, + DataSource: cached.dataSource, + UnknownCause: cause, }, nil } +func lifecycleWithMetadata(lifecycle *types.VersionLifecycle, engine string, cached *cachedVersions) *types.VersionLifecycle { + result := *lifecycle + result.Engine = engine + result.FetchedAt = cached.fetchedAt + result.DataSource = cached.dataSource + return &result +} + +func matchingCycle(cycles []string, version string) bool { + for _, cycle := range cycles { + cycle = strings.TrimSpace(cycle) + if cycle == version || strings.HasPrefix(version, cycle+".") { + return true + } + } + return false +} + // 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 @@ -180,6 +214,14 @@ func (p *Provider) ListAllVersions(ctx context.Context, engine string) ([]*types // Normalize engine (used only as a label on returned VersionLifecycles) engine = strings.ToLower(engine) + cached, err := p.loadVersions(ctx, engine) + if err != nil { + return nil, err + } + return cached.versions, nil +} + +func (p *Provider) loadVersions(ctx context.Context, engine string) (*cachedVersions, error) { product := p.product // Use product as cache key @@ -188,10 +230,9 @@ func (p *Provider) ListAllVersions(ctx context.Context, engine string) ([]*types // Check cache first (fast path) p.mu.RLock() if cached, found := p.cache[cacheKey]; found { - if time.Since(cached.fetchedAt) < p.cacheTTL { - versions := cached.versions + if time.Since(cached.cachedAt) < p.cacheTTL { p.mu.RUnlock() - return versions, nil + return cached, nil } } p.mu.RUnlock() @@ -199,7 +240,7 @@ func (p *Provider) ListAllVersions(ctx context.Context, engine string) ([]*types // Cache miss or expired - use singleflight to prevent thundering herd result, err, _ := p.group.Do(cacheKey, func() (interface{}, error) { // Fetch from endoflife.date API (only one goroutine executes this) - result, err := p.client.GetProductCycles(ctx, product) + cyclesResult, err := p.client.GetProductCycles(ctx, product) if err != nil { // 404 (product not yet on endoflife.date — new product or // pending PR like aurora-mysql) is treated as an empty @@ -213,23 +254,36 @@ func (p *Provider) ListAllVersions(ctx context.Context, engine string) ([]*types "engine", engine, "product", product, "note", "This may be a new product or pending PR on endoflife.date") - empty := []*types.VersionLifecycle{} - p.mu.Lock() - p.cache[cacheKey] = &cachedVersions{ - versions: empty, - fetchedAt: time.Now(), + entry := &cachedVersions{ + versions: []*types.VersionLifecycle{}, + fetchedAt: cyclesResult.FetchedAt, + cachedAt: time.Now(), + dataSource: cyclesResult.DataSource, + productCause: types.LifecycleUnknownCauseProductNotFound, } + p.mu.Lock() + p.cache[cacheKey] = entry p.mu.Unlock() - return empty, nil + return entry, nil } - return nil, errors.Wrapf(err, "failed to fetch cycles for product %s", product) + return &cachedVersions{fetchedAt: cyclesResult.FetchedAt, dataSource: cyclesResult.DataSource, + fetchErr: errors.Wrapf(err, "failed to fetch cycles for product %s", product)}, nil } // Convert to our types var versions []*types.VersionLifecycle - for _, cycle := range result.Cycles { + var malformedCycles []string + for _, cycle := range cyclesResult.Cycles { + if err := ValidateProductCycle(cycle); err != nil { + if cycle != nil && strings.TrimSpace(cycle.Cycle) != "" { + malformedCycles = append(malformedCycles, strings.TrimSpace(cycle.Cycle)) + } + p.logger.WarnContext(ctx, "invalid EOL cycle, skipping", "engine", engine, "product", product, "error", err) + continue + } lifecycle, err := p.convertCycle(engine, product, cycle) if err != nil { + malformedCycles = append(malformedCycles, strings.TrimSpace(cycle.Cycle)) // Skip cycles we can't parse, but log a warning p.logger.WarnContext(ctx, "failed to convert EOL cycle, skipping", "engine", engine, @@ -242,24 +296,55 @@ func (p *Provider) ListAllVersions(ctx context.Context, engine string) ([]*types // Cache the result p.mu.Lock() - p.cache[cacheKey] = &cachedVersions{ - versions: versions, - fetchedAt: time.Now(), - } + entry := &cachedVersions{versions: versions, malformedCycles: malformedCycles, + fetchedAt: cyclesResult.FetchedAt, cachedAt: time.Now(), dataSource: cyclesResult.DataSource} + p.cache[cacheKey] = entry p.mu.Unlock() - return versions, nil + return entry, nil }) if err != nil { return nil, err } - versions, ok := result.([]*types.VersionLifecycle) + cached, ok := result.(*cachedVersions) if !ok { - return nil, errors.New("failed to convert result to VersionLifecycle slice") + return nil, errors.New("failed to convert result to cached versions") + } + return cached, cached.fetchErr +} + +// ValidateProductCycle enforces the date-or-boolean fields accepted by runtime adapters. +func ValidateProductCycle(cycle *ProductCycle) error { + if cycle == nil { + return errors.New("cycle is nil") + } + if strings.TrimSpace(cycle.Cycle) == "" { + return errors.New("cycle identifier is empty") + } + for name, value := range map[string]any{"support": cycle.Support, "eol": cycle.EOL, "extendedSupport": cycle.ExtendedSupport, "lts": cycle.LTS} { + if err := validateDateOrBoolean(value); err != nil { + return errors.Wrapf(err, "%s for cycle %q", name, cycle.Cycle) + } + } + return nil +} + +func validateDateOrBoolean(value any) error { + switch value := value.(type) { + case nil, bool: + return nil + case string: + value = strings.TrimSpace(value) + if value == "" || value == "true" || value == "false" { + return nil + } + _, err := time.Parse("2006-01-02", value) + return err + default: + return errors.Errorf("unsupported value type %T", value) } - return versions, nil } // convertCycle delegates the cycle→VersionLifecycle conversion to the diff --git a/pkg/eol/endoflife/provider_404_test.go b/pkg/eol/endoflife/provider_404_test.go index 4b08147..d99cda3 100644 --- a/pkg/eol/endoflife/provider_404_test.go +++ b/pkg/eol/endoflife/provider_404_test.go @@ -5,7 +5,9 @@ import ( "errors" "sync/atomic" "testing" + "time" + "github.com/block/Version-Guard/pkg/types" pkgerrors "github.com/pkg/errors" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -16,9 +18,10 @@ import ( // The provider must treat ErrProductNotFound as a recoverable signal and // return an UNKNOWN lifecycle, not error out. func TestProvider_GetVersionLifecycle_Product404(t *testing.T) { + fetchedAt := time.Date(2026, time.August, 5, 12, 0, 0, 0, time.UTC) mockClient := &MockClient{ GetProductCyclesFunc: func(_ context.Context, product string) (ProductCyclesResult, error) { - return ProductCyclesResult{}, pkgerrors.Wrapf(ErrProductNotFound, "product %q", product) + return ProductCyclesResult{DataSource: types.LifecycleDataSourceLocalOverride, FetchedAt: fetchedAt}, pkgerrors.Wrapf(ErrProductNotFound, "product %q", product) }, } @@ -34,6 +37,10 @@ func TestProvider_GetVersionLifecycle_Product404(t *testing.T) { assert.Equal(t, "", lifecycle.Version, "Version should be empty for UNKNOWN") assert.Equal(t, "aurora-mysql", lifecycle.Engine) assert.False(t, lifecycle.IsSupported, "IsSupported should be false for UNKNOWN") + assert.Equal(t, provider.Name(), lifecycle.Source) + assert.Equal(t, types.LifecycleUnknownCauseProductNotFound, lifecycle.UnknownCause) + assert.Equal(t, types.LifecycleDataSourceLocalOverride, lifecycle.DataSource) + assert.Equal(t, fetchedAt, lifecycle.FetchedAt) } // TestProvider_ListAllVersions_Product404 tests that ListAllVersions returns diff --git a/pkg/eol/endoflife/provider_test.go b/pkg/eol/endoflife/provider_test.go index 2a78793..f3fa2b0 100644 --- a/pkg/eol/endoflife/provider_test.go +++ b/pkg/eol/endoflife/provider_test.go @@ -2,6 +2,7 @@ package endoflife import ( "context" + "errors" "strings" "sync" "testing" @@ -269,16 +270,17 @@ func TestProvider_CacheExpiration(t *testing.T) { } func TestProvider_VersionNotFound(t *testing.T) { + fetchedAt := time.Date(2026, time.August, 5, 12, 0, 0, 0, time.UTC) mockClient := &MockClient{ GetProductCyclesFunc: func(ctx context.Context, product string) (ProductCyclesResult, error) { - return productCyclesResult([]*ProductCycle{ + return ProductCyclesResult{Cycles: []*ProductCycle{ { Cycle: "16.2", ReleaseDate: "2024-05-09", Support: "2028-11-09", EOL: "2028-11-09", }, - }), nil + }, DataSource: types.LifecycleDataSourceEndOfLifeDate, FetchedAt: fetchedAt}, nil }, } @@ -299,6 +301,88 @@ func TestProvider_VersionNotFound(t *testing.T) { if lifecycle.Engine != "postgres" { t.Errorf("Engine = %s, want postgres", lifecycle.Engine) } + if lifecycle.UnknownCause != types.LifecycleUnknownCauseCycleNotFound { + t.Errorf("UnknownCause = %q, want %q", lifecycle.UnknownCause, types.LifecycleUnknownCauseCycleNotFound) + } + if lifecycle.DataSource != types.LifecycleDataSourceEndOfLifeDate || !lifecycle.FetchedAt.Equal(fetchedAt) { + t.Errorf("metadata = (%q, %v), want (%q, %v)", lifecycle.DataSource, lifecycle.FetchedAt, types.LifecycleDataSourceEndOfLifeDate, fetchedAt) + } +} + +func TestProvider_SourceErrorReturnsDiagnosticLifecycle(t *testing.T) { + fetchedAt := time.Date(2026, time.August, 5, 13, 0, 0, 0, time.UTC) + provider, _ := NewProvider(&MockClient{GetProductCyclesFunc: func(context.Context, string) (ProductCyclesResult, error) { + return ProductCyclesResult{DataSource: types.LifecycleDataSourceLocalOverride, FetchedAt: fetchedAt}, errors.New("status 500") + }}, "mysql", "", time.Hour, nil) + + lifecycle, err := provider.GetVersionLifecycle(context.Background(), "mysql", "8.0") + if err == nil { + t.Fatal("expected source error") + } + if lifecycle == nil || lifecycle.UnknownCause != types.LifecycleUnknownCauseSourceError { + t.Fatalf("lifecycle = %#v, want source_error diagnostic", lifecycle) + } + if lifecycle.DataSource != types.LifecycleDataSourceLocalOverride || !lifecycle.FetchedAt.Equal(fetchedAt) { + t.Errorf("diagnostic metadata not preserved: %#v", lifecycle) + } +} + +func TestProvider_MalformedMatchingCycle(t *testing.T) { + tests := []struct { + name, version string + wantCause types.LifecycleUnknownCause + }{ + {name: "matching malformed cycle", version: "8.0.35", wantCause: types.LifecycleUnknownCauseMalformedCycle}, + {name: "unrelated malformed cycle", version: "9.0", wantCause: types.LifecycleUnknownCauseCycleNotFound}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + provider, _ := NewProvider(&MockClient{GetProductCyclesFunc: func(context.Context, string) (ProductCyclesResult, error) { + return productCyclesResult([]*ProductCycle{ + {Cycle: "8.0", EOL: "not-a-date"}, + {Cycle: "7", Support: "invalid"}, + }), nil + }}, "mysql", "", time.Hour, nil) + lifecycle, err := provider.GetVersionLifecycle(context.Background(), "mysql", tt.version) + if err != nil { + t.Fatal(err) + } + if lifecycle.UnknownCause != tt.wantCause { + t.Errorf("UnknownCause = %q, want %q", lifecycle.UnknownCause, tt.wantCause) + } + }) + } +} + +func TestProvider_ValidCycleWinsOverMalformedPrefix(t *testing.T) { + provider, _ := NewProvider(&MockClient{GetProductCyclesFunc: func(context.Context, string) (ProductCyclesResult, error) { + return productCyclesResult([]*ProductCycle{ + {Cycle: "8", EOL: "invalid"}, + {Cycle: "8.0", EOL: "2030-01-01"}, + }), nil + }}, "mysql", "", time.Hour, nil) + lifecycle, err := provider.GetVersionLifecycle(context.Background(), "mysql", "8.0.35") + if err != nil { + t.Fatal(err) + } + if lifecycle.Version != "8.0" || lifecycle.UnknownCause != "" { + t.Fatalf("lifecycle = %#v, want valid 8.0 cycle", lifecycle) + } +} + +func TestValidateProductCycle(t *testing.T) { + invalid := []*ProductCycle{nil, {}, {Cycle: " "}, {Cycle: "8", Support: 42}, {Cycle: "8", EOL: "2026-1-01"}} + for _, cycle := range invalid { + if err := ValidateProductCycle(cycle); err == nil { + t.Errorf("ValidateProductCycle(%#v) = nil, want error", cycle) + } + } + valid := []*ProductCycle{{Cycle: "8"}, {Cycle: "8", Support: true, EOL: "false", ExtendedSupport: "", LTS: "2026-01-01"}} + for _, cycle := range valid { + if err := ValidateProductCycle(cycle); err != nil { + t.Errorf("ValidateProductCycle(%#v) = %v", cycle, err) + } + } } func TestProvider_Name(t *testing.T) { diff --git a/pkg/eol/provider.go b/pkg/eol/provider.go index d9b2940..4bc5fa7 100644 --- a/pkg/eol/provider.go +++ b/pkg/eol/provider.go @@ -8,7 +8,9 @@ import ( // Provider defines the interface for fetching version lifecycle (EOL) data type Provider interface { - // GetVersionLifecycle retrieves lifecycle information for a specific engine version + // GetVersionLifecycle retrieves lifecycle information for a specific engine version. + // Implementations may return a non-nil diagnostic lifecycle with a non-nil error; + // callers should preserve that lifecycle when reporting the failure. GetVersionLifecycle(ctx context.Context, engine, version string) (*types.VersionLifecycle, error) // ListAllVersions retrieves all known versions for an engine From 7c2b5425ae6756ef8588e5ae7ada4324cfd7d23a Mon Sep 17 00:00:00 2001 From: Youssef <53960652+yti93@users.noreply.github.com> Date: Wed, 5 Aug 2026 14:06:53 -0700 Subject: [PATCH 07/18] feat: expose lifecycle attribution metrics Amp-Thread-ID: https://ampcode.com/threads/T-019fd393-8713-704d-bd35-038f82f8dd95 Co-authored-by: Amp --- pkg/snapshot/builder_test.go | 4 + pkg/telemetry/metrics.go | 56 +++++++++++++ pkg/telemetry/metrics_test.go | 58 +++++++++++++ pkg/workflow/detection/activities.go | 37 +++++++-- pkg/workflow/detection/activities_test.go | 99 +++++++++++++++++++++++ 5 files changed, 247 insertions(+), 7 deletions(-) diff --git a/pkg/snapshot/builder_test.go b/pkg/snapshot/builder_test.go index 2ac0a7f..0cf4500 100644 --- a/pkg/snapshot/builder_test.go +++ b/pkg/snapshot/builder_test.go @@ -181,6 +181,8 @@ func TestBuilder_CurrentSchemaBreakWireShape(t *testing.T) { Version: "13", Engine: "aurora-postgresql", Source: "endoflife-date-api", + DataSource: types.LifecycleDataSourceLocalOverride, + UnknownCause: types.LifecycleUnknownCauseCycleNotFound, IsSupported: true, IsDeprecated: true, IsExtendedSupport: true, @@ -235,5 +237,7 @@ func TestBuilder_CurrentSchemaBreakWireShape(t *testing.T) { assert.Equal(t, "13", eol["version"]) assert.Equal(t, "aurora-postgresql", eol["engine"]) assert.Equal(t, "endoflife-date-api", eol["source"]) + assert.Equal(t, "cycle_not_found", eol["unknown_cause"]) + assert.Equal(t, "local_override", eol["data_source"]) assert.Equal(t, true, eol["is_extended_support"]) } diff --git a/pkg/telemetry/metrics.go b/pkg/telemetry/metrics.go index 16723d6..f78c5ca 100644 --- a/pkg/telemetry/metrics.go +++ b/pkg/telemetry/metrics.go @@ -60,6 +60,16 @@ var ( Help: "Latest Version Guard detection compliance ratio by resource type.", }, []string{"resource_type"}) + detectionUnknownResources = prometheus.NewGaugeVec(prometheus.GaugeOpts{ + Name: "version_guard_detection_unknown_resources", + Help: "Latest Version Guard UNKNOWN resource counts by resource type and cause.", + }, []string{"resource_type", "cause"}) + + detectionLifecycleResources = prometheus.NewGaugeVec(prometheus.GaugeOpts{ + Name: "version_guard_detection_lifecycle_resources", + Help: "Latest Version Guard detection resource counts by resource type and lifecycle data source.", + }, []string{"resource_type", "source"}) + detectionRunTotal = prometheus.NewCounterVec(prometheus.CounterOpts{ Name: "version_guard_detection_run_total", Help: "Total Version Guard detection workflow results by resource type.", @@ -117,6 +127,8 @@ func Register(registry *prometheus.Registry) error { scanLastTriggerTimestamp, detectionResources, detectionComplianceRatio, + detectionUnknownResources, + detectionLifecycleResources, detectionRunTotal, detectionDuration, detectionLastRunTimestamp, @@ -174,6 +186,48 @@ func RecordDetectionSummary(resourceType types.ResourceType, summary *types.Scan detectionComplianceRatio.WithLabelValues(resourceTypeLabel).Set(ratio) } +// RecordDetectionBreakdown records bounded lifecycle attribution and first +// clears every allowed series for the resource type to prevent stale values. +func RecordDetectionBreakdown( + resourceType types.ResourceType, + unknownCounts map[types.LifecycleUnknownCause]int, + sourceCounts map[types.LifecycleDataSource]int, +) { + resourceTypeLabel := normalizeLabel(string(resourceType), "unknown") + knownCauses := make(map[types.LifecycleUnknownCause]struct{}) + for _, cause := range types.KnownLifecycleUnknownCauses() { + knownCauses[cause] = struct{}{} + detectionUnknownResources.WithLabelValues(resourceTypeLabel, string(cause)).Set(0) + } + knownSources := make(map[types.LifecycleDataSource]struct{}) + for _, source := range types.KnownLifecycleDataSources() { + knownSources[source] = struct{}{} + detectionLifecycleResources.WithLabelValues(resourceTypeLabel, string(source)).Set(0) + } + + normalizedCauses := make(map[types.LifecycleUnknownCause]int) + for cause, count := range unknownCounts { + if _, ok := knownCauses[cause]; !ok { + cause = types.LifecycleUnknownCauseUnattributed + } + normalizedCauses[cause] += count + } + for cause, count := range normalizedCauses { + detectionUnknownResources.WithLabelValues(resourceTypeLabel, string(cause)).Set(float64(count)) + } + + normalizedSources := make(map[types.LifecycleDataSource]int) + for source, count := range sourceCounts { + if _, ok := knownSources[source]; !ok { + source = types.LifecycleDataSourceUnknown + } + normalizedSources[source] += count + } + for source, count := range normalizedSources { + detectionLifecycleResources.WithLabelValues(resourceTypeLabel, string(source)).Set(float64(count)) + } +} + // RecordDetectionRun records a detection child workflow result. func RecordDetectionRun(resourceType types.ResourceType, result string) { RecordDetectionRunWithDuration(resourceType, result, 0) @@ -296,6 +350,8 @@ func ResetForTest() { scanLastTriggerTimestamp.Reset() detectionResources.Reset() detectionComplianceRatio.Reset() + detectionUnknownResources.Reset() + detectionLifecycleResources.Reset() detectionRunTotal.Reset() detectionDuration.Reset() detectionLastRunTimestamp.Reset() diff --git a/pkg/telemetry/metrics_test.go b/pkg/telemetry/metrics_test.go index 7190558..1131da9 100644 --- a/pkg/telemetry/metrics_test.go +++ b/pkg/telemetry/metrics_test.go @@ -70,6 +70,64 @@ version_guard_detection_compliance_ratio{resource_type="aurora-mysql"} 0.5 require.Equal(t, 5, testutil.CollectAndCount(detectionResources)) } +func TestRecordDetectionBreakdown(t *testing.T) { + ResetForTest() + RecordDetectionBreakdown( + "aurora-mysql", + map[types.LifecycleUnknownCause]int{types.LifecycleUnknownCauseCycleNotFound: 2}, + map[types.LifecycleDataSource]int{types.LifecycleDataSourceLocalOverride: 3}, + ) + + expectedUnknown := ` +# HELP version_guard_detection_unknown_resources Latest Version Guard UNKNOWN resource counts by resource type and cause. +# TYPE version_guard_detection_unknown_resources gauge +version_guard_detection_unknown_resources{cause="cycle_not_found",resource_type="aurora-mysql"} 2 +version_guard_detection_unknown_resources{cause="empty_inventory_version",resource_type="aurora-mysql"} 0 +version_guard_detection_unknown_resources{cause="indeterminate_lifecycle",resource_type="aurora-mysql"} 0 +version_guard_detection_unknown_resources{cause="lifecycle_mismatch",resource_type="aurora-mysql"} 0 +version_guard_detection_unknown_resources{cause="malformed_cycle",resource_type="aurora-mysql"} 0 +version_guard_detection_unknown_resources{cause="product_not_found",resource_type="aurora-mysql"} 0 +version_guard_detection_unknown_resources{cause="source_error",resource_type="aurora-mysql"} 0 +version_guard_detection_unknown_resources{cause="unattributed",resource_type="aurora-mysql"} 0 +` + expectedSources := ` +# HELP version_guard_detection_lifecycle_resources Latest Version Guard detection resource counts by resource type and lifecycle data source. +# TYPE version_guard_detection_lifecycle_resources gauge +version_guard_detection_lifecycle_resources{resource_type="aurora-mysql",source="endoflife_date"} 0 +version_guard_detection_lifecycle_resources{resource_type="aurora-mysql",source="local_override"} 3 +version_guard_detection_lifecycle_resources{resource_type="aurora-mysql",source="unknown"} 0 +` + require.NoError(t, testutil.CollectAndCompare(detectionUnknownResources, strings.NewReader(expectedUnknown))) + require.NoError(t, testutil.CollectAndCompare(detectionLifecycleResources, strings.NewReader(expectedSources))) +} + +func TestRecordDetectionBreakdownClearsStaleSeries(t *testing.T) { + ResetForTest() + RecordDetectionBreakdown("lambda", map[types.LifecycleUnknownCause]int{ + types.LifecycleUnknownCauseSourceError: 4, + }, nil) + RecordDetectionBreakdown("lambda", nil, nil) + + require.Equal(t, float64(0), testutil.ToFloat64( + detectionUnknownResources.WithLabelValues("lambda", "source_error"), + )) +} + +func TestRecordDetectionBreakdownNormalizesInvalidValues(t *testing.T) { + ResetForTest() + RecordDetectionBreakdown(" ", map[types.LifecycleUnknownCause]int{"": 2, "new-cause": 3}, + map[types.LifecycleDataSource]int{"": 4, "new-source": 5}) + + require.Equal(t, float64(5), testutil.ToFloat64( + detectionUnknownResources.WithLabelValues("unknown", "unattributed"), + )) + require.Equal(t, float64(9), testutil.ToFloat64( + detectionLifecycleResources.WithLabelValues("unknown", "unknown"), + )) + require.Equal(t, len(types.KnownLifecycleUnknownCauses()), testutil.CollectAndCount(detectionUnknownResources)) + require.Equal(t, len(types.KnownLifecycleDataSources()), testutil.CollectAndCount(detectionLifecycleResources)) +} + func TestRecordDetectionRun(t *testing.T) { ResetForTest() RecordDetectionRunWithDuration("eks", ResultFailure, 2*time.Second) diff --git a/pkg/workflow/detection/activities.go b/pkg/workflow/detection/activities.go index d3c6a7f..fe01d96 100644 --- a/pkg/workflow/detection/activities.go +++ b/pkg/workflow/detection/activities.go @@ -3,6 +3,7 @@ package detection import ( "context" "fmt" + "strings" "sync" "go.temporal.io/sdk/activity" @@ -207,12 +208,26 @@ func (a *Activities) FetchEOLData(ctx context.Context, input FetchEOLInput) (*EO continue } seen[key] = true + if strings.TrimSpace(resource.CurrentVersion) == "" { + lifecycles[key] = &types.VersionLifecycle{ + Engine: resource.Engine, + DataSource: types.LifecycleDataSourceUnknown, + UnknownCause: types.LifecycleUnknownCauseEmptyInventoryVersion, + } + continue + } lifecycle, err := provider.GetVersionLifecycle(ctx, resource.Engine, resource.CurrentVersion) if err != nil { logger.Warn("Failed to get lifecycle", "engine", resource.Engine, "version", resource.CurrentVersion, "error", err) - // Continue with other versions - continue + if lifecycle == nil { + lifecycle = &types.VersionLifecycle{ + Engine: resource.Engine, + Source: provider.Name(), + DataSource: types.LifecycleDataSourceUnknown, + UnknownCause: types.LifecycleUnknownCauseSourceError, + } + } } lifecycles[key] = lifecycle @@ -249,10 +264,13 @@ func (a *Activities) DetectDrift(ctx context.Context, input DetectInput) (*Detec } } - // Classify using policy - status := a.Policy.Classify(resource, lifecycle) - message := a.Policy.GetMessage(resource, lifecycle, status) - lifecycleDetails := types.LifecycleDetailsFromVersionLifecycle(lifecycle) + // Classify and annotate a copy so provider and cache-owned lifecycle + // pointers remain raw and reusable by other resources. + annotated := *lifecycle + status := a.Policy.Classify(resource, &annotated) + annotated.UnknownCause = policy.UnknownCause(resource, &annotated, status) + message := a.Policy.GetMessage(resource, &annotated, status) + lifecycleDetails := types.LifecycleDetailsFromVersionLifecycle(&annotated) // Create finding. Name, account, and region (when configured) are // part of resource.Extra and propagate through verbatim. @@ -265,7 +283,7 @@ func (a *Activities) DetectDrift(ctx context.Context, input DetectInput) (*Detec Engine: resource.Engine, Status: status, Message: message, - EOLDate: lifecycle.EOLDate, + EOLDate: annotated.EOLDate, Tags: resource.Tags, Extra: resource.Extra, EOL: lifecycleDetails, @@ -339,8 +357,11 @@ func (a *Activities) EmitMetrics(ctx context.Context, input MetricsInput) (*Metr summary := &types.ScanSummary{ TotalResources: len(findings), } + unknownCounts := make(map[types.LifecycleUnknownCause]int) + sourceCounts := make(map[types.LifecycleDataSource]int) for _, f := range findings { + sourceCounts[f.EOL.DataSource]++ switch f.Status { case types.StatusRed: summary.RedCount++ @@ -350,6 +371,7 @@ func (a *Activities) EmitMetrics(ctx context.Context, input MetricsInput) (*Metr summary.GreenCount++ case types.StatusUnknown: summary.UnknownCount++ + unknownCounts[f.EOL.UnknownCause]++ } } @@ -365,6 +387,7 @@ func (a *Activities) EmitMetrics(ctx context.Context, input MetricsInput) (*Metr "compliance", summary.CompliancePercentage) telemetry.RecordDetectionSummary(input.ResourceType, summary) + telemetry.RecordDetectionBreakdown(input.ResourceType, unknownCounts, sourceCounts) if input.FindingsBatchID != "" { a.resourceCache.Delete(input.FindingsBatchID) diff --git a/pkg/workflow/detection/activities_test.go b/pkg/workflow/detection/activities_test.go index d85b4db..7cf200d 100644 --- a/pkg/workflow/detection/activities_test.go +++ b/pkg/workflow/detection/activities_test.go @@ -2,6 +2,7 @@ package detection import ( "context" + "errors" "testing" "time" @@ -19,6 +20,24 @@ import ( "github.com/block/Version-Guard/pkg/types" ) +type countingEOLProvider struct { + lifecycle *types.VersionLifecycle + err error + calls int +} + +func (p *countingEOLProvider) GetVersionLifecycle(context.Context, string, string) (*types.VersionLifecycle, error) { + p.calls++ + return p.lifecycle, p.err +} + +func (p *countingEOLProvider) ListAllVersions(context.Context, string) ([]*types.VersionLifecycle, error) { + return nil, nil +} + +func (p *countingEOLProvider) Name() string { return "counting-provider" } +func (p *countingEOLProvider) Engines() []string { return nil } + // newTestActivities creates an Activities instance with mock dependencies. func newTestActivities(resources []*types.Resource, eolVersions map[string]*types.VersionLifecycle) *Activities { mockSource := &invmock.InventorySource{Resources: resources} @@ -218,6 +237,57 @@ func TestFetchEOLData_DeduplicatesVersions(t *testing.T) { assert.Len(t, eol.VersionLifecycles, 1) } +func TestFetchEOLData_EmptyVersionDoesNotCallProvider(t *testing.T) { + provider := &countingEOLProvider{} + act := NewActivities(nil, map[types.ResourceType]eol.Provider{ + types.ResourceTypeAurora: provider, + }, policy.NewDefaultPolicy(), memory.NewStore()) + env := newActivityEnv() + env.RegisterActivity(act.FetchEOLData) + + result, err := env.ExecuteActivity(act.FetchEOLData, FetchEOLInput{ + ResourceType: types.ResourceTypeAurora, + Resources: []*types.Resource{{ + Engine: "aurora-mysql", CurrentVersion: " ", Type: types.ResourceTypeAurora, + }}, + }) + require.NoError(t, err) + + var output EOLResult + require.NoError(t, result.Get(&output)) + require.Equal(t, 0, provider.calls) + lifecycle := output.VersionLifecycles["aurora-mysql: "] + require.NotNil(t, lifecycle) + assert.Equal(t, types.LifecycleUnknownCauseEmptyInventoryVersion, lifecycle.UnknownCause) + assert.Equal(t, types.LifecycleDataSourceUnknown, lifecycle.DataSource) +} + +func TestFetchEOLData_PreservesDiagnosticLifecycleOnError(t *testing.T) { + diagnostic := &types.VersionLifecycle{ + Engine: "aurora-mysql", Source: "endoflife-date-api", + DataSource: types.LifecycleDataSourceEndOfLifeDate, + UnknownCause: types.LifecycleUnknownCauseSourceError, + } + provider := &countingEOLProvider{lifecycle: diagnostic, err: errors.New("upstream unavailable")} + act := NewActivities(nil, map[types.ResourceType]eol.Provider{ + types.ResourceTypeAurora: provider, + }, policy.NewDefaultPolicy(), memory.NewStore()) + env := newActivityEnv() + env.RegisterActivity(act.FetchEOLData) + + result, err := env.ExecuteActivity(act.FetchEOLData, FetchEOLInput{ + ResourceType: types.ResourceTypeAurora, + Resources: []*types.Resource{{ + Engine: "aurora-mysql", CurrentVersion: "8.0.35", Type: types.ResourceTypeAurora, + }}, + }) + require.NoError(t, err) + + var output EOLResult + require.NoError(t, result.Get(&output)) + assert.Equal(t, diagnostic, output.VersionLifecycles["aurora-mysql:8.0.35"]) +} + // --- DetectDrift tests --- func TestDetectDrift_FromCache_CleansUpAndStoresFindings(t *testing.T) { @@ -354,6 +424,8 @@ func TestDetectDrift_PropagatesLifecycleDetails(t *testing.T) { Version: "5.7", Engine: "mysql", Source: "endoflife-date-api", + DataSource: types.LifecycleDataSourceLocalOverride, + UnknownCause: types.LifecycleUnknownCauseCycleNotFound, DeprecationDate: &standardSupportEnd, ExtendedSupportEnd: &extendedSupportEnd, EOLDate: &extendedSupportEnd, @@ -382,6 +454,8 @@ func TestDetectDrift_PropagatesLifecycleDetails(t *testing.T) { assert.Equal(t, "5.7", details.Version) assert.Equal(t, "mysql", details.Engine) assert.Equal(t, "endoflife-date-api", details.Source) + assert.Equal(t, types.LifecycleDataSourceLocalOverride, details.DataSource) + assert.Empty(t, details.UnknownCause, "known statuses clear unknown attribution") require.NotNil(t, details.StandardSupportEnd) require.NotNil(t, details.ExtendedSupportEnd) require.NotNil(t, details.EOLDate) @@ -396,6 +470,31 @@ func TestDetectDrift_PropagatesLifecycleDetails(t *testing.T) { assert.True(t, details.IsExtendedSupport) } +func TestDetectDrift_AnnotatesLifecycleCopy(t *testing.T) { + resource := &types.Resource{ + ID: "r1", Engine: "aurora-mysql", CurrentVersion: "8.0.35", Type: types.ResourceTypeAurora, + } + lifecycle := &types.VersionLifecycle{ + Version: "5.7", Engine: "aurora-mysql", DataSource: types.LifecycleDataSourceEndOfLifeDate, + } + act := newTestActivities([]*types.Resource{resource}, nil) + env := newActivityEnv() + env.RegisterActivity(act.DetectDrift) + + result, err := env.ExecuteActivity(act.DetectDrift, DetectInput{ + Resources: []*types.Resource{resource}, + VersionLifecycles: map[string]*types.VersionLifecycle{"aurora-mysql:8.0.35": lifecycle}, + }) + require.NoError(t, err) + + var output DetectResult + require.NoError(t, result.Get(&output)) + require.Len(t, output.Findings, 1) + assert.Equal(t, types.LifecycleUnknownCauseLifecycleMismatch, output.Findings[0].EOL.UnknownCause) + assert.Equal(t, types.LifecycleDataSourceEndOfLifeDate, output.Findings[0].EOL.DataSource) + assert.Empty(t, lifecycle.UnknownCause, "provider lifecycle must not be mutated") +} + func TestDetectDrift_UnknownVersion(t *testing.T) { resources := []*types.Resource{ {ID: "r1", Engine: "aurora-mysql", CurrentVersion: "99.0.0", Type: types.ResourceTypeAurora}, From a945315d39265fe6ab553d905179eb240de488dd Mon Sep 17 00:00:00 2001 From: Youssef <53960652+yti93@users.noreply.github.com> Date: Wed, 5 Aug 2026 14:08:41 -0700 Subject: [PATCH 08/18] test: align attribution fixture with unknown status Amp-Thread-ID: https://ampcode.com/threads/T-019fd393-8713-704d-bd35-038f82f8dd95 Co-authored-by: Amp --- pkg/snapshot/builder_test.go | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/pkg/snapshot/builder_test.go b/pkg/snapshot/builder_test.go index 0cf4500..d673f22 100644 --- a/pkg/snapshot/builder_test.go +++ b/pkg/snapshot/builder_test.go @@ -166,7 +166,7 @@ func TestBuilder_CurrentSchemaBreakWireShape(t *testing.T) { CloudProvider: types.CloudProviderAWS, Service: "svc", Engine: "aurora-postgresql", - Status: types.StatusGreen, + Status: types.StatusUnknown, Extra: map[string]string{ "name": "c1", "account_id": "123456789012", @@ -183,9 +183,9 @@ func TestBuilder_CurrentSchemaBreakWireShape(t *testing.T) { Source: "endoflife-date-api", DataSource: types.LifecycleDataSourceLocalOverride, UnknownCause: types.LifecycleUnknownCauseCycleNotFound, - IsSupported: true, - IsDeprecated: true, - IsExtendedSupport: true, + IsSupported: false, + IsDeprecated: false, + IsExtendedSupport: false, }, }, }). @@ -239,5 +239,5 @@ func TestBuilder_CurrentSchemaBreakWireShape(t *testing.T) { assert.Equal(t, "endoflife-date-api", eol["source"]) assert.Equal(t, "cycle_not_found", eol["unknown_cause"]) assert.Equal(t, "local_override", eol["data_source"]) - assert.Equal(t, true, eol["is_extended_support"]) + assert.Equal(t, false, eol["is_extended_support"]) } From ed456737d4d82c6a87282469fe39edd0616fc1ef Mon Sep 17 00:00:00 2001 From: Youssef <53960652+yti93@users.noreply.github.com> Date: Wed, 5 Aug 2026 14:12:12 -0700 Subject: [PATCH 09/18] feat: govern local lifecycle overrides Amp-Thread-ID: https://ampcode.com/threads/T-019fd393-8713-704d-bd35-038f82f8dd95 Co-authored-by: Amp --- deploy/endoflife-override/README.md | 23 ++- deploy/endoflife-override/manifest.go | 210 +++++++++++++++++++++ deploy/endoflife-override/manifest.json | 23 +++ deploy/endoflife-override/manifest_test.go | 94 +++++++++ deploy/endoflife-override/nginx.conf | 3 + deploy/endoflife-override/nginx_test.go | 33 ++++ 6 files changed, 380 insertions(+), 6 deletions(-) create mode 100644 deploy/endoflife-override/manifest.go create mode 100644 deploy/endoflife-override/manifest.json create mode 100644 deploy/endoflife-override/manifest_test.go create mode 100644 deploy/endoflife-override/nginx_test.go diff --git a/deploy/endoflife-override/README.md b/deploy/endoflife-override/README.md index 2e59e1b..d57c2b6 100644 --- a/deploy/endoflife-override/README.md +++ b/deploy/endoflife-override/README.md @@ -29,18 +29,25 @@ curl -s https://deploy-preview-9534--endoflife-date.netlify.app/api/amazon-auror | python3 -m json.tool > api/amazon-aurora-mysql.json ``` -2. Restart docker-compose — no rebuild needed: +2. Add or update the corresponding entry in `manifest.json`. The source URL, +owner, reason, review date, and review due date are required. Reviews may be +scheduled at most 30 days apart. + +3. Run the override package tests, then restart docker-compose — no rebuild needed: ```bash +go test ./deploy/endoflife-override docker compose restart endoflife ``` -## Current Overrides +The validator checks the manifest schema and metadata, one-to-one coverage of +manifest entries and `api/*.json` files, and lifecycle data using the same +validation as the runtime provider. Malformed metadata, missing files, invalid +URLs, and invalid lifecycle data fail validation. An expired review due date is +warn-only so CI continues to run while making the overdue review visible. -| File | Reason | Upstream PR | -|------|--------|-------------| -| `amazon-aurora-mysql.json` | Product not yet on endoflife.date | [#9534](https://github.com/endoflife-date/endoflife.date/pull/9534) | -| `amazon-opensearch.json` | Missing cycles 3.3 and 3.5 | [#9919](https://github.com/endoflife-date/endoflife.date/pull/9919) | +`manifest.json` is the machine-readable source of truth for current overrides. +Update it whenever an override is added, reviewed, or removed. ## Configuration @@ -57,3 +64,7 @@ When `EOL_BASE_URL` is not set, Version Guard connects directly to `https://endo ## Removing Overrides Once an upstream PR is merged, delete the local JSON file. Nginx will then proxy that product to the upstream API automatically. + +Delete its `manifest.json` entry in the same change. Nginx marks local and +upstream responses with authoritative `X-Version-Guard-EOL-Source` headers; +those values flow into snapshot findings and lifecycle source metrics. diff --git a/deploy/endoflife-override/manifest.go b/deploy/endoflife-override/manifest.go new file mode 100644 index 0000000..54e16ef --- /dev/null +++ b/deploy/endoflife-override/manifest.go @@ -0,0 +1,210 @@ +package override + +import ( + "encoding/json" + "fmt" + "io" + "net/url" + "os" + "path/filepath" + "strings" + "time" + + "github.com/block/Version-Guard/pkg/eol/endoflife" +) + +const ( + manifestSchemaVersion = 1 + dateLayout = "2006-01-02" + maximumReviewInterval = 30 * 24 * time.Hour +) + +type manifest struct { + SchemaVersion int `json:"schema_version"` + Overrides []manifestOverride `json:"overrides"` +} + +type manifestOverride struct { + Product string `json:"product"` + Path string `json:"path"` + Reason string `json:"reason"` + Owner string `json:"owner"` + SourceURL string `json:"source_url"` + ReviewedOn string `json:"reviewed_on"` + ReviewDueOn string `json:"review_due_on"` +} + +func validateManifest(root string, now time.Time, warnings io.Writer) error { + m, err := readManifest(filepath.Join(root, "manifest.json")) + if err != nil { + return err + } + if m.SchemaVersion != manifestSchemaVersion { + return fmt.Errorf("schema_version must be %d", manifestSchemaVersion) + } + if warnings == nil { + warnings = io.Discard + } + + products := make(map[string]struct{}, len(m.Overrides)) + paths := make(map[string]struct{}, len(m.Overrides)) + for index := range m.Overrides { + override := &m.Overrides[index] + if err := validateOverride(root, override, now.UTC(), warnings, products, paths); err != nil { + return fmt.Errorf("override %d: %w", index, err) + } + } + + apiFiles, err := filepath.Glob(filepath.Join(root, "api", "*.json")) + if err != nil { + return fmt.Errorf("list API files: %w", err) + } + for _, apiFile := range apiFiles { + relative, err := filepath.Rel(root, apiFile) + if err != nil { + return fmt.Errorf("resolve API file %q: %w", apiFile, err) + } + relative = filepath.ToSlash(relative) + if _, ok := paths[relative]; !ok { + return fmt.Errorf("API file %q has no manifest entry", relative) + } + } + return nil +} + +func readManifest(path string) (*manifest, error) { + file, err := os.Open(path) + if err != nil { + return nil, fmt.Errorf("open manifest: %w", err) + } + defer file.Close() + decoder := json.NewDecoder(file) + decoder.DisallowUnknownFields() + var m manifest + if err := decoder.Decode(&m); err != nil { + return nil, fmt.Errorf("decode manifest: %w", err) + } + if err := ensureJSONEOF(decoder); err != nil { + return nil, fmt.Errorf("decode manifest: %w", err) + } + return &m, nil +} + +func validateOverride(root string, override *manifestOverride, now time.Time, warnings io.Writer, products, paths map[string]struct{}) error { + required := []struct { + name string + value string + }{ + {"product", override.Product}, {"path", override.Path}, {"reason", override.Reason}, + {"owner", override.Owner}, {"source_url", override.SourceURL}, + {"reviewed_on", override.ReviewedOn}, {"review_due_on", override.ReviewDueOn}, + } + for _, field := range required { + if strings.TrimSpace(field.value) == "" { + return fmt.Errorf("%s is required", field.name) + } + } + if _, exists := products[override.Product]; exists { + return fmt.Errorf("duplicate product %q", override.Product) + } + products[override.Product] = struct{}{} + if _, exists := paths[override.Path]; exists { + return fmt.Errorf("duplicate path %q", override.Path) + } + paths[override.Path] = struct{}{} + + if !strings.HasPrefix(override.SourceURL, "https://") { + return fmt.Errorf("source_url must use https") + } + if _, err := parseHTTPSURL(override.SourceURL); err != nil { + return err + } + reviewedOn, err := parseManifestDate("reviewed_on", override.ReviewedOn) + if err != nil { + return err + } + reviewDueOn, err := parseManifestDate("review_due_on", override.ReviewDueOn) + if err != nil { + return err + } + interval := reviewDueOn.Sub(reviewedOn) + if interval < 0 { + return fmt.Errorf("review_due_on is before reviewed_on") + } + if interval > maximumReviewInterval { + return fmt.Errorf("review interval exceeds 30 days") + } + if now.After(reviewDueOn) { + fmt.Fprintf(warnings, "warning: review overdue for %s (due %s)\n", override.Product, override.ReviewDueOn) + } + + cleanPath := filepath.ToSlash(filepath.Clean(override.Path)) + if cleanPath != override.Path || !strings.HasPrefix(cleanPath, "api/") || strings.Contains(cleanPath, "../") { + return fmt.Errorf("path %q must stay under api/", override.Path) + } + fullPath := filepath.Join(root, filepath.FromSlash(cleanPath)) + info, err := os.Stat(fullPath) + if err != nil { + if os.IsNotExist(err) { + return fmt.Errorf("path %q does not exist", override.Path) + } + return fmt.Errorf("stat path %q: %w", override.Path, err) + } + if !info.Mode().IsRegular() { + return fmt.Errorf("path %q is not a regular file", override.Path) + } + return validateAPIFile(fullPath) +} + +func parseHTTPSURL(raw string) (*url.URL, error) { + parsed, err := url.ParseRequestURI(raw) + if err != nil || parsed.Scheme != "https" || parsed.Host == "" { + return nil, fmt.Errorf("source_url must be a valid https URL") + } + return parsed, nil +} + +func parseManifestDate(name, value string) (time.Time, error) { + parsed, err := time.Parse(dateLayout, value) + if err != nil || parsed.Format(dateLayout) != value { + return time.Time{}, fmt.Errorf("%s must use YYYY-MM-DD", name) + } + return parsed, nil +} + +func validateAPIFile(path string) error { + file, err := os.Open(path) + if err != nil { + return fmt.Errorf("open API file %q: %w", path, err) + } + defer file.Close() + decoder := json.NewDecoder(file) + decoder.DisallowUnknownFields() + var cycles []*endoflife.ProductCycle + if err := decoder.Decode(&cycles); err != nil { + return fmt.Errorf("decode API file %q: %w", path, err) + } + if cycles == nil { + return fmt.Errorf("API file %q must contain a top-level array", path) + } + if err := ensureJSONEOF(decoder); err != nil { + return fmt.Errorf("decode API file %q: %w", path, err) + } + for index, cycle := range cycles { + if err := endoflife.ValidateProductCycle(cycle); err != nil { + return fmt.Errorf("API file %q cycle %d: %w", path, index, err) + } + } + return nil +} + +func ensureJSONEOF(decoder *json.Decoder) error { + var extra any + if err := decoder.Decode(&extra); err != io.EOF { + if err == nil { + return fmt.Errorf("multiple JSON values") + } + return err + } + return nil +} diff --git a/deploy/endoflife-override/manifest.json b/deploy/endoflife-override/manifest.json new file mode 100644 index 0000000..0f6d8b5 --- /dev/null +++ b/deploy/endoflife-override/manifest.json @@ -0,0 +1,23 @@ +{ + "schema_version": 1, + "overrides": [ + { + "product": "amazon-aurora-mysql", + "path": "api/amazon-aurora-mysql.json", + "reason": "Product pending upstream inclusion", + "owner": "@block/block-platform", + "source_url": "https://github.com/endoflife-date/endoflife.date/pull/9534", + "reviewed_on": "2026-08-05", + "review_due_on": "2026-09-04" + }, + { + "product": "amazon-opensearch", + "path": "api/amazon-opensearch.json", + "reason": "Required cycles are missing upstream", + "owner": "@block/block-platform", + "source_url": "https://github.com/endoflife-date/endoflife.date/pull/9919", + "reviewed_on": "2026-08-05", + "review_due_on": "2026-09-04" + } + ] +} diff --git a/deploy/endoflife-override/manifest_test.go b/deploy/endoflife-override/manifest_test.go new file mode 100644 index 0000000..8cdc89d --- /dev/null +++ b/deploy/endoflife-override/manifest_test.go @@ -0,0 +1,94 @@ +package override + +import ( + "bytes" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +func TestValidateManifest(t *testing.T) { + tests := []struct { + name string + mutate func(t *testing.T, root string) + wantErr string + wantWarning string + }{ + {name: "valid manifest"}, + {name: "duplicate product", mutate: mutateManifest(func(m *manifest) { m.Overrides = append(m.Overrides, m.Overrides[0]) }), wantErr: "duplicate product"}, + {name: "missing API file entry", mutate: func(t *testing.T, root string) { + require.NoError(t, os.WriteFile(filepath.Join(root, "api", "unlisted.json"), []byte("[]"), 0o600)) + }, wantErr: "has no manifest entry"}, + {name: "entry references missing file", mutate: func(t *testing.T, root string) { + require.NoError(t, os.Remove(filepath.Join(root, "api", "amazon-aurora-mysql.json"))) + }, wantErr: "does not exist"}, + {name: "invalid source URL", mutate: mutateManifest(func(m *manifest) { m.Overrides[0].SourceURL = "http://example.com/source" }), wantErr: "must use https"}, + {name: "malformed source URL", mutate: mutateManifest(func(m *manifest) { m.Overrides[0].SourceURL = "https://[invalid" }), wantErr: "valid https URL"}, + {name: "invalid review date", mutate: mutateManifest(func(m *manifest) { m.Overrides[0].ReviewedOn = "August 5" }), wantErr: "YYYY-MM-DD"}, + {name: "review interval over 30 days", mutate: mutateManifest(func(m *manifest) { m.Overrides[0].ReviewDueOn = "2026-09-05" }), wantErr: "exceeds 30 days"}, + {name: "invalid lifecycle data", mutate: func(t *testing.T, root string) { + require.NoError(t, os.WriteFile(filepath.Join(root, "api", "amazon-aurora-mysql.json"), []byte(`[{"cycle":"3","eol":42}]`), 0o600)) + }, wantErr: "unsupported value type"}, + {name: "API data is not an array", mutate: func(t *testing.T, root string) { + require.NoError(t, os.WriteFile(filepath.Join(root, "api", "amazon-aurora-mysql.json"), []byte(`null`), 0o600)) + }, wantErr: "top-level array"}, + {name: "overdue review warns", mutate: mutateManifest(func(m *manifest) { m.Overrides[0].ReviewDueOn = "2026-08-06" }), wantWarning: "review overdue"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + root := copyRepositoryFixture(t) + if tt.mutate != nil { + tt.mutate(t, root) + } + var warnings bytes.Buffer + err := validateManifest(root, time.Date(2026, 8, 7, 0, 0, 0, 0, time.UTC), &warnings) + if tt.wantErr != "" { + require.ErrorContains(t, err, tt.wantErr) + return + } + require.NoError(t, err) + require.Contains(t, warnings.String(), tt.wantWarning) + }) + } +} + +func TestRepositoryManifest(t *testing.T) { + var warnings bytes.Buffer + require.NoError(t, validateManifest(".", time.Now().UTC(), &warnings)) + if warnings.Len() > 0 { + t.Log(strings.TrimSpace(warnings.String())) + } +} + +func mutateManifest(mutate func(*manifest)) func(*testing.T, string) { + return func(t *testing.T, root string) { + path := filepath.Join(root, "manifest.json") + data, err := os.ReadFile(path) + require.NoError(t, err) + var m manifest + require.NoError(t, json.Unmarshal(data, &m)) + mutate(&m) + data, err = json.Marshal(m) + require.NoError(t, err) + require.NoError(t, os.WriteFile(path, data, 0o600)) + } +} + +func copyRepositoryFixture(t *testing.T) string { + t.Helper() + root := t.TempDir() + for _, path := range []string{"manifest.json", "api/amazon-aurora-mysql.json", "api/amazon-opensearch.json"} { + data, err := os.ReadFile(path) + require.NoError(t, err) + destination := filepath.Join(root, path) + require.NoError(t, os.MkdirAll(filepath.Dir(destination), 0o700)) + require.NoError(t, os.WriteFile(destination, data, 0o600)) + } + return root +} diff --git a/deploy/endoflife-override/nginx.conf b/deploy/endoflife-override/nginx.conf index 4d7a6b6..ab00c93 100644 --- a/deploy/endoflife-override/nginx.conf +++ b/deploy/endoflife-override/nginx.conf @@ -5,6 +5,7 @@ server { location /api/ { root /data; try_files $uri @upstream; + add_header X-Version-Guard-EOL-Source local_override always; } # Proxy to upstream endoflife.date for everything else @@ -13,5 +14,7 @@ server { proxy_set_header Host endoflife.date; proxy_set_header User-Agent "version-guard/1.0"; proxy_ssl_server_name on; + proxy_hide_header X-Version-Guard-EOL-Source; + add_header X-Version-Guard-EOL-Source endoflife_date always; } } diff --git a/deploy/endoflife-override/nginx_test.go b/deploy/endoflife-override/nginx_test.go new file mode 100644 index 0000000..21fd10b --- /dev/null +++ b/deploy/endoflife-override/nginx_test.go @@ -0,0 +1,33 @@ +package override + +import ( + "os" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNginxSourceHeaders(t *testing.T) { + data, err := os.ReadFile("nginx.conf") + require.NoError(t, err) + config := string(data) + + local := locationBlock(t, config, "location /api/ {") + upstream := locationBlock(t, config, "location @upstream {") + assert.Equal(t, 1, strings.Count(local, "add_header X-Version-Guard-EOL-Source local_override always;")) + assert.Equal(t, 1, strings.Count(upstream, "proxy_hide_header X-Version-Guard-EOL-Source;")) + assert.Equal(t, 1, strings.Count(upstream, "add_header X-Version-Guard-EOL-Source endoflife_date always;")) + assert.Equal(t, 2, strings.Count(config, "add_header X-Version-Guard-EOL-Source")) +} + +func locationBlock(t *testing.T, config, start string) string { + t.Helper() + startIndex := strings.Index(config, start) + require.NotEqual(t, -1, startIndex) + remainder := config[startIndex+len(start):] + endIndex := strings.Index(remainder, "\n }") + require.NotEqual(t, -1, endIndex) + return remainder[:endIndex] +} From e03f6d6b46ad5549cd7e1acf1bcc371163a10eb3 Mon Sep 17 00:00:00 2001 From: Youssef <53960652+yti93@users.noreply.github.com> Date: Wed, 5 Aug 2026 14:14:48 -0700 Subject: [PATCH 10/18] fix: tighten lifecycle override governance Amp-Thread-ID: https://ampcode.com/threads/T-019fd393-8713-704d-bd35-038f82f8dd95 Co-authored-by: Amp --- deploy/endoflife-override/manifest.go | 8 ++-- deploy/endoflife-override/manifest_test.go | 43 ++++++++++++++++++++++ 2 files changed, 48 insertions(+), 3 deletions(-) diff --git a/deploy/endoflife-override/manifest.go b/deploy/endoflife-override/manifest.go index 54e16ef..abc7150 100644 --- a/deploy/endoflife-override/manifest.go +++ b/deploy/endoflife-override/manifest.go @@ -134,13 +134,15 @@ func validateOverride(root string, override *manifestOverride, now time.Time, wa if interval > maximumReviewInterval { return fmt.Errorf("review interval exceeds 30 days") } - if now.After(reviewDueOn) { + if !now.Before(reviewDueOn.AddDate(0, 0, 1)) { fmt.Fprintf(warnings, "warning: review overdue for %s (due %s)\n", override.Product, override.ReviewDueOn) } cleanPath := filepath.ToSlash(filepath.Clean(override.Path)) - if cleanPath != override.Path || !strings.HasPrefix(cleanPath, "api/") || strings.Contains(cleanPath, "../") { - return fmt.Errorf("path %q must stay under api/", override.Path) + filename := strings.TrimPrefix(cleanPath, "api/") + if cleanPath != override.Path || filename == cleanPath || filename == "" || + strings.ContainsAny(filename, `/\`) || filepath.Ext(filename) != ".json" { + return fmt.Errorf("path %q must be a direct api/.json path", override.Path) } fullPath := filepath.Join(root, filepath.FromSlash(cleanPath)) info, err := os.Stat(fullPath) diff --git a/deploy/endoflife-override/manifest_test.go b/deploy/endoflife-override/manifest_test.go index 8cdc89d..e8d572e 100644 --- a/deploy/endoflife-override/manifest_test.go +++ b/deploy/endoflife-override/manifest_test.go @@ -20,7 +20,10 @@ func TestValidateManifest(t *testing.T) { wantWarning string }{ {name: "valid manifest"}, + {name: "unsupported schema version", mutate: mutateManifest(func(m *manifest) { m.SchemaVersion = 2 }), wantErr: "schema_version must be 1"}, + {name: "missing required field", mutate: mutateManifest(func(m *manifest) { m.Overrides[0].Owner = "" }), wantErr: "owner is required"}, {name: "duplicate product", mutate: mutateManifest(func(m *manifest) { m.Overrides = append(m.Overrides, m.Overrides[0]) }), wantErr: "duplicate product"}, + {name: "duplicate path", mutate: mutateManifest(func(m *manifest) { m.Overrides[1].Path = m.Overrides[0].Path }), wantErr: "duplicate path"}, {name: "missing API file entry", mutate: func(t *testing.T, root string) { require.NoError(t, os.WriteFile(filepath.Join(root, "api", "unlisted.json"), []byte("[]"), 0o600)) }, wantErr: "has no manifest entry"}, @@ -30,7 +33,28 @@ func TestValidateManifest(t *testing.T) { {name: "invalid source URL", mutate: mutateManifest(func(m *manifest) { m.Overrides[0].SourceURL = "http://example.com/source" }), wantErr: "must use https"}, {name: "malformed source URL", mutate: mutateManifest(func(m *manifest) { m.Overrides[0].SourceURL = "https://[invalid" }), wantErr: "valid https URL"}, {name: "invalid review date", mutate: mutateManifest(func(m *manifest) { m.Overrides[0].ReviewedOn = "August 5" }), wantErr: "YYYY-MM-DD"}, + {name: "review due before reviewed", mutate: mutateManifest(func(m *manifest) { m.Overrides[0].ReviewDueOn = "2026-08-04" }), wantErr: "before reviewed_on"}, {name: "review interval over 30 days", mutate: mutateManifest(func(m *manifest) { m.Overrides[0].ReviewDueOn = "2026-09-05" }), wantErr: "exceeds 30 days"}, + {name: "path escapes API directory", mutate: mutateManifest(func(m *manifest) { m.Overrides[0].Path = "api/../manifest.json" }), wantErr: "direct api/"}, + {name: "nested API path", mutate: func(t *testing.T, root string) { + nested := filepath.Join(root, "api", "nested", "amazon-aurora-mysql.json") + require.NoError(t, os.MkdirAll(filepath.Dir(nested), 0o700)) + require.NoError(t, os.Rename(filepath.Join(root, "api", "amazon-aurora-mysql.json"), nested)) + mutateManifest(func(m *manifest) { m.Overrides[0].Path = "api/nested/amazon-aurora-mysql.json" })(t, root) + }, wantErr: "direct api/"}, + {name: "non-JSON API path", mutate: func(t *testing.T, root string) { + nonJSON := filepath.Join(root, "api", "amazon-aurora-mysql.txt") + require.NoError(t, os.Rename(filepath.Join(root, "api", "amazon-aurora-mysql.json"), nonJSON)) + mutateManifest(func(m *manifest) { m.Overrides[0].Path = "api/amazon-aurora-mysql.txt" })(t, root) + }, wantErr: "direct api/"}, + {name: "trailing manifest JSON", mutate: func(t *testing.T, root string) { + path := filepath.Join(root, "manifest.json") + file, err := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0) + require.NoError(t, err) + _, err = file.WriteString("\n{}") + require.NoError(t, err) + require.NoError(t, file.Close()) + }, wantErr: "multiple JSON values"}, {name: "invalid lifecycle data", mutate: func(t *testing.T, root string) { require.NoError(t, os.WriteFile(filepath.Join(root, "api", "amazon-aurora-mysql.json"), []byte(`[{"cycle":"3","eol":42}]`), 0o600)) }, wantErr: "unsupported value type"}, @@ -58,6 +82,25 @@ func TestValidateManifest(t *testing.T) { } } +func TestValidateManifestReviewDueDateBoundary(t *testing.T) { + tests := []struct { + name string + now time.Time + wantWarning string + }{ + {name: "due date remains valid for full UTC day", now: time.Date(2026, 9, 4, 23, 59, 59, 0, time.UTC)}, + {name: "following UTC day warns", now: time.Date(2026, 9, 5, 0, 0, 0, 0, time.UTC), wantWarning: "review overdue"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var warnings bytes.Buffer + require.NoError(t, validateManifest(copyRepositoryFixture(t), tt.now, &warnings)) + require.Equal(t, tt.wantWarning != "", strings.Contains(warnings.String(), "review overdue")) + }) + } +} + func TestRepositoryManifest(t *testing.T) { var warnings bytes.Buffer require.NoError(t, validateManifest(".", time.Now().UTC(), &warnings)) From 7fd3beb2b9be50c4271db27f5620693adc01ae72 Mon Sep 17 00:00:00 2001 From: Youssef <53960652+yti93@users.noreply.github.com> Date: Wed, 5 Aug 2026 14:17:15 -0700 Subject: [PATCH 11/18] fix: close manifest validation gaps Amp-Thread-ID: https://ampcode.com/threads/T-019fd393-8713-704d-bd35-038f82f8dd95 Co-authored-by: Amp --- deploy/endoflife-override/manifest.go | 8 ++++++- deploy/endoflife-override/manifest_test.go | 27 +++++++++++++++++++++- 2 files changed, 33 insertions(+), 2 deletions(-) diff --git a/deploy/endoflife-override/manifest.go b/deploy/endoflife-override/manifest.go index abc7150..5ccd13d 100644 --- a/deploy/endoflife-override/manifest.go +++ b/deploy/endoflife-override/manifest.go @@ -144,14 +144,20 @@ func validateOverride(root string, override *manifestOverride, now time.Time, wa strings.ContainsAny(filename, `/\`) || filepath.Ext(filename) != ".json" { return fmt.Errorf("path %q must be a direct api/.json path", override.Path) } + if strings.TrimSuffix(filename, ".json") != override.Product { + return fmt.Errorf("path filename must match product %q", override.Product) + } fullPath := filepath.Join(root, filepath.FromSlash(cleanPath)) - info, err := os.Stat(fullPath) + info, err := os.Lstat(fullPath) if err != nil { if os.IsNotExist(err) { return fmt.Errorf("path %q does not exist", override.Path) } return fmt.Errorf("stat path %q: %w", override.Path, err) } + if info.Mode()&os.ModeSymlink != 0 { + return fmt.Errorf("path %q must not be a symlink", override.Path) + } if !info.Mode().IsRegular() { return fmt.Errorf("path %q is not a regular file", override.Path) } diff --git a/deploy/endoflife-override/manifest_test.go b/deploy/endoflife-override/manifest_test.go index e8d572e..7370954 100644 --- a/deploy/endoflife-override/manifest_test.go +++ b/deploy/endoflife-override/manifest_test.go @@ -21,9 +21,16 @@ func TestValidateManifest(t *testing.T) { }{ {name: "valid manifest"}, {name: "unsupported schema version", mutate: mutateManifest(func(m *manifest) { m.SchemaVersion = 2 }), wantErr: "schema_version must be 1"}, - {name: "missing required field", mutate: mutateManifest(func(m *manifest) { m.Overrides[0].Owner = "" }), wantErr: "owner is required"}, + {name: "missing product", mutate: mutateManifest(func(m *manifest) { m.Overrides[0].Product = "" }), wantErr: "product is required"}, + {name: "missing path", mutate: mutateManifest(func(m *manifest) { m.Overrides[0].Path = "" }), wantErr: "path is required"}, + {name: "missing reason", mutate: mutateManifest(func(m *manifest) { m.Overrides[0].Reason = "" }), wantErr: "reason is required"}, + {name: "missing owner", mutate: mutateManifest(func(m *manifest) { m.Overrides[0].Owner = "" }), wantErr: "owner is required"}, + {name: "missing source URL", mutate: mutateManifest(func(m *manifest) { m.Overrides[0].SourceURL = "" }), wantErr: "source_url is required"}, + {name: "missing reviewed date", mutate: mutateManifest(func(m *manifest) { m.Overrides[0].ReviewedOn = "" }), wantErr: "reviewed_on is required"}, + {name: "missing review due date", mutate: mutateManifest(func(m *manifest) { m.Overrides[0].ReviewDueOn = "" }), wantErr: "review_due_on is required"}, {name: "duplicate product", mutate: mutateManifest(func(m *manifest) { m.Overrides = append(m.Overrides, m.Overrides[0]) }), wantErr: "duplicate product"}, {name: "duplicate path", mutate: mutateManifest(func(m *manifest) { m.Overrides[1].Path = m.Overrides[0].Path }), wantErr: "duplicate path"}, + {name: "product does not match API filename", mutate: mutateManifest(func(m *manifest) { m.Overrides[0].Product = "different-product" }), wantErr: "must match product"}, {name: "missing API file entry", mutate: func(t *testing.T, root string) { require.NoError(t, os.WriteFile(filepath.Join(root, "api", "unlisted.json"), []byte("[]"), 0o600)) }, wantErr: "has no manifest entry"}, @@ -47,6 +54,17 @@ func TestValidateManifest(t *testing.T) { require.NoError(t, os.Rename(filepath.Join(root, "api", "amazon-aurora-mysql.json"), nonJSON)) mutateManifest(func(m *manifest) { m.Overrides[0].Path = "api/amazon-aurora-mysql.txt" })(t, root) }, wantErr: "direct api/"}, + {name: "symlink API path", mutate: func(t *testing.T, root string) { + target := filepath.Join(t.TempDir(), "outside.json") + data, err := os.ReadFile(filepath.Join(root, "api", "amazon-aurora-mysql.json")) + require.NoError(t, err) + require.NoError(t, os.WriteFile(target, data, 0o600)) + link := filepath.Join(root, "api", "amazon-aurora-mysql.json") + require.NoError(t, os.Remove(link)) + if err := os.Symlink(target, link); err != nil { + t.Skipf("platform cannot create symlinks: %v", err) + } + }, wantErr: "must not be a symlink"}, {name: "trailing manifest JSON", mutate: func(t *testing.T, root string) { path := filepath.Join(root, "manifest.json") file, err := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0) @@ -61,6 +79,13 @@ func TestValidateManifest(t *testing.T) { {name: "API data is not an array", mutate: func(t *testing.T, root string) { require.NoError(t, os.WriteFile(filepath.Join(root, "api", "amazon-aurora-mysql.json"), []byte(`null`), 0o600)) }, wantErr: "top-level array"}, + {name: "API data has trailing JSON value", mutate: func(t *testing.T, root string) { + path := filepath.Join(root, "api", "amazon-aurora-mysql.json") + data, err := os.ReadFile(path) + require.NoError(t, err) + data = append(data, []byte("\n{}")...) + require.NoError(t, os.WriteFile(path, data, 0o600)) + }, wantErr: "multiple JSON values"}, {name: "overdue review warns", mutate: mutateManifest(func(m *manifest) { m.Overrides[0].ReviewDueOn = "2026-08-06" }), wantWarning: "review overdue"}, } From 27475a0c3334de978f28755650452af183b28c89 Mon Sep 17 00:00:00 2001 From: Youssef <53960652+yti93@users.noreply.github.com> Date: Wed, 5 Aug 2026 14:19:30 -0700 Subject: [PATCH 12/18] fix: reject symlinked API directory Amp-Thread-ID: https://ampcode.com/threads/T-019fd393-8713-704d-bd35-038f82f8dd95 Co-authored-by: Amp --- deploy/endoflife-override/manifest.go | 14 +++++++++++++- deploy/endoflife-override/manifest_test.go | 7 +++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/deploy/endoflife-override/manifest.go b/deploy/endoflife-override/manifest.go index 5ccd13d..375d909 100644 --- a/deploy/endoflife-override/manifest.go +++ b/deploy/endoflife-override/manifest.go @@ -46,6 +46,18 @@ func validateManifest(root string, now time.Time, warnings io.Writer) error { warnings = io.Discard } + apiDirectory := filepath.Join(root, "api") + apiInfo, err := os.Lstat(apiDirectory) + if err != nil { + return fmt.Errorf("stat API directory: %w", err) + } + if apiInfo.Mode()&os.ModeSymlink != 0 { + return fmt.Errorf("API directory must not be a symlink") + } + if !apiInfo.IsDir() { + return fmt.Errorf("API directory is not a directory") + } + products := make(map[string]struct{}, len(m.Overrides)) paths := make(map[string]struct{}, len(m.Overrides)) for index := range m.Overrides { @@ -55,7 +67,7 @@ func validateManifest(root string, now time.Time, warnings io.Writer) error { } } - apiFiles, err := filepath.Glob(filepath.Join(root, "api", "*.json")) + apiFiles, err := filepath.Glob(filepath.Join(apiDirectory, "*.json")) if err != nil { return fmt.Errorf("list API files: %w", err) } diff --git a/deploy/endoflife-override/manifest_test.go b/deploy/endoflife-override/manifest_test.go index 7370954..da1ae9b 100644 --- a/deploy/endoflife-override/manifest_test.go +++ b/deploy/endoflife-override/manifest_test.go @@ -65,6 +65,13 @@ func TestValidateManifest(t *testing.T) { t.Skipf("platform cannot create symlinks: %v", err) } }, wantErr: "must not be a symlink"}, + {name: "symlink API directory", mutate: func(t *testing.T, root string) { + externalAPI := filepath.Join(t.TempDir(), "api") + require.NoError(t, os.Rename(filepath.Join(root, "api"), externalAPI)) + if err := os.Symlink(externalAPI, filepath.Join(root, "api")); err != nil { + t.Skipf("platform cannot create symlinks: %v", err) + } + }, wantErr: "API directory must not be a symlink"}, {name: "trailing manifest JSON", mutate: func(t *testing.T, root string) { path := filepath.Join(root, "manifest.json") file, err := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0) From 9f4ffdc7374732310a2031ce178ab8e5ab65ae30 Mon Sep 17 00:00:00 2001 From: Youssef <53960652+yti93@users.noreply.github.com> Date: Wed, 5 Aug 2026 14:23:33 -0700 Subject: [PATCH 13/18] docs: explain lifecycle unknown diagnostics Amp-Thread-ID: https://ampcode.com/threads/T-019fd393-8713-704d-bd35-038f82f8dd95 Co-authored-by: Amp --- ARCHITECTURE.md | 31 +++++++++++++++++++++++++- README.md | 24 +++++++++++++++++++- USAGE.md | 28 ++++++++++++++++++++++- deploy/endoflife-override/manifest.go | 7 +++--- pkg/eol/endoflife/client.go | 7 +++--- pkg/eol/endoflife/provider.go | 1 + pkg/eol/endoflife/provider_404_test.go | 3 ++- pkg/types/resource.go | 2 ++ 8 files changed, 93 insertions(+), 10 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index fb29c4c..1665b34 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -328,7 +328,36 @@ type VersionPolicy interface { - 🔴 **RED**: Past EOL, deprecated, or extended support expired - 🟡 **YELLOW**: In extended support or approaching EOL (< 90 days) - 🟢 **GREEN**: In standard support, current version -- ⚪ **UNKNOWN**: Version not found in EOL database +- ⚪ **UNKNOWN**: Lifecycle lookup or classification was inconclusive + +UNKNOWN findings use a bounded cause vocabulary: `product_not_found`, +`cycle_not_found`, `source_error`, `malformed_cycle`, +`empty_inventory_version`, `lifecycle_mismatch`, +`indeterminate_lifecycle`, and `unattributed`. + +### Lifecycle attribution and diagnostics + +The endoflife.date client records where each lifecycle response came from. +Requests sent directly to the default `https://endoflife.date/api` endpoint +resolve to `endoflife_date`. The nginx override shim marks local JSON responses +as `local_override` and proxied upstream responses as `endoflife_date` using +the trusted `X-Version-Guard-EOL-Source` response header. Custom or otherwise +untrusted endpoints default to `unknown` unless they provide one of those +recognized header values; arbitrary values are not propagated. + +Each snapshot finding's `eol` object preserves `unknown_cause`, `data_source`, +`engine`, and `version` for drill-down. Prometheus uses only bounded labels: +`version_guard_detection_unknown_resources{resource_type,cause}` and +`version_guard_detection_lifecycle_resources{resource_type,source}`. Engine +and version are intentionally excluded from labels to avoid unbounded +cardinality. + +Local overrides are governed by the machine-readable +[`deploy/endoflife-override/manifest.json`](./deploy/endoflife-override/manifest.json). +The [override policy and validation workflow](./deploy/endoflife-override/README.md) +requires ownership, provenance, review dates, one-to-one manifest/file +coverage, and runtime-compatible lifecycle data. Overdue reviews warn; invalid +metadata or lifecycle data fails validation. ### 4. Detection Pipeline diff --git a/README.md b/README.md index e920c78..ca0597a 100644 --- a/README.md +++ b/README.md @@ -188,6 +188,21 @@ The same OpenMetrics endpoint exports `temporal_*`, `version_guard_*`, `go_*`, and `process_*` series. Datadog/BPCI scrape configuration must allow all four families for the RCA dashboard panels to populate. +Lifecycle diagnostics are exposed through two bounded-cardinality gauges: + +- `version_guard_detection_unknown_resources{resource_type,cause}` reports the + latest UNKNOWN count. `cause` is one of `product_not_found`, + `cycle_not_found`, `source_error`, `malformed_cycle`, + `empty_inventory_version`, `lifecycle_mismatch`, + `indeterminate_lifecycle`, or `unattributed`. +- `version_guard_detection_lifecycle_resources{resource_type,source}` reports + the latest resource count by lifecycle source. `source` is one of + `endoflife_date`, `local_override`, or `unknown`. + +Prometheus deliberately does not label these metrics with engine or version, +which would create unbounded series. Use the snapshot `eol` object's +`unknown_cause`, `data_source`, `engine`, and `version` fields for drill-down. + #### End-to-end with `make compose-*` The same commands work for everyone — they auto-detect whether a webhook-style emitter is present and adjust accordingly: @@ -473,7 +488,13 @@ See `./bin/version-guard --help` for all options. | 🔴 **RED** | Past EOL, deprecated, extended support expired | Urgent upgrade required | | 🟡 **YELLOW** | In extended support (costly), approaching EOL (< 90 days) | Plan upgrade soon | | 🟢 **GREEN** | In standard support, current version | Compliant | -| ⚪ **UNKNOWN** | Version not found in EOL database | Investigate | +| ⚪ **UNKNOWN** | Lifecycle lookup or classification was inconclusive (see bounded causes below) | Investigate | + +UNKNOWN is attributed to one of: `product_not_found`, `cycle_not_found`, +`source_error`, `malformed_cycle`, `empty_inventory_version`, +`lifecycle_mismatch`, `indeterminate_lifecycle`, or `unattributed`. The +snapshot `eol` object preserves the cause and lifecycle source alongside the +engine and version for diagnosis. ## 🔌 Extending Version Guard @@ -565,6 +586,7 @@ constants used in tests. "version": "5.7", "engine": "mysql", "source": "endoflife-date-api", + "data_source": "local_override", "is_supported": true, "is_deprecated": true, "is_extended_support": true, diff --git a/USAGE.md b/USAGE.md index 1adf277..7fcf105 100644 --- a/USAGE.md +++ b/USAGE.md @@ -364,6 +364,28 @@ Useful SDK metrics include: - `temporal_request_failure_total` - `temporal_request_latency_seconds` +Version Guard also exposes lifecycle diagnostic gauges: + +- `version_guard_detection_unknown_resources{resource_type,cause}` — latest + UNKNOWN resources by resource type and bounded cause. Causes are + `product_not_found`, `cycle_not_found`, `source_error`, `malformed_cycle`, + `empty_inventory_version`, `lifecycle_mismatch`, + `indeterminate_lifecycle`, and `unattributed`. +- `version_guard_detection_lifecycle_resources{resource_type,source}` — latest + resources by resource type and lifecycle source. Sources are + `endoflife_date`, `local_override`, and `unknown`. + +Engine and version are intentionally not Prometheus labels. For a specific +resource, inspect its snapshot `eol.unknown_cause`, `eol.data_source`, +`eol.engine`, and `eol.version` fields instead. Direct requests to the default +endoflife.date API resolve to `endoflife_date`; nginx-served local files resolve +to `local_override`; custom endpoints without a recognized +`X-Version-Guard-EOL-Source` header resolve to `unknown`. + +Operators adding, reviewing, or removing local overrides must update +[`deploy/endoflife-override/manifest.json`](./deploy/endoflife-override/manifest.json) +and follow its [validation policy](./deploy/endoflife-override/README.md). + Set `TEMPORAL_METRICS_ENABLED=false` to disable the handler, or `TEMPORAL_METRICS_LISTEN_ADDRESS=0.0.0.0:9091` to change the listen address. @@ -780,7 +802,11 @@ A: Next scan will detect the new version and auto-resolve the finding. A: No, Version Guard only detects and reports. You must upgrade manually. **Q: What if my resource version isn't in the EOL database?** -A: Finding will show status UNKNOWN. You can extend the EOL provider to add version data. +A: The finding will show UNKNOWN with a bounded `eol.unknown_cause`. UNKNOWN +also covers source errors, malformed lifecycle data, empty inventory versions, +lifecycle mismatches, and indeterminate lifecycle records—not only missing +versions. Inspect `eol.data_source`, `eol.engine`, and `eol.version` in the +snapshot to choose the remediation. **Q: How do I add a new resource type?** A: See [Runbook 1](#runbook-1-onboarding-new-resource-type) above. diff --git a/deploy/endoflife-override/manifest.go b/deploy/endoflife-override/manifest.go index 375d909..e477666 100644 --- a/deploy/endoflife-override/manifest.go +++ b/deploy/endoflife-override/manifest.go @@ -20,8 +20,8 @@ const ( ) type manifest struct { - SchemaVersion int `json:"schema_version"` Overrides []manifestOverride `json:"overrides"` + SchemaVersion int `json:"schema_version"` } type manifestOverride struct { @@ -62,8 +62,8 @@ func validateManifest(root string, now time.Time, warnings io.Writer) error { paths := make(map[string]struct{}, len(m.Overrides)) for index := range m.Overrides { override := &m.Overrides[index] - if err := validateOverride(root, override, now.UTC(), warnings, products, paths); err != nil { - return fmt.Errorf("override %d: %w", index, err) + if validationErr := validateOverride(root, override, now.UTC(), warnings, products, paths); validationErr != nil { + return fmt.Errorf("override %d: %w", index, validationErr) } } @@ -102,6 +102,7 @@ func readManifest(path string) (*manifest, error) { return &m, nil } +//nolint:gocyclo // Validation intentionally reports the first field-specific policy violation. func validateOverride(root string, override *manifestOverride, now time.Time, warnings io.Writer, products, paths map[string]struct{}) error { required := []struct { name string diff --git a/pkg/eol/endoflife/client.go b/pkg/eol/endoflife/client.go index 1fd9ede..db3d38b 100644 --- a/pkg/eol/endoflife/client.go +++ b/pkg/eol/endoflife/client.go @@ -9,8 +9,9 @@ import ( "strings" "time" - "github.com/block/Version-Guard/pkg/types" "github.com/pkg/errors" + + "github.com/block/Version-Guard/pkg/types" ) const ( @@ -42,6 +43,8 @@ type Client interface { } // ProductCyclesResult contains lifecycle cycles and metadata about their source. +// +//nolint:govet // Field order groups the response payload before its attribution metadata. type ProductCyclesResult struct { Cycles []*ProductCycle FetchedAt time.Time @@ -50,8 +53,6 @@ type ProductCyclesResult struct { // ProductCycle represents a single version/cycle from endoflife.date API // API docs: https://endoflife.date/docs/api/ -// -//nolint:govet // field order matches endoflife.date API response shape for readability type ProductCycle struct { Cycle string `json:"cycle"` // Version identifier (e.g., "1.31") ReleaseDate string `json:"releaseDate"` // Release date (YYYY-MM-DD) diff --git a/pkg/eol/endoflife/provider.go b/pkg/eol/endoflife/provider.go index 6872e7b..93244c3 100644 --- a/pkg/eol/endoflife/provider.go +++ b/pkg/eol/endoflife/provider.go @@ -331,6 +331,7 @@ func ValidateProductCycle(cycle *ProductCycle) error { return nil } +//nolint:goconst // These strings are the accepted wire representations, not domain constants. func validateDateOrBoolean(value any) error { switch value := value.(type) { case nil, bool: diff --git a/pkg/eol/endoflife/provider_404_test.go b/pkg/eol/endoflife/provider_404_test.go index d99cda3..fa6e8e8 100644 --- a/pkg/eol/endoflife/provider_404_test.go +++ b/pkg/eol/endoflife/provider_404_test.go @@ -7,10 +7,11 @@ import ( "testing" "time" - "github.com/block/Version-Guard/pkg/types" pkgerrors "github.com/pkg/errors" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + "github.com/block/Version-Guard/pkg/types" ) // TestProvider_GetVersionLifecycle_Product404 tests graceful degradation when diff --git a/pkg/types/resource.go b/pkg/types/resource.go index a539b95..caad662 100644 --- a/pkg/types/resource.go +++ b/pkg/types/resource.go @@ -181,6 +181,8 @@ type VersionLifecycle struct { // 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 v3. +// +//nolint:govet // Preserve the established Finding field order and snapshot compatibility. type Finding struct { // Tags are the resource's key-value metadata (e.g., AWS resource tags) Tags map[string]string `json:",omitempty"` From 60773e7f8df62866b3be4ca41fe4320c4efb05c1 Mon Sep 17 00:00:00 2001 From: Youssef <53960652+yti93@users.noreply.github.com> Date: Wed, 5 Aug 2026 14:29:55 -0700 Subject: [PATCH 14/18] fix: reject malformed lifecycle responses Amp-Thread-ID: https://ampcode.com/threads/T-019fd393-8713-704d-bd35-038f82f8dd95 Co-authored-by: Amp --- pkg/eol/endoflife/client.go | 13 +++++- pkg/eol/endoflife/client_test.go | 30 ++++++++++++++ pkg/eol/endoflife/provider_test.go | 24 +++++++++++ pkg/workflow/detection/activities.go | 19 +++++++-- pkg/workflow/detection/activities_test.go | 49 +++++++++++++++++++++++ 5 files changed, 130 insertions(+), 5 deletions(-) diff --git a/pkg/eol/endoflife/client.go b/pkg/eol/endoflife/client.go index db3d38b..56a614b 100644 --- a/pkg/eol/endoflife/client.go +++ b/pkg/eol/endoflife/client.go @@ -148,9 +148,20 @@ func (c *RealHTTPClient) GetProductCycles(ctx context.Context, product string) ( return result, errors.Errorf("unexpected status code %d: %s", resp.StatusCode, string(body)) } - if err := json.NewDecoder(resp.Body).Decode(&result.Cycles); err != nil { + decoder := json.NewDecoder(resp.Body) + if err := decoder.Decode(&result.Cycles); err != nil { return result, errors.Wrap(err, "failed to decode response") } + if result.Cycles == nil { + return result, errors.New("failed to decode response: cycles must be a JSON array") + } + var trailing any + if err := decoder.Decode(&trailing); err != io.EOF { + if err == nil { + return result, errors.New("failed to decode response: unexpected trailing JSON value") + } + return result, errors.Wrap(err, "failed to decode response trailer") + } return result, nil } diff --git a/pkg/eol/endoflife/client_test.go b/pkg/eol/endoflife/client_test.go index fd482e5..3d3cf0a 100644 --- a/pkg/eol/endoflife/client_test.go +++ b/pkg/eol/endoflife/client_test.go @@ -10,6 +10,9 @@ import ( "testing" "time" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/block/Version-Guard/pkg/types" ) @@ -204,6 +207,33 @@ func TestRealHTTPClient_ProductCyclesResultSource(t *testing.T) { } } +func TestRealHTTPClient_RejectsMalformedWholeResponse(t *testing.T) { + tests := []struct { + name string + body string + }{ + {name: "null cycles", body: `null`}, + {name: "trailing JSON value", body: `[] {}`}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set(EOLSourceHeader, string(types.LifecycleDataSourceLocalOverride)) + _, _ = w.Write([]byte(tt.body)) + })) + defer server.Close() + + client := NewRealHTTPClientWithConfig(nil, server.URL) + result, err := client.GetProductCycles(context.Background(), "test") + + require.Error(t, err) + assert.Equal(t, types.LifecycleDataSourceLocalOverride, result.DataSource) + assert.False(t, result.FetchedAt.IsZero()) + }) + } +} + func TestNewRealHTTPClient_DefaultDataSource(t *testing.T) { client := NewRealHTTPClient() client.httpClient.Transport = roundTripFunc(func(req *http.Request) (*http.Response, error) { diff --git a/pkg/eol/endoflife/provider_test.go b/pkg/eol/endoflife/provider_test.go index f3fa2b0..2b50e9e 100644 --- a/pkg/eol/endoflife/provider_test.go +++ b/pkg/eol/endoflife/provider_test.go @@ -3,6 +3,8 @@ package endoflife import ( "context" "errors" + "net/http" + "net/http/httptest" "strings" "sync" "testing" @@ -327,6 +329,28 @@ func TestProvider_SourceErrorReturnsDiagnosticLifecycle(t *testing.T) { } } +func TestProvider_MalformedResponseReturnsSourceError(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set(EOLSourceHeader, string(types.LifecycleDataSourceLocalOverride)) + _, _ = w.Write([]byte(`null`)) + })) + defer server.Close() + + client := NewRealHTTPClientWithConfig(server.Client(), server.URL) + provider, _ := NewProvider(client, "mysql", "", time.Hour, nil) + lifecycle, err := provider.GetVersionLifecycle(context.Background(), "mysql", "8.0") + + if err == nil { + t.Fatal("expected malformed response error") + } + if lifecycle == nil || lifecycle.UnknownCause != types.LifecycleUnknownCauseSourceError { + t.Fatalf("lifecycle = %#v, want source_error diagnostic", lifecycle) + } + if lifecycle.DataSource != types.LifecycleDataSourceLocalOverride || lifecycle.FetchedAt.IsZero() { + t.Errorf("diagnostic metadata not preserved: %#v", lifecycle) + } +} + func TestProvider_MalformedMatchingCycle(t *testing.T) { tests := []struct { name, version string diff --git a/pkg/workflow/detection/activities.go b/pkg/workflow/detection/activities.go index fe01d96..1bb3327 100644 --- a/pkg/workflow/detection/activities.go +++ b/pkg/workflow/detection/activities.go @@ -222,12 +222,21 @@ func (a *Activities) FetchEOLData(ctx context.Context, input FetchEOLInput) (*EO logger.Warn("Failed to get lifecycle", "engine", resource.Engine, "version", resource.CurrentVersion, "error", err) if lifecycle == nil { lifecycle = &types.VersionLifecycle{ + Version: resource.CurrentVersion, Engine: resource.Engine, Source: provider.Name(), DataSource: types.LifecycleDataSourceUnknown, UnknownCause: types.LifecycleUnknownCauseSourceError, } } + } else if lifecycle == nil { + lifecycle = &types.VersionLifecycle{ + Version: resource.CurrentVersion, + Engine: resource.Engine, + Source: provider.Name(), + DataSource: types.LifecycleDataSourceUnknown, + UnknownCause: types.LifecycleUnknownCauseUnattributed, + } } lifecycles[key] = lifecycle @@ -255,12 +264,14 @@ func (a *Activities) DetectDrift(ctx context.Context, input DetectInput) (*Detec for _, resource := range resources { key := resource.Engine + ":" + resource.CurrentVersion lifecycle, ok := input.VersionLifecycles[key] - if !ok { + if !ok || lifecycle == nil { // No lifecycle data - create unknown finding lifecycle = &types.VersionLifecycle{ - Version: resource.CurrentVersion, - Engine: resource.Engine, - IsSupported: false, + Version: resource.CurrentVersion, + Engine: resource.Engine, + IsSupported: false, + DataSource: types.LifecycleDataSourceUnknown, + UnknownCause: types.LifecycleUnknownCauseUnattributed, } } diff --git a/pkg/workflow/detection/activities_test.go b/pkg/workflow/detection/activities_test.go index 7cf200d..59e8015 100644 --- a/pkg/workflow/detection/activities_test.go +++ b/pkg/workflow/detection/activities_test.go @@ -288,6 +288,32 @@ func TestFetchEOLData_PreservesDiagnosticLifecycleOnError(t *testing.T) { assert.Equal(t, diagnostic, output.VersionLifecycles["aurora-mysql:8.0.35"]) } +func TestFetchEOLData_NilLifecycleWithoutErrorIsUnattributed(t *testing.T) { + provider := &countingEOLProvider{} + act := NewActivities(nil, map[types.ResourceType]eol.Provider{ + types.ResourceTypeAurora: provider, + }, policy.NewDefaultPolicy(), memory.NewStore()) + env := newActivityEnv() + env.RegisterActivity(act.FetchEOLData) + + result, err := env.ExecuteActivity(act.FetchEOLData, FetchEOLInput{ + ResourceType: types.ResourceTypeAurora, + Resources: []*types.Resource{{ + Engine: "aurora-mysql", CurrentVersion: "8.0.35", Type: types.ResourceTypeAurora, + }}, + }) + require.NoError(t, err) + + var output EOLResult + require.NoError(t, result.Get(&output)) + lifecycle := output.VersionLifecycles["aurora-mysql:8.0.35"] + require.NotNil(t, lifecycle) + assert.Equal(t, "aurora-mysql", lifecycle.Engine) + assert.Equal(t, "8.0.35", lifecycle.Version) + assert.Equal(t, types.LifecycleDataSourceUnknown, lifecycle.DataSource) + assert.Equal(t, types.LifecycleUnknownCauseUnattributed, lifecycle.UnknownCause) +} + // --- DetectDrift tests --- func TestDetectDrift_FromCache_CleansUpAndStoresFindings(t *testing.T) { @@ -514,6 +540,29 @@ func TestDetectDrift_UnknownVersion(t *testing.T) { assert.Equal(t, 1, detect.FindingsCount) } +func TestDetectDrift_NilLifecycleMapValueIsUnattributed(t *testing.T) { + resource := &types.Resource{ + ID: "r1", Engine: "aurora-mysql", CurrentVersion: "8.0.35", Type: types.ResourceTypeAurora, + } + act := newTestActivities([]*types.Resource{resource}, nil) + env := newActivityEnv() + env.RegisterActivity(act.DetectDrift) + + result, err := env.ExecuteActivity(act.DetectDrift, DetectInput{ + Resources: []*types.Resource{resource}, + VersionLifecycles: map[string]*types.VersionLifecycle{ + "aurora-mysql:8.0.35": nil, + }, + }) + require.NoError(t, err) + + var output DetectResult + require.NoError(t, result.Get(&output)) + require.Len(t, output.Findings, 1) + assert.Equal(t, types.StatusUnknown, output.Findings[0].Status) + assert.Equal(t, types.LifecycleUnknownCauseUnattributed, output.Findings[0].EOL.UnknownCause) +} + // --- StoreFindings tests --- func TestStoreFindings_FromCache(t *testing.T) { From 9d09962623cc1b78d62d6ea7a3c9add8dfe4558b Mon Sep 17 00:00:00 2001 From: Youssef <53960652+yti93@users.noreply.github.com> Date: Wed, 5 Aug 2026 14:30:27 -0700 Subject: [PATCH 15/18] docs: record final fix verification Amp-Thread-ID: https://ampcode.com/threads/T-019fd393-8713-704d-bd35-038f82f8dd95 Co-authored-by: Amp --- .superpowers/sdd/final-fix-report.md | 42 ++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 .superpowers/sdd/final-fix-report.md diff --git a/.superpowers/sdd/final-fix-report.md b/.superpowers/sdd/final-fix-report.md new file mode 100644 index 0000000..0970b85 --- /dev/null +++ b/.superpowers/sdd/final-fix-report.md @@ -0,0 +1,42 @@ +# Final Whole-Branch Fix Report + +## Outcome + +Both final review findings are fixed without changing public Temporal activity input/output shapes or the snapshot version. + +## Files + +- `pkg/eol/endoflife/client.go`: rejects a decoded nil cycle slice and requires the decoder's second read to return `io.EOF`. +- `pkg/eol/endoflife/client_test.go`: covers JSON `null` and a valid array followed by another JSON value, including retained response metadata. +- `pkg/eol/endoflife/provider_test.go`: proves malformed client responses retain metadata and become `source_error` provider diagnostics. +- `pkg/workflow/detection/activities.go`: synthesizes bounded lifecycle diagnostics for nil provider results and guards nil lifecycle map values before copying. +- `pkg/workflow/detection/activities_test.go`: covers nil/no-error provider results and explicit nil lifecycle map entries as `UNKNOWN`/`unattributed` without panic. + +## RED evidence + +Before production changes: + +- `go test ./pkg/eol/endoflife -run 'TestRealHTTPClient_RejectsMalformedWholeResponse|TestProvider_MalformedResponseReturnsSourceError' -count=1` failed all three new checks because `null` and trailing JSON returned nil errors and the provider did not produce a source error. +- `go test ./pkg/workflow/detection -run 'TestFetchEOLData_NilLifecycleWithoutErrorIsUnattributed|TestDetectDrift_NilLifecycleMapValueIsUnattributed' -count=1` failed because `FetchEOLData` stored nil and `DetectDrift` panicked dereferencing an explicit nil map value. + +## GREEN verification + +- `make fmt-all` — passed; only the five intended Go files changed. +- `go test ./pkg/eol/endoflife -count=1` — passed (`ok`, 0.372s). +- `go test ./pkg/workflow/detection -count=1` — passed (`ok`, 0.364s). +- `go test -race ./pkg/eol/endoflife ./pkg/workflow/detection -count=1` — passed (`ok`, 1.586s and 1.714s). +- `make test` — passed all repository packages with race detection; changed packages passed in 1.353s and 1.624s. +- `git diff --check` — passed with no output. + +## Commits + +- `60773e7 fix: reject malformed lifecycle responses` — implementation and regression tests. +- The report itself is committed separately so it can record the immutable implementation commit. + +## Self-review + +Reviewed the complete final diff for correctness, compatibility, edge cases, test quality, and scope. The client preserves `FetchedAt` and trusted source metadata on both new malformed-response errors. Whitespace-only response trailers still resolve to `io.EOF`; any second JSON value or malformed trailer is rejected. Nil+error remains `source_error`; nil+no-error and explicit nil map values become bounded `unknown`/`unattributed` diagnostics with engine/version context. No activity contracts, workflow ordering, snapshot schema version, or unrelated files changed. + +## Concerns + +None identified. A provider returning `(nil, nil)` remains treated as a compatibility anomaly rather than an activity failure, as required by the approved design. From b9d6f889f495e685ae7b7e3eb1caacc4e5fa6c03 Mon Sep 17 00:00:00 2001 From: Youssef <53960652+yti93@users.noreply.github.com> Date: Wed, 5 Aug 2026 14:30:59 -0700 Subject: [PATCH 16/18] chore: remove internal review artifact Amp-Thread-ID: https://ampcode.com/threads/T-019fd393-8713-704d-bd35-038f82f8dd95 Co-authored-by: Amp --- .superpowers/sdd/final-fix-report.md | 42 ---------------------------- 1 file changed, 42 deletions(-) delete mode 100644 .superpowers/sdd/final-fix-report.md diff --git a/.superpowers/sdd/final-fix-report.md b/.superpowers/sdd/final-fix-report.md deleted file mode 100644 index 0970b85..0000000 --- a/.superpowers/sdd/final-fix-report.md +++ /dev/null @@ -1,42 +0,0 @@ -# Final Whole-Branch Fix Report - -## Outcome - -Both final review findings are fixed without changing public Temporal activity input/output shapes or the snapshot version. - -## Files - -- `pkg/eol/endoflife/client.go`: rejects a decoded nil cycle slice and requires the decoder's second read to return `io.EOF`. -- `pkg/eol/endoflife/client_test.go`: covers JSON `null` and a valid array followed by another JSON value, including retained response metadata. -- `pkg/eol/endoflife/provider_test.go`: proves malformed client responses retain metadata and become `source_error` provider diagnostics. -- `pkg/workflow/detection/activities.go`: synthesizes bounded lifecycle diagnostics for nil provider results and guards nil lifecycle map values before copying. -- `pkg/workflow/detection/activities_test.go`: covers nil/no-error provider results and explicit nil lifecycle map entries as `UNKNOWN`/`unattributed` without panic. - -## RED evidence - -Before production changes: - -- `go test ./pkg/eol/endoflife -run 'TestRealHTTPClient_RejectsMalformedWholeResponse|TestProvider_MalformedResponseReturnsSourceError' -count=1` failed all three new checks because `null` and trailing JSON returned nil errors and the provider did not produce a source error. -- `go test ./pkg/workflow/detection -run 'TestFetchEOLData_NilLifecycleWithoutErrorIsUnattributed|TestDetectDrift_NilLifecycleMapValueIsUnattributed' -count=1` failed because `FetchEOLData` stored nil and `DetectDrift` panicked dereferencing an explicit nil map value. - -## GREEN verification - -- `make fmt-all` — passed; only the five intended Go files changed. -- `go test ./pkg/eol/endoflife -count=1` — passed (`ok`, 0.372s). -- `go test ./pkg/workflow/detection -count=1` — passed (`ok`, 0.364s). -- `go test -race ./pkg/eol/endoflife ./pkg/workflow/detection -count=1` — passed (`ok`, 1.586s and 1.714s). -- `make test` — passed all repository packages with race detection; changed packages passed in 1.353s and 1.624s. -- `git diff --check` — passed with no output. - -## Commits - -- `60773e7 fix: reject malformed lifecycle responses` — implementation and regression tests. -- The report itself is committed separately so it can record the immutable implementation commit. - -## Self-review - -Reviewed the complete final diff for correctness, compatibility, edge cases, test quality, and scope. The client preserves `FetchedAt` and trusted source metadata on both new malformed-response errors. Whitespace-only response trailers still resolve to `io.EOF`; any second JSON value or malformed trailer is rejected. Nil+error remains `source_error`; nil+no-error and explicit nil map values become bounded `unknown`/`unattributed` diagnostics with engine/version context. No activity contracts, workflow ordering, snapshot schema version, or unrelated files changed. - -## Concerns - -None identified. A provider returning `(nil, nil)` remains treated as a compatibility anomaly rather than an activity failure, as required by the approved design. From 0ec49ef2f683225bf2d27b6086e63b262e7ccd0b Mon Sep 17 00:00:00 2001 From: Youssef <53960652+yti93@users.noreply.github.com> Date: Wed, 5 Aug 2026 14:33:33 -0700 Subject: [PATCH 17/18] Fix lifecycle source header fallback Amp-Thread-ID: https://ampcode.com/threads/T-019fd393-8713-704d-bd35-038f82f8dd95 Co-authored-by: Amp --- pkg/eol/endoflife/client.go | 7 +++- pkg/eol/endoflife/client_test.go | 66 ++++++++++++++++++++++---------- 2 files changed, 50 insertions(+), 23 deletions(-) diff --git a/pkg/eol/endoflife/client.go b/pkg/eol/endoflife/client.go index 56a614b..b622764 100644 --- a/pkg/eol/endoflife/client.go +++ b/pkg/eol/endoflife/client.go @@ -100,13 +100,16 @@ func NewRealHTTPClientWithConfig(httpClient *http.Client, baseURL string) *RealH } func lifecycleDataSource(value string, fallback types.LifecycleDataSource) types.LifecycleDataSource { - switch types.LifecycleDataSource(strings.ToLower(strings.TrimSpace(value))) { + normalized := types.LifecycleDataSource(strings.ToLower(strings.TrimSpace(value))) + switch normalized { + case "": + return fallback case types.LifecycleDataSourceEndOfLifeDate: return types.LifecycleDataSourceEndOfLifeDate case types.LifecycleDataSourceLocalOverride: return types.LifecycleDataSourceLocalOverride default: - return fallback + return types.LifecycleDataSourceUnknown } } diff --git a/pkg/eol/endoflife/client_test.go b/pkg/eol/endoflife/client_test.go index 3d3cf0a..b225c39 100644 --- a/pkg/eol/endoflife/client_test.go +++ b/pkg/eol/endoflife/client_test.go @@ -234,28 +234,52 @@ func TestRealHTTPClient_RejectsMalformedWholeResponse(t *testing.T) { } } -func TestNewRealHTTPClient_DefaultDataSource(t *testing.T) { - client := NewRealHTTPClient() - client.httpClient.Transport = roundTripFunc(func(req *http.Request) (*http.Response, error) { - req.URL.Scheme = "http" - req.URL.Host = "example.test" - return &http.Response{ - StatusCode: http.StatusOK, - Header: make(http.Header), - Body: io.NopCloser(strings.NewReader(`[]`)), - Request: req, - }, nil - }) - - result, err := client.GetProductCycles(context.Background(), "test") - if err != nil { - t.Fatalf("GetProductCycles() error = %v", err) - } - if result.DataSource != types.LifecycleDataSourceEndOfLifeDate { - t.Errorf("DataSource = %q, want %q", result.DataSource, types.LifecycleDataSourceEndOfLifeDate) +func TestNewRealHTTPClient_ResponseDataSource(t *testing.T) { + tests := []struct { + name string + header string + wantSource types.LifecycleDataSource + }{ + { + name: "absent header uses direct client fallback", + wantSource: types.LifecycleDataSourceEndOfLifeDate, + }, + { + name: "invalid header does not use direct client fallback", + header: "attacker-controlled-value", + wantSource: types.LifecycleDataSourceUnknown, + }, } - if result.FetchedAt.IsZero() { - t.Error("FetchedAt should be non-zero") + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + client := NewRealHTTPClient() + client.httpClient.Transport = roundTripFunc(func(req *http.Request) (*http.Response, error) { + req.URL.Scheme = "http" + req.URL.Host = "example.test" + header := make(http.Header) + if tt.header != "" { + header.Set(EOLSourceHeader, tt.header) + } + return &http.Response{ + StatusCode: http.StatusOK, + Header: header, + Body: io.NopCloser(strings.NewReader(`[]`)), + Request: req, + }, nil + }) + + result, err := client.GetProductCycles(context.Background(), "test") + if err != nil { + t.Fatalf("GetProductCycles() error = %v", err) + } + if result.DataSource != tt.wantSource { + t.Errorf("DataSource = %q, want %q", result.DataSource, tt.wantSource) + } + if result.FetchedAt.IsZero() { + t.Error("FetchedAt should be non-zero") + } + }) } } From 2d7aa383f1e7b048aa862ffeaf96abc10d8b8635 Mon Sep 17 00:00:00 2001 From: Youssef <53960652+yti93@users.noreply.github.com> Date: Wed, 5 Aug 2026 14:43:12 -0700 Subject: [PATCH 18/18] fix: preserve lifecycle diagnostic metadata Amp-Thread-ID: https://ampcode.com/threads/T-019fd393-8713-704d-bd35-038f82f8dd95 Co-authored-by: Amp --- deploy/endoflife-override/manifest_test.go | 6 + ...026-08-05-lifecycle-unknown-attribution.md | 948 ------------------ ...05-lifecycle-unknown-attribution-design.md | 148 --- pkg/eol/endoflife/provider.go | 29 +- pkg/eol/endoflife/provider_test.go | 35 +- pkg/workflow/detection/activities_test.go | 21 +- 6 files changed, 82 insertions(+), 1105 deletions(-) delete mode 100644 docs/superpowers/plans/2026-08-05-lifecycle-unknown-attribution.md delete mode 100644 docs/superpowers/specs/2026-08-05-lifecycle-unknown-attribution-design.md diff --git a/deploy/endoflife-override/manifest_test.go b/deploy/endoflife-override/manifest_test.go index da1ae9b..df365d8 100644 --- a/deploy/endoflife-override/manifest_test.go +++ b/deploy/endoflife-override/manifest_test.go @@ -83,6 +83,12 @@ func TestValidateManifest(t *testing.T) { {name: "invalid lifecycle data", mutate: func(t *testing.T, root string) { require.NoError(t, os.WriteFile(filepath.Join(root, "api", "amazon-aurora-mysql.json"), []byte(`[{"cycle":"3","eol":42}]`), 0o600)) }, wantErr: "unsupported value type"}, + {name: "malformed optional release date", mutate: func(t *testing.T, root string) { + require.NoError(t, os.WriteFile(filepath.Join(root, "api", "amazon-aurora-mysql.json"), []byte(`[{"cycle":"3","releaseDate":"2026-1-01"}]`), 0o600)) + }, wantErr: "releaseDate"}, + {name: "malformed optional latest release date", mutate: func(t *testing.T, root string) { + require.NoError(t, os.WriteFile(filepath.Join(root, "api", "amazon-aurora-mysql.json"), []byte(`[{"cycle":"3","latestReleaseDate":"not-a-date"}]`), 0o600)) + }, wantErr: "latestReleaseDate"}, {name: "API data is not an array", mutate: func(t *testing.T, root string) { require.NoError(t, os.WriteFile(filepath.Join(root, "api", "amazon-aurora-mysql.json"), []byte(`null`), 0o600)) }, wantErr: "top-level array"}, diff --git a/docs/superpowers/plans/2026-08-05-lifecycle-unknown-attribution.md b/docs/superpowers/plans/2026-08-05-lifecycle-unknown-attribution.md deleted file mode 100644 index 95724d4..0000000 --- a/docs/superpowers/plans/2026-08-05-lifecycle-unknown-attribution.md +++ /dev/null @@ -1,948 +0,0 @@ -# Lifecycle UNKNOWN Attribution Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Attribute every lifecycle `UNKNOWN` finding to a bounded actionable cause, expose safe aggregate metrics and snapshot drill-down, and govern local endoflife.date overrides with source and review metadata. - -**Architecture:** Add closed cause/source values to the existing lifecycle object, enrich the endoflife.date client result with bounded response metadata, and preserve partial diagnostics through the provider and detection activity. Aggregate only cause and source in Prometheus; retain engine/version detail in the existing snapshot finding. Nginx marks local versus proxied responses, while a standalone manifest validator enforces override metadata structure and warns—without failing—when review is overdue. - -**Tech Stack:** Go 1.24, Temporal Go SDK payloads, Prometheus client_golang, nginx, JSON, testify, standard `testing`/`httptest`. - -## Global Constraints - -- Preserve all Temporal workflow/activity names, ordering, and input/output types. -- Keep snapshot schema `v4`; new lifecycle fields are optional additions inside `eol`. -- Keep existing `VersionLifecycle.Source` semantics and value `endoflife-date-api`. -- Never put engine, version, product, URL, owner, or error text in Prometheus labels. -- Normalize arbitrary or absent causes to `unattributed` and data sources to `unknown`. -- Never mutate lifecycle pointers returned from the provider cache. -- An overdue override review emits a warning only; malformed or inconsistent override metadata is an error. -- Use focused package tests while iterating, then repository Makefile targets. - -## File Structure - -- `pkg/types/resource.go`: closed lifecycle cause/source types and lifecycle fields. -- `pkg/types/lifecycle_details.go`: additive snapshot-facing propagation. -- `pkg/policy/default.go`: pure UNKNOWN cause fallback based on classification semantics. -- `pkg/eol/endoflife/client.go`: product response envelope and trusted source-header handling. -- `pkg/eol/endoflife/provider.go`: product/cycle/source failure attribution and precise malformed-cycle tracking. -- `pkg/eol/provider.go`: document partial diagnostic lifecycle returns. -- `pkg/workflow/detection/activities.go`: preserve partial failures, avoid empty-version provider calls, annotate lifecycle copies, and aggregate breakdowns. -- `pkg/telemetry/metrics.go`: bounded cause/source gauges with stale-series reset. -- `deploy/endoflife-override/nginx.conf`: authoritative response-origin header. -- `deploy/endoflife-override/manifest.json`: override ownership, source, and review metadata. -- `deploy/endoflife-override/manifest.go`: deterministic parser and validator. -- `deploy/endoflife-override/*_test.go`: manifest and nginx contract tests. -- Existing package tests: lock client, provider, policy, detection, metrics, and snapshot behavior. - ---- - -### Task 1: Define and propagate the lifecycle attribution contract - -**Files:** -- Modify: `pkg/types/resource.go` -- Modify: `pkg/types/lifecycle_details.go` -- Modify: `pkg/types/resource_test.go` -- Modify: `pkg/policy/default.go` -- Modify: `pkg/policy/default_test.go` - -**Interfaces:** -- Produces: `types.LifecycleUnknownCause`, `types.LifecycleDataSource`, `types.KnownLifecycleUnknownCauses()`, `types.KnownLifecycleDataSources()`, and `policy.UnknownCause(resource, lifecycle, status)`. -- Consumed by: endoflife.date client/provider, detection, telemetry, and snapshot tasks. - -- [ ] **Step 1: Write failing lifecycle propagation tests** - -Add tests that create a `VersionLifecycle` with provider source, data source, and -cause, then assert `LifecycleDetailsFromVersionLifecycle` preserves all three: - -```go -func TestLifecycleDetailsFromVersionLifecycle_PropagatesAttribution(t *testing.T) { - lifecycle := &VersionLifecycle{ - Source: "endoflife-date-api", - DataSource: LifecycleDataSourceLocalOverride, - UnknownCause: LifecycleUnknownCauseCycleNotFound, - } - - details := LifecycleDetailsFromVersionLifecycle(lifecycle) - - assert.Equal(t, "endoflife-date-api", details.Source) - assert.Equal(t, LifecycleDataSourceLocalOverride, details.DataSource) - assert.Equal(t, LifecycleUnknownCauseCycleNotFound, details.UnknownCause) -} -``` - -Add table tests asserting the known-value functions return every enum exactly -once and in stable order. - -- [ ] **Step 2: Run the type tests and verify red state** - -Run: `go test ./pkg/types -run 'TestLifecycleDetailsFromVersionLifecycle_PropagatesAttribution|TestKnownLifecycle' -count=1` - -Expected: compile failure because attribution types and fields do not exist. - -- [ ] **Step 3: Implement the closed domain values and propagation** - -Add these definitions to `pkg/types/resource.go`: - -```go -type LifecycleUnknownCause string - -const ( - LifecycleUnknownCauseProductNotFound LifecycleUnknownCause = "product_not_found" - LifecycleUnknownCauseCycleNotFound LifecycleUnknownCause = "cycle_not_found" - LifecycleUnknownCauseSourceError LifecycleUnknownCause = "source_error" - LifecycleUnknownCauseMalformedCycle LifecycleUnknownCause = "malformed_cycle" - LifecycleUnknownCauseEmptyInventoryVersion LifecycleUnknownCause = "empty_inventory_version" - LifecycleUnknownCauseLifecycleMismatch LifecycleUnknownCause = "lifecycle_mismatch" - LifecycleUnknownCauseIndeterminate LifecycleUnknownCause = "indeterminate_lifecycle" - LifecycleUnknownCauseUnattributed LifecycleUnknownCause = "unattributed" -) - -type LifecycleDataSource string - -const ( - LifecycleDataSourceEndOfLifeDate LifecycleDataSource = "endoflife_date" - LifecycleDataSourceLocalOverride LifecycleDataSource = "local_override" - LifecycleDataSourceUnknown LifecycleDataSource = "unknown" -) - -func KnownLifecycleUnknownCauses() []LifecycleUnknownCause { - return []LifecycleUnknownCause{ - LifecycleUnknownCauseProductNotFound, - LifecycleUnknownCauseCycleNotFound, - LifecycleUnknownCauseSourceError, - LifecycleUnknownCauseMalformedCycle, - LifecycleUnknownCauseEmptyInventoryVersion, - LifecycleUnknownCauseLifecycleMismatch, - LifecycleUnknownCauseIndeterminate, - LifecycleUnknownCauseUnattributed, - } -} - -func KnownLifecycleDataSources() []LifecycleDataSource { - return []LifecycleDataSource{ - LifecycleDataSourceEndOfLifeDate, - LifecycleDataSourceLocalOverride, - LifecycleDataSourceUnknown, - } -} -``` - -Add `DataSource` and `UnknownCause` to `VersionLifecycle`. Add these fields to -`LifecycleDetails` and copy them in `LifecycleDetailsFromVersionLifecycle`: - -```go -DataSource LifecycleDataSource `json:"data_source,omitempty"` -UnknownCause LifecycleUnknownCause `json:"unknown_cause,omitempty"` -``` - -- [ ] **Step 4: Write failing policy attribution tests** - -Add a table-driven test covering provider-cause precedence, blank inventory -version, empty lifecycle version, mismatch, indeterminate lifecycle, -non-UNKNOWN status, and nil lifecycle: - -```go -func TestUnknownCause(t *testing.T) { - tests := []struct { - name string - resource *types.Resource - lifecycle *types.VersionLifecycle - status types.Status - want types.LifecycleUnknownCause - }{ - { - name: "provider cause wins", - resource: &types.Resource{CurrentVersion: "8.0.35"}, - lifecycle: &types.VersionLifecycle{UnknownCause: types.LifecycleUnknownCauseProductNotFound}, - status: types.StatusUnknown, - want: types.LifecycleUnknownCauseProductNotFound, - }, - { - name: "empty inventory version", - resource: &types.Resource{CurrentVersion: " "}, - lifecycle: &types.VersionLifecycle{}, - status: types.StatusUnknown, - want: types.LifecycleUnknownCauseEmptyInventoryVersion, - }, - { - name: "cycle absent", - resource: &types.Resource{CurrentVersion: "8.0.35"}, - lifecycle: &types.VersionLifecycle{}, - status: types.StatusUnknown, - want: types.LifecycleUnknownCauseCycleNotFound, - }, - { - name: "lifecycle mismatch", - resource: &types.Resource{CurrentVersion: "8.0.35"}, - lifecycle: &types.VersionLifecycle{Version: "5.7"}, - status: types.StatusUnknown, - want: types.LifecycleUnknownCauseLifecycleMismatch, - }, - { - name: "indeterminate lifecycle", - resource: &types.Resource{CurrentVersion: "8.0.35"}, - lifecycle: &types.VersionLifecycle{Version: "8.0"}, - status: types.StatusUnknown, - want: types.LifecycleUnknownCauseIndeterminate, - }, - { - name: "known status has no cause", - resource: &types.Resource{CurrentVersion: "8.0.35"}, - lifecycle: &types.VersionLifecycle{Version: "8.0", IsSupported: true}, - status: types.StatusGreen, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - assert.Equal(t, tt.want, UnknownCause(tt.resource, tt.lifecycle, tt.status)) - }) - } -} -``` - -Nil lifecycle with UNKNOWN must return `unattributed`, not panic. - -- [ ] **Step 5: Run the policy test and verify red state** - -Run: `go test ./pkg/policy -run TestUnknownCause -count=1` - -Expected: compile failure because `UnknownCause` does not exist. - -- [ ] **Step 6: Implement the pure policy helper** - -Add to `pkg/policy/default.go`, reusing the package-private `versionMatches`: - -```go -func UnknownCause( - resource *types.Resource, - lifecycle *types.VersionLifecycle, - status types.Status, -) types.LifecycleUnknownCause { - if status != types.StatusUnknown { - return "" - } - if lifecycle != nil && lifecycle.UnknownCause != "" { - return lifecycle.UnknownCause - } - if resource == nil || lifecycle == nil { - return types.LifecycleUnknownCauseUnattributed - } - if strings.TrimSpace(resource.CurrentVersion) == "" { - return types.LifecycleUnknownCauseEmptyInventoryVersion - } - if strings.TrimSpace(lifecycle.Version) == "" { - return types.LifecycleUnknownCauseCycleNotFound - } - if !versionMatches(lifecycle.Version, resource.CurrentVersion) { - return types.LifecycleUnknownCauseLifecycleMismatch - } - return types.LifecycleUnknownCauseIndeterminate -} -``` - -- [ ] **Step 7: Run focused tests and commit** - -Run: `go test ./pkg/types ./pkg/policy -count=1` - -Expected: PASS. - -```bash -git add pkg/types/resource.go pkg/types/lifecycle_details.go pkg/types/resource_test.go pkg/policy/default.go pkg/policy/default_test.go -git commit -m "feat: define lifecycle unknown attribution" -``` - ---- - -### Task 2: Enrich endoflife.date client responses with bounded source metadata - -**Files:** -- Modify: `pkg/eol/endoflife/client.go` -- Modify: `pkg/eol/endoflife/client_test.go` -- Modify: `pkg/eol/endoflife/mock_client.go` -- Modify: provider tests and fixtures that implement `Client` - -**Interfaces:** -- Consumes: `types.LifecycleDataSource` from Task 1. -- Produces: `ProductCyclesResult{Cycles, DataSource, FetchedAt}` and `EOLSourceHeader`. -- Consumed by: `Provider.ListAllVersions` and provider diagnostic attribution in Task 3. - -- [ ] **Step 1: Write failing client source tests** - -Extend client tests with these cases: - -```go -func TestRealHTTPClient_ProductCyclesResultSource(t *testing.T) { - tests := []struct { - name string - baseURL func(string) string - header string - wantSource types.LifecycleDataSource - }{ - { - name: "custom endpoint with local override header", - baseURL: func(serverURL string) string { return serverURL }, - header: "local_override", - wantSource: types.LifecycleDataSourceLocalOverride, - }, - { - name: "custom endpoint without header", - baseURL: func(serverURL string) string { return serverURL }, - wantSource: types.LifecycleDataSourceUnknown, - }, - { - name: "invalid source header", - baseURL: func(serverURL string) string { return serverURL }, - header: "attacker-controlled-value", - wantSource: types.LifecycleDataSourceUnknown, - }, - } - - // Each server returns [] with the optional source header. Assert the - // result source and that FetchedAt is non-zero. -} -``` - -Update the typed 404 test to assert that result metadata remains available with -the error. Add a direct-constructor unit test against a rewritten test transport -so `NewRealHTTPClient` defaults to `endoflife_date` without making a network -request. - -- [ ] **Step 2: Run client tests and verify red state** - -Run: `go test ./pkg/eol/endoflife -run 'TestRealHTTPClient_(ProductCyclesResultSource|404ReturnsTypedError)' -count=1` - -Expected: compile failures because `GetProductCycles` still returns a slice. - -- [ ] **Step 3: Implement the result envelope and trusted header parser** - -Change the client contract: - -```go -const EOLSourceHeader = "X-Version-Guard-EOL-Source" - -type ProductCyclesResult struct { - Cycles []*ProductCycle - FetchedAt time.Time - DataSource types.LifecycleDataSource -} - -type Client interface { - GetProductCycles(ctx context.Context, product string) (ProductCyclesResult, error) -} -``` - -Add a `defaultDataSource` field to `RealHTTPClient`. The default constructor uses -`endoflife_date`; `NewRealHTTPClientWithConfig` uses `unknown` unless `baseURL` -is empty and falls back to the direct upstream URL. - -Normalize only known header values: - -```go -func lifecycleDataSource(value string, fallback types.LifecycleDataSource) types.LifecycleDataSource { - switch types.LifecycleDataSource(strings.ToLower(strings.TrimSpace(value))) { - case types.LifecycleDataSourceEndOfLifeDate: - return types.LifecycleDataSourceEndOfLifeDate - case types.LifecycleDataSourceLocalOverride: - return types.LifecycleDataSourceLocalOverride - default: - return fallback - } -} -``` - -Create the result before performing the request. Once a response exists, update -its source from the header. Return the result alongside every error, including -404, body read, status, and decode errors. - -- [ ] **Step 4: Update mocks and call sites to the new interface** - -Change `MockClient.GetProductCyclesFunc` and all inline test clients from: - -```go -func(context.Context, string) ([]*ProductCycle, error) -``` - -to: - -```go -func(context.Context, string) (ProductCyclesResult, error) -``` - -Successful fixtures return: - -```go -return ProductCyclesResult{ - Cycles: cycles, - DataSource: types.LifecycleDataSourceEndOfLifeDate, - FetchedAt: time.Now(), -}, nil -``` - -Do not alter provider semantics in this task beyond compiling against -`result.Cycles`; Task 3 adds attribution. - -- [ ] **Step 5: Run focused tests and commit** - -Run: `go test ./pkg/eol/endoflife -count=1` - -Expected: PASS with existing provider behavior preserved. - -```bash -git add pkg/eol/endoflife/client.go pkg/eol/endoflife/client_test.go pkg/eol/endoflife/mock_client.go pkg/eol/endoflife/*_test.go -git commit -m "feat: report lifecycle response source" -``` - ---- - -### Task 3: Attribute provider outcomes precisely - -**Files:** -- Modify: `pkg/eol/provider.go` -- Modify: `pkg/eol/endoflife/provider.go` -- Modify: `pkg/eol/endoflife/provider_test.go` -- Modify: `pkg/eol/endoflife/provider_404_test.go` - -**Interfaces:** -- Consumes: `ProductCyclesResult`, lifecycle cause/source types. -- Produces: provider lifecycles carrying `product_not_found`, `cycle_not_found`, `malformed_cycle`, or `source_error`, including partial diagnostic lifecycle plus error. -- Consumed by: detection activity in Task 4. - -- [ ] **Step 1: Write failing provider attribution tests** - -Add or extend tests for: - -```go -func TestProvider_GetVersionLifecycle_Product404(t *testing.T) { - // Mock returns a local_override result plus wrapped ErrProductNotFound. - // Assert no final error, empty Version, product_not_found, local_override, - // provider Source, and preserved FetchedAt. -} - -func TestProvider_VersionNotFound(t *testing.T) { - // Successful cycles do not match 99.99. - // Assert cycle_not_found and endoflife_date. -} - -func TestProvider_SourceErrorReturnsDiagnosticLifecycle(t *testing.T) { - // Mock returns metadata plus a 500 error. - // Assert lifecycle is non-nil, cause is source_error, source is preserved, - // and error remains non-nil. -} - -func TestProvider_MalformedMatchingCycle(t *testing.T) { - // A cycle "8.0" has eol: "not-a-date" and requested version is 8.0.35. - // Assert malformed_cycle. Add a second malformed unrelated cycle and - // request 9.0; assert cycle_not_found. -} -``` - -Add a valid-cycle-wins case where malformed `8` and valid `8.0` can both prefix -match `8.0.35`; expect the valid lifecycle. - -- [ ] **Step 2: Run provider tests and verify red state** - -Run: `go test ./pkg/eol/endoflife -run 'TestProvider_(GetVersionLifecycle_Product404|VersionNotFound|SourceErrorReturnsDiagnosticLifecycle|MalformedMatchingCycle)' -count=1` - -Expected: assertion failures because causes/source diagnostics are absent. - -- [ ] **Step 3: Extend cached product metadata** - -Change the cache entry to retain source/fetch/cause and malformed cycle IDs: - -```go -type cachedVersions struct { - versions []*types.VersionLifecycle - malformedCycles []string - fetchedAt time.Time - dataSource types.LifecycleDataSource - productCause types.LifecycleUnknownCause -} -``` - -Return a copy of the matching valid lifecycle with response metadata applied. -For an absent match, inspect `malformedCycles` with the same exact/prefix match -rules used for valid cycles and return a new diagnostic lifecycle. - -- [ ] **Step 4: Add narrow ProductCycle validation** - -Before adapter conversion, reject nil cycles, blank cycle IDs, and invalid -date-or-boolean strings for `support`, `eol`, `extendedSupport`, and `lts`: - -```go -func ValidateProductCycle(cycle *ProductCycle) error { - if cycle == nil { - return errors.New("cycle is nil") - } - if strings.TrimSpace(cycle.Cycle) == "" { - return errors.New("cycle identifier is empty") - } - for name, value := range map[string]any{ - "support": cycle.Support, - "eol": cycle.EOL, - "extendedSupport": cycle.ExtendedSupport, - "lts": cycle.LTS, - } { - if err := validateDateOrBoolean(value); err != nil { - return errors.Wrapf(err, "%s for cycle %q", name, cycle.Cycle) - } - } - return nil -} -``` - -`ValidateProductCycle` is exported so the override manifest validator can reuse -the runtime contract without duplicating it. `validateDateOrBoolean` accepts -nil, booleans, empty strings, `"true"`, -`"false"`, and strict `YYYY-MM-DD`; it rejects all other types and strings. -Record a rejected non-empty cycle ID instead of adding it to valid versions. - -- [ ] **Step 5: Preserve partial source failures and product 404 metadata** - -For 404, cache an empty entry with `product_not_found` and return it as graceful -UNKNOWN. For non-404 errors, return a lifecycle and the wrapped error: - -```go -return &types.VersionLifecycle{ - Engine: engine, - Source: p.Name(), - DataSource: result.DataSource, - FetchedAt: result.FetchedAt, - UnknownCause: types.LifecycleUnknownCauseSourceError, -}, errors.Wrapf(err, "failed to fetch cycles for product %s", product) -``` - -Update the `eol.Provider` interface comment to state that implementations may -return a non-nil diagnostic lifecycle with a non-nil error and callers should -preserve it. - -- [ ] **Step 6: Run focused tests and commit** - -Run: `go test ./pkg/eol/... -count=1` - -Expected: PASS. - -```bash -git add pkg/eol/provider.go pkg/eol/endoflife/provider.go pkg/eol/endoflife/provider_test.go pkg/eol/endoflife/provider_404_test.go -git commit -m "feat: attribute lifecycle provider failures" -``` - ---- - -### Task 4: Preserve diagnostics in findings and expose bounded metrics - -**Files:** -- Modify: `pkg/workflow/detection/activities.go` -- Modify: `pkg/workflow/detection/activities_test.go` -- Modify: `pkg/telemetry/metrics.go` -- Modify: `pkg/telemetry/metrics_test.go` -- Modify: `pkg/snapshot/builder_test.go` - -**Interfaces:** -- Consumes: provider diagnostic lifecycle, `policy.UnknownCause`, known cause/source lists. -- Produces: finding `eol.unknown_cause`/`eol.data_source`, `RecordDetectionBreakdown(resourceType, unknownCounts, sourceCounts)`, and two bounded gauge families. - -- [ ] **Step 1: Write failing detection tests** - -Add a counting provider test double and cover: - -```go -func TestFetchEOLData_EmptyVersionDoesNotCallProvider(t *testing.T) { - // Resource CurrentVersion is whitespace. - // Assert provider call count is zero and lifecycle cause is - // empty_inventory_version with data_source unknown. -} - -func TestFetchEOLData_PreservesDiagnosticLifecycleOnError(t *testing.T) { - // Provider returns a source_error lifecycle and error. - // Assert the lifecycle remains in VersionLifecycles. -} - -func TestDetectDrift_AnnotatesLifecycleCopy(t *testing.T) { - // Pass a mismatch lifecycle pointer. - // Assert finding cause is lifecycle_mismatch and original pointer cause - // remains empty. -} -``` - -Extend lifecycle detail propagation assertions to include source and cause. - -- [ ] **Step 2: Run detection tests and verify red state** - -Run: `go test ./pkg/workflow/detection -run 'Test(FetchEOLData|DetectDrift).*' -count=1` - -Expected: new attribution assertions fail. - -- [ ] **Step 3: Implement detection preservation and copy annotation** - -In `FetchEOLData`, synthesize empty-version diagnostics before calling the -provider. On provider errors, retain a non-nil lifecycle; if nil, create: - -```go -lifecycle = &types.VersionLifecycle{ - Engine: resource.Engine, - Source: provider.Name(), - DataSource: types.LifecycleDataSourceUnknown, - UnknownCause: types.LifecycleUnknownCauseSourceError, -} -``` - -In `DetectDrift`, copy before annotating: - -```go -annotated := *lifecycle -status := a.Policy.Classify(resource, &annotated) -annotated.UnknownCause = policy.UnknownCause(resource, &annotated, status) -lifecycleDetails := types.LifecycleDetailsFromVersionLifecycle(&annotated) -``` - -For known statuses, the helper clears the cause. - -- [ ] **Step 4: Write failing metric tests** - -Add exact OpenMetrics expectations: - -```go -func TestRecordDetectionBreakdown(t *testing.T) { - ResetForTest() - RecordDetectionBreakdown( - "aurora-mysql", - map[types.LifecycleUnknownCause]int{ - types.LifecycleUnknownCauseCycleNotFound: 2, - }, - map[types.LifecycleDataSource]int{ - types.LifecycleDataSourceLocalOverride: 3, - }, - ) - - // CollectAndCompare asserts only resource_type,cause on the first gauge - // and resource_type,source on the second. Every known value exists; all - // unobserved values are zero. -} -``` - -Add a second-call test that records a nonzero cause, then records empty maps and -asserts the former series is zero. Add invalid/empty inputs and assert they roll -into `unattributed`/`unknown`. - -- [ ] **Step 5: Run telemetry tests and verify red state** - -Run: `go test ./pkg/telemetry -run TestRecordDetectionBreakdown -count=1` - -Expected: compile failure because metrics and recorder do not exist. - -- [ ] **Step 6: Implement bounded gauges and normalization** - -Add and register: - -```go -detectionUnknownResources = prometheus.NewGaugeVec(prometheus.GaugeOpts{ - Name: "version_guard_detection_unknown_resources", - Help: "Latest Version Guard UNKNOWN resource counts by resource type and cause.", -}, []string{"resource_type", "cause"}) - -detectionLifecycleResources = prometheus.NewGaugeVec(prometheus.GaugeOpts{ - Name: "version_guard_detection_lifecycle_resources", - Help: "Latest Version Guard detection resource counts by resource type and lifecycle data source.", -}, []string{"resource_type", "source"}) -``` - -`RecordDetectionBreakdown` first sets every known cause/source to zero, then -records normalized observed values. Add both vectors to `Register` and -`ResetForTest`. - -- [ ] **Step 7: Derive breakdowns from findings** - -In `EmitMetrics`, build maps while calculating the existing summary: - -```go -unknownCounts := make(map[types.LifecycleUnknownCause]int) -sourceCounts := make(map[types.LifecycleDataSource]int) -for _, finding := range findings { - sourceCounts[finding.EOL.DataSource]++ - if finding.Status == types.StatusUnknown { - unknownCounts[finding.EOL.UnknownCause]++ - } -} -telemetry.RecordDetectionSummary(input.ResourceType, summary) -telemetry.RecordDetectionBreakdown(input.ResourceType, unknownCounts, sourceCounts) -``` - -Normalization stays inside telemetry so all callers receive cardinality safety. - -- [ ] **Step 8: Lock the additive snapshot contract** - -Extend `TestBuilder_CurrentSchemaBreakWireShape` with an UNKNOWN finding and: - -```go -assert.Equal(t, "cycle_not_found", eol["unknown_cause"]) -assert.Equal(t, "local_override", eol["data_source"]) -assert.Equal(t, "aurora-postgresql", eol["engine"]) -``` - -Keep the expected snapshot version at `v4`. - -- [ ] **Step 9: Run focused tests and commit** - -Run: `go test ./pkg/workflow/detection ./pkg/telemetry ./pkg/snapshot -count=1` - -Expected: PASS. - -```bash -git add pkg/workflow/detection/activities.go pkg/workflow/detection/activities_test.go pkg/telemetry/metrics.go pkg/telemetry/metrics_test.go pkg/snapshot/builder_test.go -git commit -m "feat: expose lifecycle attribution metrics" -``` - ---- - -### Task 5: Add override source headers and manifest governance - -**Files:** -- Modify: `deploy/endoflife-override/nginx.conf` -- Modify: `deploy/endoflife-override/README.md` -- Create: `deploy/endoflife-override/manifest.json` -- Create: `deploy/endoflife-override/manifest.go` -- Create: `deploy/endoflife-override/manifest_test.go` -- Create: `deploy/endoflife-override/nginx_test.go` - -**Interfaces:** -- Consumes: `EOLSourceHeader` values `local_override` and `endoflife_date`. -- Produces: validated manifest schema version 1 and authoritative nginx source headers. - -- [ ] **Step 1: Write failing manifest validation tests** - -Implement tests around an internal function with injected filesystem root, UTC -date, and warning writer: - -```go -func validateManifest(root string, now time.Time, warnings io.Writer) error -``` - -Table cases must cover: - -```go -tests := []struct { - name string - mutate func(root string) - wantErr string - wantWarning string -}{ - {name: "valid manifest"}, - {name: "duplicate product", mutate: duplicateProduct, wantErr: "duplicate product"}, - {name: "missing API file entry", mutate: addUnlistedAPIFile, wantErr: "has no manifest entry"}, - {name: "entry references missing file", mutate: removeReferencedFile, wantErr: "does not exist"}, - {name: "invalid source URL", mutate: useHTTPSourceURL, wantErr: "must use https"}, - {name: "invalid review date", mutate: useInvalidDate, wantErr: "YYYY-MM-DD"}, - {name: "review interval over 30 days", mutate: extendReviewInterval, wantErr: "exceeds 30 days"}, - {name: "overdue review warns", mutate: makeOverdue, wantWarning: "review overdue"}, -} -``` - -The overdue case must assert `NoError` and warning output. Use `t.TempDir()` and -copy fixture files; never mutate repository fixtures during tests. - -Add a repository-fixture test so normal `go test ./...` validates the checked-in -manifest on every CI run while preserving warn-only expiry behavior: - -```go -func TestRepositoryManifest(t *testing.T) { - var warnings bytes.Buffer - require.NoError(t, validateManifest(".", time.Now().UTC(), &warnings)) - if warnings.Len() > 0 { - t.Log(strings.TrimSpace(warnings.String())) - } -} -``` - -- [ ] **Step 2: Run manifest tests and verify red state** - -Run: `go test ./deploy/endoflife-override -run TestValidateManifest -count=1` - -Expected: compile failure because validator does not exist. - -- [ ] **Step 3: Add the schema-versioned manifest** - -Create `manifest.json`: - -```json -{ - "schema_version": 1, - "overrides": [ - { - "product": "amazon-aurora-mysql", - "path": "api/amazon-aurora-mysql.json", - "reason": "Product pending upstream inclusion", - "owner": "@block/block-platform", - "source_url": "https://github.com/endoflife-date/endoflife.date/pull/9534", - "reviewed_on": "2026-08-05", - "review_due_on": "2026-09-04" - }, - { - "product": "amazon-opensearch", - "path": "api/amazon-opensearch.json", - "reason": "Required cycles are missing upstream", - "owner": "@block/block-platform", - "source_url": "https://github.com/endoflife-date/endoflife.date/pull/9919", - "reviewed_on": "2026-08-05", - "review_due_on": "2026-09-04" - } - ] -} -``` - -- [ ] **Step 4: Implement deterministic validation** - -Define private manifest structs and validate: - -1. `schema_version == 1`. -2. Required strings are non-empty. -3. Products and paths are unique. -4. `source_url` parses and uses HTTPS. -5. Dates use strict `2006-01-02` and due date is not before review date or more - than 30 days after it. -6. Every manifest path exists under root and stays under `api/`. -7. Every `api/*.json` has exactly one manifest entry and vice versa. -8. Each API file is a top-level `[]ProductCycle`; every cycle passes the same - exported or package-shared lifecycle validation used by the provider. -9. `now.UTC()` after the due date writes a warning and does not return an error. - -Avoid copying lifecycle validation logic: expose the smallest reusable -`endoflife.ValidateProductCycle` function from Task 3 and call it here. - -- [ ] **Step 5: Write and pass nginx contract tests** - -The test reads `nginx.conf` and asserts the local location and named upstream -location each own one trusted header statement, and the upstream location hides -incoming copies: - -```go -assert.Contains(t, config, "add_header X-Version-Guard-EOL-Source local_override always;") -assert.Contains(t, config, "proxy_hide_header X-Version-Guard-EOL-Source;") -assert.Contains(t, config, "add_header X-Version-Guard-EOL-Source endoflife_date always;") -``` - -Then update nginx: - -```nginx -location /api/ { - root /data; - try_files $uri @upstream; - add_header X-Version-Guard-EOL-Source local_override always; -} - -location @upstream { - proxy_pass https://endoflife.date; - proxy_set_header Host endoflife.date; - proxy_set_header User-Agent "version-guard/1.0"; - proxy_ssl_server_name on; - proxy_hide_header X-Version-Guard-EOL-Source; - add_header X-Version-Guard-EOL-Source endoflife_date always; -} -``` - -- [ ] **Step 6: Update override operating documentation** - -Replace the hand-maintained override table with instructions to update -`manifest.json` whenever adding, reviewing, or removing an override. State: - -- review interval is at most 30 days; -- due-date expiration warns but does not fail CI; -- malformed/missing metadata still fails validation; -- source URL and owner are required; -- nginx source headers flow into snapshot findings and source metrics. - -- [ ] **Step 7: Run focused tests and commit** - -Run: `go test ./deploy/endoflife-override ./pkg/eol/endoflife -count=1` - -Expected: PASS. The real manifest may print no warning because its due date is -in the future on 2026-08-05. - -```bash -git add deploy/endoflife-override pkg/eol/endoflife/provider.go -git commit -m "feat: govern local lifecycle overrides" -``` - ---- - -### Task 6: Integrate documentation and run repository verification - -**Files:** -- Modify: `README.md` -- Modify: `ARCHITECTURE.md` -- Modify: `USAGE.md` -- Modify: relevant files from Tasks 1–5 only if verification finds defects - -**Interfaces:** -- Consumes: final metric names, lifecycle fields, cause/source values, and manifest workflow. -- Produces: user-facing operating contract aligned with implemented behavior. - -- [ ] **Step 1: Update metric and UNKNOWN documentation** - -Document both new metrics with their exact labels. Replace claims that UNKNOWN -means only “version not found” with the bounded cause list. Explain that -snapshots contain `eol.unknown_cause`, `eol.data_source`, engine, and version for -drill-down while Prometheus does not label engine/version. - -- [ ] **Step 2: Update override and architecture documentation** - -Document that direct upstream responses resolve to `endoflife_date`, nginx local -files to `local_override`, and untrusted/custom endpoints without the header to -`unknown`. Link to the override manifest and validation policy. - -- [ ] **Step 3: Run formatting** - -Run: `make fmt-all` - -Expected: exit 0. Review `git diff` and ensure formatting did not alter unrelated -files. - -- [ ] **Step 4: Run targeted race-sensitive packages** - -Run: `go test -race ./pkg/eol/endoflife ./pkg/workflow/detection ./pkg/telemetry ./deploy/endoflife-override -count=1` - -Expected: PASS with no race reports. - -- [ ] **Step 5: Run repository test suite** - -Run: `make test` - -Expected: all packages PASS. - -- [ ] **Step 6: Run repository lint/check target** - -Run: `make check` - -Expected: build, tests, formatting checks, and lint pass. If chart files are -unchanged, chart-testing is not required. - -- [ ] **Step 7: Inspect the final diff for compatibility and scope** - -Run: - -```bash -git diff --check origin/main...HEAD -git diff --stat origin/main...HEAD -git status --short -``` - -Expected: no whitespace errors; changes are limited to the design/plan, -lifecycle attribution, metrics, override governance, tests, and aligned docs. - -- [ ] **Step 8: Commit final documentation or verification fixes** - -```bash -git add README.md ARCHITECTURE.md USAGE.md -git commit -m "docs: explain lifecycle unknown diagnostics" -``` - -If verification required code fixes, include only those related files and use a -message describing the corrected behavior. - -- [ ] **Step 9: Perform one independent full-diff review before PR creation** - -Review `git diff origin/main...HEAD` for cause precedence, stale metrics, cache -mutation, arbitrary label values, Temporal payload compatibility, manifest -warning semantics, and unrelated edits. Apply and verify any concrete fixes in -one final commit before pushing. diff --git a/docs/superpowers/specs/2026-08-05-lifecycle-unknown-attribution-design.md b/docs/superpowers/specs/2026-08-05-lifecycle-unknown-attribution-design.md deleted file mode 100644 index 5e949b2..0000000 --- a/docs/superpowers/specs/2026-08-05-lifecycle-unknown-attribution-design.md +++ /dev/null @@ -1,148 +0,0 @@ -# Lifecycle UNKNOWN Attribution and Override Provenance - -## Goal - -Make lifecycle `UNKNOWN` findings actionable without introducing unbounded -Prometheus labels. Operators must be able to distinguish unsupported products, -missing or malformed cycles, source failures, empty inventory versions, and -classification gaps. Local endoflife.date overrides must also identify their -origin and carry machine-readable ownership and review metadata. - -## Scope - -This change covers the endoflife.date client and provider, detection findings, -snapshot drill-down, application metrics, the local nginx override, and local -override metadata validation. It preserves existing graceful scan behavior and -does not add engine, version, product, URL, owner, or error text to metric -labels. - -Overdue override reviews produce warnings only. Invalid manifests, missing -files, malformed lifecycle data, or inconsistent metadata remain validation -errors. - -## Lifecycle attribution model - -Add closed string types to the lifecycle domain: - -- Unknown causes: `product_not_found`, `cycle_not_found`, `source_error`, - `malformed_cycle`, `empty_inventory_version`, `lifecycle_mismatch`, - `indeterminate_lifecycle`, and compatibility fallback `unattributed`. -- Data sources: `endoflife_date`, `local_override`, and `unknown`. - -`VersionLifecycle` gains `UnknownCause` and `DataSource`. The existing `Source` -field remains the provider identity (`endoflife-date-api`) for compatibility. -`LifecycleDetails` gains optional `unknown_cause` and `data_source` fields so -each snapshot finding retains cause, source, engine, and version together. - -Provider attribution takes precedence. For an `UNKNOWN` classification without -a provider cause, detection assigns the cause as follows: - -1. Blank inventory version: `empty_inventory_version`. -2. Blank lifecycle version: `cycle_not_found`. -3. Lifecycle version does not match inventory version: `lifecycle_mismatch`. -4. Matching lifecycle has no RED, YELLOW, or GREEN signal: - `indeterminate_lifecycle`. -5. An old or invalid payload that cannot be classified: `unattributed`. - -Detection annotates a copy of the lifecycle value. Cached provider lifecycle -pointers are never mutated. - -## Client and provider flow - -The endoflife.date client returns product cycles plus bounded response metadata: -data source and fetch timestamp. The direct upstream client defaults to -`endoflife_date`; a custom endpoint defaults to `unknown`. A trusted -`X-Version-Guard-EOL-Source` response header may select `endoflife_date` or -`local_override`; arbitrary values normalize to `unknown`. - -Response metadata is retained on errors. The provider maps outcomes as follows: - -| Outcome | Cause | -| --- | --- | -| Product HTTP 404 | `product_not_found` | -| Successful response without a matching cycle | `cycle_not_found` | -| Transport, non-404 HTTP, or response decode failure | `source_error` | -| Matching cycle fails lifecycle validation/adaptation | `malformed_cycle` | - -Malformed-cycle attribution is precise: the provider tracks rejected cycle -identifiers and emits `malformed_cycle` only when a rejected cycle matches the -requested inventory version. An unrelated malformed cycle does not change a -missing version from `cycle_not_found`. - -Providers may return partial lifecycle diagnostics with a non-nil error. -`FetchEOLData` retains that lifecycle while logging the error. If another -provider returns only an error, the activity creates a bounded `source_error` -lifecycle instead of dropping the lookup entirely. - -## Override source and provenance - -The nginx override adds `X-Version-Guard-EOL-Source: local_override` when a -static override file is served and `endoflife_date` when a request is proxied. -It hides any upstream copy of that header before setting its own value. - -`deploy/endoflife-override/manifest.json` contains one entry per override: - -- `product` -- `path` -- `reason` -- `owner` -- `source_url` -- `reviewed_on` -- `review_due_on` - -The manifest has `schema_version: 1`. Validation requires unique products and -paths, HTTPS source URLs, strict `YYYY-MM-DD` dates, a review due date no more -than 30 calendar days after review, a one-to-one relationship between manifest -entries and `api/*.json`, and valid lifecycle arrays with non-empty cycle IDs. -The UTC due date itself is valid. A date after `review_due_on` emits a warning -but does not fail tests or CI. - -Validation is local and deterministic apart from the injected current date. It -does not call upstream URLs. Review means confirming whether the upstream source -has landed or changed and whether the local JSON remains necessary and accurate. - -## Metrics - -Keep the existing `version_guard_detection_resources` metric unchanged and add: - -- `version_guard_detection_unknown_resources{resource_type,cause}`: latest - count of UNKNOWN findings by closed cause. -- `version_guard_detection_lifecycle_resources{resource_type,source}`: latest - count of findings by closed lifecycle data source. - -All known cause and source series are reset to zero on every resource-type scan -before observed values are recorded, preventing stale gauge values. Empty or -invalid UNKNOWN causes normalize to `unattributed`; empty or invalid sources -normalize to `unknown`. - -The detailed drill-down remains in snapshot findings. No aggregate -engine/version report or metric is added. - -## Compatibility - -Activity names, workflow ordering, and activity input/output types remain -unchanged. New fields travel through the existing lifecycle map and finding EOL -block. They are additive and zero-value compatible with old Temporal payloads, -so no workflow version patch is required. - -The snapshot remains schema `v4`: the optional fields extend the existing `eol` -object and do not alter the top-level contract. Existing `Source` values remain -unchanged. - -## Verification - -Tests cover: - -- Product 404, missing cycle, source error, malformed matching cycle, and blank - inventory version. -- Lifecycle mismatch, indeterminate lifecycle, provider-cause precedence, and - compatibility fallback. -- Upstream, local-override, custom/unknown, and invalid-header source handling. -- Cause/source propagation into findings and snapshot JSON. -- Exact metric labels and counts, including zero-reset behavior. -- Manifest parsing, duplicate or missing entries/files, invalid URLs or dates, - review intervals over 30 days, and overdue warning behavior. -- Nginx configuration contract for local and proxied source headers. - -Focused package tests run during implementation, followed by `make test` and the -repository's relevant format/lint checks before handoff. diff --git a/pkg/eol/endoflife/provider.go b/pkg/eol/endoflife/provider.go index 93244c3..f5cd628 100644 --- a/pkg/eol/endoflife/provider.go +++ b/pkg/eol/endoflife/provider.go @@ -124,9 +124,8 @@ func (p *Provider) Engines() []string { // on the returned VersionLifecycle for downstream display; product // resolution comes from p.product, set at construction time. // -// Concurrency note: this function MUST NOT mutate the *VersionLifecycle -// pointers it gets back from ListAllVersions — those are shared across -// concurrent callers via the cache. +// Concurrency note: cached lifecycle pointers are immutable. Public methods +// return copies with caller-facing metadata applied. func (p *Provider) GetVersionLifecycle(ctx context.Context, engine, version string) (*types.VersionLifecycle, error) { engine = strings.ToLower(engine) version = strings.TrimSpace(version) @@ -134,7 +133,7 @@ func (p *Provider) GetVersionLifecycle(ctx context.Context, engine, version stri cached, err := p.loadVersions(ctx, engine) if err != nil { return &types.VersionLifecycle{ - Engine: engine, Source: p.Name(), DataSource: cached.dataSource, + Version: version, Engine: engine, Source: p.Name(), DataSource: cached.dataSource, FetchedAt: cached.fetchedAt, UnknownCause: types.LifecycleUnknownCauseSourceError, }, err } @@ -218,7 +217,11 @@ func (p *Provider) ListAllVersions(ctx context.Context, engine string) ([]*types if err != nil { return nil, err } - return cached.versions, nil + versions := make([]*types.VersionLifecycle, len(cached.versions)) + for i, lifecycle := range cached.versions { + versions[i] = lifecycleWithMetadata(lifecycle, engine, cached) + } + return versions, nil } func (p *Provider) loadVersions(ctx context.Context, engine string) (*cachedVersions, error) { @@ -323,6 +326,11 @@ func ValidateProductCycle(cycle *ProductCycle) error { if strings.TrimSpace(cycle.Cycle) == "" { return errors.New("cycle identifier is empty") } + for name, value := range map[string]string{"releaseDate": cycle.ReleaseDate, "latestReleaseDate": cycle.LatestReleaseDate} { + if err := validateOptionalDate(value); err != nil { + return errors.Wrapf(err, "%s for cycle %q", name, cycle.Cycle) + } + } for name, value := range map[string]any{"support": cycle.Support, "eol": cycle.EOL, "extendedSupport": cycle.ExtendedSupport, "lts": cycle.LTS} { if err := validateDateOrBoolean(value); err != nil { return errors.Wrapf(err, "%s for cycle %q", name, cycle.Cycle) @@ -331,6 +339,17 @@ func ValidateProductCycle(cycle *ProductCycle) error { return nil } +func validateOptionalDate(value string) error { + if value == "" { + return nil + } + parsed, err := time.Parse("2006-01-02", value) + if err != nil || parsed.Format("2006-01-02") != value { + return errors.New("must use YYYY-MM-DD") + } + return nil +} + //nolint:goconst // These strings are the accepted wire representations, not domain constants. func validateDateOrBoolean(value any) error { switch value := value.(type) { diff --git a/pkg/eol/endoflife/provider_test.go b/pkg/eol/endoflife/provider_test.go index 2b50e9e..a980978 100644 --- a/pkg/eol/endoflife/provider_test.go +++ b/pkg/eol/endoflife/provider_test.go @@ -142,9 +142,10 @@ func TestProvider_GetVersionLifecycle_PostgreSQL(t *testing.T) { } func TestProvider_ListAllVersions(t *testing.T) { + fetchedAt := time.Date(2026, time.August, 5, 14, 0, 0, 0, time.UTC) mockClient := &MockClient{ GetProductCyclesFunc: func(ctx context.Context, product string) (ProductCyclesResult, error) { - return productCyclesResult([]*ProductCycle{ + return ProductCyclesResult{Cycles: []*ProductCycle{ { Cycle: "16.2", ReleaseDate: "2024-05-09", @@ -157,7 +158,7 @@ func TestProvider_ListAllVersions(t *testing.T) { Support: "2027-11-11", EOL: "2027-11-11", }, - }), nil + }, DataSource: types.LifecycleDataSourceLocalOverride, FetchedAt: fetchedAt}, nil }, } @@ -182,6 +183,23 @@ func TestProvider_ListAllVersions(t *testing.T) { if versions[0].Source != "endoflife-date-api" { t.Errorf("Source = %s, want endoflife-date-api", versions[0].Source) } + if versions[0].DataSource != types.LifecycleDataSourceLocalOverride || !versions[0].FetchedAt.Equal(fetchedAt) { + t.Errorf("metadata = (%q, %v), want (%q, %v)", versions[0].DataSource, versions[0].FetchedAt, types.LifecycleDataSourceLocalOverride, fetchedAt) + } + + versions[0].Version = "mutated" + versions[0].Engine = "mutated" + versions[0].DataSource = types.LifecycleDataSourceUnknown + versionsAgain, err := provider.ListAllVersions(context.Background(), "postgres") + if err != nil { + t.Fatalf("second ListAllVersions() error = %v", err) + } + if versionsAgain[0].Version != "16.2" || versionsAgain[0].Engine != "postgres" || versionsAgain[0].DataSource != types.LifecycleDataSourceLocalOverride { + t.Errorf("returned mutation leaked into cache: %#v", versionsAgain[0]) + } + if versionsAgain[0] == versions[0] { + t.Error("ListAllVersions returned the same lifecycle pointer across calls") + } } func TestProvider_Caching(t *testing.T) { @@ -324,6 +342,9 @@ func TestProvider_SourceErrorReturnsDiagnosticLifecycle(t *testing.T) { if lifecycle == nil || lifecycle.UnknownCause != types.LifecycleUnknownCauseSourceError { t.Fatalf("lifecycle = %#v, want source_error diagnostic", lifecycle) } + if lifecycle.Version != "8.0" || lifecycle.Engine != "mysql" { + t.Errorf("diagnostic inventory identity not preserved: %#v", lifecycle) + } if lifecycle.DataSource != types.LifecycleDataSourceLocalOverride || !lifecycle.FetchedAt.Equal(fetchedAt) { t.Errorf("diagnostic metadata not preserved: %#v", lifecycle) } @@ -395,7 +416,15 @@ func TestProvider_ValidCycleWinsOverMalformedPrefix(t *testing.T) { } func TestValidateProductCycle(t *testing.T) { - invalid := []*ProductCycle{nil, {}, {Cycle: " "}, {Cycle: "8", Support: 42}, {Cycle: "8", EOL: "2026-1-01"}} + invalid := []*ProductCycle{ + nil, + {}, + {Cycle: " "}, + {Cycle: "8", Support: 42}, + {Cycle: "8", EOL: "2026-1-01"}, + {Cycle: "8", ReleaseDate: "2026-1-01"}, + {Cycle: "8", LatestReleaseDate: "not-a-date"}, + } for _, cycle := range invalid { if err := ValidateProductCycle(cycle); err == nil { t.Errorf("ValidateProductCycle(%#v) = nil, want error", cycle) diff --git a/pkg/workflow/detection/activities_test.go b/pkg/workflow/detection/activities_test.go index 59e8015..08c52f1 100644 --- a/pkg/workflow/detection/activities_test.go +++ b/pkg/workflow/detection/activities_test.go @@ -264,7 +264,7 @@ func TestFetchEOLData_EmptyVersionDoesNotCallProvider(t *testing.T) { func TestFetchEOLData_PreservesDiagnosticLifecycleOnError(t *testing.T) { diagnostic := &types.VersionLifecycle{ - Engine: "aurora-mysql", Source: "endoflife-date-api", + Version: "8.0.35", Engine: "aurora-mysql", Source: "endoflife-date-api", DataSource: types.LifecycleDataSourceEndOfLifeDate, UnknownCause: types.LifecycleUnknownCauseSourceError, } @@ -286,6 +286,25 @@ func TestFetchEOLData_PreservesDiagnosticLifecycleOnError(t *testing.T) { var output EOLResult require.NoError(t, result.Get(&output)) assert.Equal(t, diagnostic, output.VersionLifecycles["aurora-mysql:8.0.35"]) + + detectEnv := newActivityEnv() + detectEnv.RegisterActivity(act.DetectDrift) + detectResult, err := detectEnv.ExecuteActivity(act.DetectDrift, DetectInput{ + Resources: resourcesForDiagnosticVersion(), VersionLifecycles: output.VersionLifecycles, + }) + require.NoError(t, err) + var detected DetectResult + require.NoError(t, detectResult.Get(&detected)) + require.Len(t, detected.Findings, 1) + assert.Equal(t, "8.0.35", detected.Findings[0].EOL.Version) + assert.Equal(t, "aurora-mysql", detected.Findings[0].EOL.Engine) +} + +func resourcesForDiagnosticVersion() []*types.Resource { + return []*types.Resource{{ + ID: "diagnostic", Type: types.ResourceTypeAurora, + Engine: "aurora-mysql", CurrentVersion: "8.0.35", + }} } func TestFetchEOLData_NilLifecycleWithoutErrorIsUnattributed(t *testing.T) {