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/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..e477666 --- /dev/null +++ b/deploy/endoflife-override/manifest.go @@ -0,0 +1,231 @@ +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 { + Overrides []manifestOverride `json:"overrides"` + SchemaVersion int `json:"schema_version"` +} + +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 + } + + 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 { + override := &m.Overrides[index] + if validationErr := validateOverride(root, override, now.UTC(), warnings, products, paths); validationErr != nil { + return fmt.Errorf("override %d: %w", index, validationErr) + } + } + + apiFiles, err := filepath.Glob(filepath.Join(apiDirectory, "*.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 +} + +//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 + 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.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)) + 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) + } + 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.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) + } + 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..df365d8 --- /dev/null +++ b/deploy/endoflife-override/manifest_test.go @@ -0,0 +1,175 @@ +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: "unsupported schema version", mutate: mutateManifest(func(m *manifest) { m.SchemaVersion = 2 }), wantErr: "schema_version must be 1"}, + {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"}, + {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 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: "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: "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) + 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"}, + {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"}, + {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"}, + } + + 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 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)) + 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] +} diff --git a/pkg/eol/endoflife/client.go b/pkg/eol/endoflife/client.go index e7f3a0f..b622764 100644 --- a/pkg/eol/endoflife/client.go +++ b/pkg/eol/endoflife/client.go @@ -6,9 +6,12 @@ import ( "fmt" "io" "net/http" + "strings" "time" "github.com/pkg/errors" + + "github.com/block/Version-Guard/pkg/types" ) const ( @@ -17,6 +20,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,13 +39,20 @@ 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. +// +//nolint:govet // Field order groups the response payload before its attribution metadata. +type ProductCyclesResult struct { + Cycles []*ProductCycle + FetchedAt time.Time + DataSource types.LifecycleDataSource } // 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) @@ -53,8 +66,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 +77,53 @@ 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 { + 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 types.LifecycleDataSourceUnknown } } // 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 +131,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 +142,29 @@ 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") + 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 cycles, nil + return result, nil } diff --git a/pkg/eol/endoflife/client_test.go b/pkg/eol/endoflife/client_test.go index 8999b46..b225c39 100644 --- a/pkg/eol/endoflife/client_test.go +++ b/pkg/eol/endoflife/client_test.go @@ -3,12 +3,25 @@ package endoflife import ( "context" "errors" + "io" "net/http" "net/http/httptest" + "strings" "testing" "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "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 +96,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 +104,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 +133,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 +148,139 @@ 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 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_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, + }, + } + + 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") + } + }) + } } 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..f5cd628 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 @@ -119,17 +124,18 @@ 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) - // Fetch all versions - versions, err := p.ListAllVersions(ctx, engine) + cached, err := p.loadVersions(ctx, engine) if err != nil { - return nil, err + return &types.VersionLifecycle{ + Version: version, 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 +143,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 +154,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 +176,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 +213,18 @@ 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 + } + 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) { product := p.product // Use product as cache key @@ -188,10 +233,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 +243,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) + 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 +257,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 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 +299,72 @@ 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]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) + } + } + 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) { + 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 383b1fc..fa6e8e8 100644 --- a/pkg/eol/endoflife/provider_404_test.go +++ b/pkg/eol/endoflife/provider_404_test.go @@ -5,10 +5,13 @@ import ( "errors" "sync/atomic" "testing" + "time" 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 @@ -16,9 +19,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) ([]*ProductCycle, error) { - return nil, pkgerrors.Wrapf(ErrProductNotFound, "product %q", product) + GetProductCyclesFunc: func(_ context.Context, product string) (ProductCyclesResult, error) { + return ProductCyclesResult{DataSource: types.LifecycleDataSourceLocalOverride, FetchedAt: fetchedAt}, pkgerrors.Wrapf(ErrProductNotFound, "product %q", product) }, } @@ -34,14 +38,18 @@ 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 // 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 +69,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 +102,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..a980978 100644 --- a/pkg/eol/endoflife/provider_test.go +++ b/pkg/eol/endoflife/provider_test.go @@ -2,6 +2,9 @@ package endoflife import ( "context" + "errors" + "net/http" + "net/http/httptest" "strings" "sync" "testing" @@ -10,15 +13,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 +54,7 @@ func TestProvider_GetVersionLifecycle_PostgreSQL(t *testing.T) { EOL: "2024-11-14", // Past (before 2026-04-08) ExtendedSupport: false, }, - }, nil + }), nil }, } @@ -131,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) ([]*ProductCycle, error) { - return []*ProductCycle{ + GetProductCyclesFunc: func(ctx context.Context, product string) (ProductCyclesResult, error) { + return ProductCyclesResult{Cycles: []*ProductCycle{ { Cycle: "16.2", ReleaseDate: "2024-05-09", @@ -146,7 +158,7 @@ func TestProvider_ListAllVersions(t *testing.T) { Support: "2027-11-11", EOL: "2027-11-11", }, - }, nil + }, DataSource: types.LifecycleDataSourceLocalOverride, FetchedAt: fetchedAt}, nil }, } @@ -171,21 +183,38 @@ 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) { 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 +251,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 }, } @@ -261,16 +290,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) ([]*ProductCycle, error) { - return []*ProductCycle{ + GetProductCyclesFunc: func(ctx context.Context, product string) (ProductCyclesResult, error) { + 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 }, } @@ -291,6 +321,121 @@ 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.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) + } +} + +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 + 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"}, + {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) + } + } + 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) { @@ -319,18 +464,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 +715,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 +749,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) 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 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/snapshot/builder_test.go b/pkg/snapshot/builder_test.go index 2ac0a7f..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", @@ -181,9 +181,11 @@ func TestBuilder_CurrentSchemaBreakWireShape(t *testing.T) { Version: "13", Engine: "aurora-postgresql", Source: "endoflife-date-api", - IsSupported: true, - IsDeprecated: true, - IsExtendedSupport: true, + DataSource: types.LifecycleDataSourceLocalOverride, + UnknownCause: types.LifecycleUnknownCauseCycleNotFound, + IsSupported: false, + IsDeprecated: false, + IsExtendedSupport: false, }, }, }). @@ -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, true, eol["is_extended_support"]) + assert.Equal(t, "cycle_not_found", eol["unknown_cause"]) + assert.Equal(t, "local_override", eol["data_source"]) + assert.Equal(t, false, 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/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..caad662 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 @@ -135,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"` 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 diff --git a/pkg/workflow/detection/activities.go b/pkg/workflow/detection/activities.go index d3c6a7f..1bb3327 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,35 @@ 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{ + 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 @@ -240,19 +264,24 @@ 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, } } - // 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 +294,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 +368,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 +382,7 @@ func (a *Activities) EmitMetrics(ctx context.Context, input MetricsInput) (*Metr summary.GreenCount++ case types.StatusUnknown: summary.UnknownCount++ + unknownCounts[f.EOL.UnknownCause]++ } } @@ -365,6 +398,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..08c52f1 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,102 @@ 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{ + Version: "8.0.35", 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"]) + + 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) { + 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) { @@ -354,6 +469,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 +499,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 +515,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}, @@ -415,6 +559,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) {