diff --git a/cmd/server/main.go b/cmd/server/main.go index 4e3fb27..51cf5cd 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -269,6 +269,7 @@ func (s *ServerCLI) Run(_ *kong.Context) error { // Start gRPC server // Note: gRPC server requires protobuf code generation first. // Run `make protos` to generate the gRPC service code, then uncomment below: + //nolint:gocritic // Intentionally commented - template for future gRPC implementation /* grpcServer := grpc.NewServer() vgService := grpcservice.NewService(st) @@ -348,6 +349,7 @@ func (s *ServerCLI) Run(_ *kong.Context) error { fmt.Println("\n\nShutting down gracefully...") w.Stop() + //nolint:gocritic // Intentionally commented - template for future gRPC implementation // grpcServer.GracefulStop() // Uncomment when gRPC server is enabled fmt.Println("✓ Shutdown complete") diff --git a/pkg/eol/aws/eks.go b/pkg/eol/aws/eks.go index 0074ee8..6a7af98 100644 --- a/pkg/eol/aws/eks.go +++ b/pkg/eol/aws/eks.go @@ -31,6 +31,13 @@ type EKSVersion struct { LatestPlatformVersion string // Latest platform version for this K8s version } +// EKS version status constants +const ( + eksStatusStandard = "standard" + eksStatusExtended = "extended" + eksStatusDeprecated = "deprecated" +) + // EKSEOLProvider fetches EOL data from AWS EKS API // //nolint:govet // field alignment sacrificed for readability @@ -111,7 +118,15 @@ func (p *EKSEOLProvider) GetVersionLifecycle(ctx context.Context, engine, versio } // Version not found - return unknown lifecycle (empty Version signals missing data) - // Policy will classify as UNKNOWN (data gap) rather than RED/YELLOW (user issue) + // + // Design Decision: Return lifecycle with empty Version rather than error + // Rationale: + // - Maintains observability: Resource tracked with UNKNOWN status vs lost entirely + // - Graceful degradation: Workflow continues during partial API outages + // - Policy decides: EOL provider fetches data, policy layer interprets "unknown" + // + // Alternative (rejected): Return error - would cause workflow to skip resource, + // losing visibility into resources with incomplete EOL data coverage. return &types.VersionLifecycle{ Version: "", // Empty = unknown data, not unsupported version Engine: engine, @@ -170,7 +185,11 @@ func (p *EKSEOLProvider) ListAllVersions(ctx context.Context, engine string) ([] return nil, err } - return result.([]*types.VersionLifecycle), nil + versions, ok := result.([]*types.VersionLifecycle) + if !ok { + return nil, errors.New("failed to convert result to VersionLifecycle slice") + } + return versions, nil } // convertAWSVersion converts an AWS EKSVersion to our VersionLifecycle type @@ -198,17 +217,17 @@ func (p *EKSEOLProvider) convertAWSVersion(av *EKSVersion) *types.VersionLifecyc status := strings.ToLower(av.Status) switch status { - case "standard": + case eksStatusStandard: lifecycle.IsSupported = true lifecycle.IsDeprecated = false lifecycle.IsEOL = false - case "extended": + case eksStatusExtended: lifecycle.IsSupported = true lifecycle.IsExtendedSupport = true lifecycle.IsDeprecated = true - case "deprecated": + case eksStatusDeprecated: lifecycle.IsDeprecated = true lifecycle.IsSupported = false @@ -272,8 +291,22 @@ func enrichWithLifecycleDates(ctx context.Context, version *EKSVersion, eolClien updateStatusFromDates(version) } -// enrichFromEndOfLife attempts to enrich version data from endoflife.date API -// Returns true if successful, false if data not found or API error +// enrichFromEndOfLife attempts to enrich version data from endoflife.date cycles +// Returns true if successful, false if data not found +// +// WARNING: Amazon EKS uses a NON-STANDARD schema on endoflife.date +// +// Standard endoflife.date semantics: +// - cycle.EOL = true end of life date +// - cycle.Support = end of standard support +// +// Amazon EKS DEVIATION (non-standard): +// - cycle.EOL = end of STANDARD support (NOT true EOL!) +// - cycle.ExtendedSupport = end of EXTENDED support (true EOL) +// - cycle.Support = often empty/missing +// +// This is why EKS MUST use EKSEOLProvider with this custom field mapping +// instead of the generic endoflife.Provider (which would interpret dates incorrectly). func enrichFromEndOfLife(ctx context.Context, version *EKSVersion, client endoflife.Client) bool { cycles, err := client.GetProductCycles(ctx, "amazon-eks") if err != nil { @@ -294,12 +327,7 @@ func enrichFromEndOfLife(ctx context.Context, version *EKSVersion, client endofl } } - // For Amazon EKS, endoflife.date has a special schema: - // - "eol" field = end of STANDARD support (not true EOL) - // - "extendedSupport" field = end of EXTENDED support (true EOL) - // - "support" field is often empty/missing for EKS - - // End of standard support from EOL field (EKS-specific) + // End of standard support from EOL field (EKS NON-STANDARD mapping!) if version.EndOfStandardDate == nil && cycle.EOL != "" && cycle.EOL != "false" { if eolDate, err := time.Parse("2006-01-02", cycle.EOL); err == nil { version.EndOfStandardDate = &eolDate @@ -371,14 +399,14 @@ func enrichWithStaticDates(version *EKSVersion) { // updateStatusFromDates updates version status based on lifecycle dates func updateStatusFromDates(version *EKSVersion) { // Update status based on dates if not already set - if version.Status == "" || version.Status == "extended" || version.Status == "standard" { + if version.Status == "" || version.Status == eksStatusExtended || version.Status == eksStatusStandard { now := time.Now() if version.EndOfExtendedDate != nil && now.After(*version.EndOfExtendedDate) { - version.Status = "deprecated" + version.Status = eksStatusDeprecated } else if version.EndOfStandardDate != nil && now.After(*version.EndOfStandardDate) { - version.Status = "extended" + version.Status = eksStatusExtended } else if version.Status == "" { - version.Status = "standard" + version.Status = eksStatusStandard } } } diff --git a/pkg/eol/aws/rds.go b/pkg/eol/aws/rds.go index 54059eb..61df0b9 100644 --- a/pkg/eol/aws/rds.go +++ b/pkg/eol/aws/rds.go @@ -93,7 +93,15 @@ func (p *RDSEOLProvider) GetVersionLifecycle(ctx context.Context, engine, versio } // Version not found - return unknown lifecycle (empty Version signals missing data) - // Policy will classify as UNKNOWN (data gap) rather than RED/YELLOW (user issue) + // + // Design Decision: Return lifecycle with empty Version rather than error + // Rationale: + // - Maintains observability: Resource tracked with UNKNOWN status vs lost entirely + // - Graceful degradation: Workflow continues during partial API outages + // - Policy decides: EOL provider fetches data, policy layer interprets "unknown" + // + // Alternative (rejected): Return error - would cause workflow to skip resource, + // losing visibility into resources with incomplete EOL data coverage. return &types.VersionLifecycle{ Version: "", // Empty = unknown data, not unsupported version Engine: engine, @@ -146,7 +154,11 @@ func (p *RDSEOLProvider) ListAllVersions(ctx context.Context, engine string) ([] return nil, err } - return result.([]*types.VersionLifecycle), nil + versions, ok := result.([]*types.VersionLifecycle) + if !ok { + return nil, errors.New("failed to convert result to VersionLifecycle slice") + } + return versions, nil } // convertAWSVersion converts an AWS EngineVersion to our VersionLifecycle type diff --git a/pkg/eol/endoflife/client.go b/pkg/eol/endoflife/client.go index 91d412d..3311f96 100644 --- a/pkg/eol/endoflife/client.go +++ b/pkg/eol/endoflife/client.go @@ -73,7 +73,7 @@ func NewRealHTTPClientWithConfig(httpClient *http.Client, baseURL string) *RealH func (c *RealHTTPClient) GetProductCycles(ctx context.Context, product string) ([]*ProductCycle, error) { url := fmt.Sprintf("%s/%s.json", c.baseURL, product) - req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, http.NoBody) if err != nil { return nil, errors.Wrap(err, "failed to create request") } @@ -88,7 +88,10 @@ func (c *RealHTTPClient) GetProductCycles(ctx context.Context, product string) ( defer resp.Body.Close() if resp.StatusCode != http.StatusOK { - body, _ := io.ReadAll(resp.Body) + 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 nil, errors.Errorf("unexpected status code %d: %s", resp.StatusCode, string(body)) } diff --git a/pkg/eol/endoflife/client_test.go b/pkg/eol/endoflife/client_test.go index dcf3b0d..e390ea9 100644 --- a/pkg/eol/endoflife/client_test.go +++ b/pkg/eol/endoflife/client_test.go @@ -143,9 +143,9 @@ func TestRealHTTPClient_ContextCancellation(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) cancel() - // Execute - should fail due to cancelled context + // Execute - should fail due to canceled context _, err := client.GetProductCycles(ctx, "test") if err == nil { - t.Error("Expected error due to cancelled context, got nil") + t.Error("Expected error due to canceled context, got nil") } } diff --git a/pkg/eol/endoflife/provider.go b/pkg/eol/endoflife/provider.go index 770580b..4d2d7bd 100644 --- a/pkg/eol/endoflife/provider.go +++ b/pkg/eol/endoflife/provider.go @@ -14,10 +14,22 @@ import ( ) // ProductMapping maps internal engine names to endoflife.date product identifiers +// +// WARNING: This provider uses STANDARD endoflife.date field semantics: +// - cycle.EOL → true end of life date +// - cycle.Support → end of standard support date +// +// Some AWS products (e.g., EKS) use NON-STANDARD schemas on endoflife.date +// and MUST use dedicated providers (e.g., EKSEOLProvider) instead of this generic provider. +// These products are listed here but blocked by ProductsWithNonStandardSchema below. var ProductMapping = map[string]string{ - "kubernetes": "amazon-eks", - "k8s": "amazon-eks", - "eks": "amazon-eks", + // EKS entries are mapped but BLOCKED by ProductsWithNonStandardSchema + // because EKS uses non-standard schema where cycle.EOL means "end of standard support" + // not "true end of life". Use pkg/eol/aws.EKSEOLProvider instead. + "kubernetes": "amazon-eks", + "k8s": "amazon-eks", + "eks": "amazon-eks", + "postgres": "amazon-rds-postgresql", "postgresql": "amazon-rds-postgresql", "mysql": "amazon-rds-mysql", @@ -29,6 +41,18 @@ var ProductMapping = map[string]string{ "elasticache-valkey": "valkey", } +// ProductsWithNonStandardSchema lists products that MUST NOT use this generic provider +// because they use non-standard field semantics on endoflife.date. +// The provider will return an error if these products are requested. +var ProductsWithNonStandardSchema = []string{ + "amazon-eks", // cycle.EOL = end of standard support (NOT true EOL!) +} + +const ( + providerName = "endoflife-date-api" + falseBool = "false" +) + // Provider fetches EOL data from endoflife.date API // //nolint:govet // field alignment sacrificed for readability @@ -61,7 +85,7 @@ func NewProvider(client Client, cacheTTL time.Duration) *Provider { // Name returns the name of this provider func (p *Provider) Name() string { - return "endoflife-date-api" + return providerName } // Engines returns the list of supported engines @@ -98,9 +122,16 @@ func (p *Provider) GetVersionLifecycle(ctx context.Context, engine, version stri } } - // Version not found - return an unknown lifecycle // Version not found - return unknown lifecycle (empty Version signals missing data) - // Policy will classify as UNKNOWN (data gap) rather than RED/YELLOW (user issue) + // + // Design Decision: Return lifecycle with empty Version rather than error + // Rationale: + // - Maintains observability: Resource tracked with UNKNOWN status vs lost entirely + // - Graceful degradation: Workflow continues during partial API outages + // - Policy decides: EOL provider fetches data, policy layer interprets "unknown" + // + // Alternative (rejected): Return error - would cause workflow to skip resource, + // losing visibility into resources with incomplete EOL data coverage. return &types.VersionLifecycle{ Version: "", // Empty = unknown data, not unsupported version Engine: engine, @@ -121,12 +152,23 @@ func (p *Provider) ListAllVersions(ctx context.Context, engine string) ([]*types return nil, fmt.Errorf("unsupported engine: %s", engine) } + // Guard against products with non-standard schemas + // These products interpret endoflife.date fields differently and need dedicated providers + for _, blockedProduct := range ProductsWithNonStandardSchema { + if product == blockedProduct { + return nil, fmt.Errorf( + "engine %s (product: %s) uses non-standard endoflife.date schema and cannot use generic provider; use dedicated provider instead (e.g., EKSEOLProvider)", + engine, product, + ) + } + } + // Use product as cache key cacheKey := product // Check cache first (fast path) p.mu.RLock() - if cached, ok := p.cache[cacheKey]; ok { + if cached, found := p.cache[cacheKey]; found { if time.Since(cached.fetchedAt) < p.cacheTTL { versions := cached.versions p.mu.RUnlock() @@ -170,20 +212,27 @@ func (p *Provider) ListAllVersions(ctx context.Context, engine string) ([]*types return nil, err } - return result.([]*types.VersionLifecycle), nil + versions, ok := result.([]*types.VersionLifecycle) + if !ok { + return nil, errors.New("failed to convert result to VersionLifecycle slice") + } + return versions, nil } // convertCycle converts a ProductCycle to our VersionLifecycle type +// +// Field Mapping (STANDARD endoflife.date schema): +// - cycle.ReleaseDate → ReleaseDate +// - cycle.Support → DeprecationDate (end of standard support) +// - cycle.EOL → EOLDate (true end of life) +// - cycle.ExtendedSupport → ExtendedSupportEnd +// +// WARNING: This assumes STANDARD field semantics. Products with non-standard schemas +// (e.g., amazon-eks where cycle.EOL means "end of standard support", not true EOL) +// should be blocked by ListAllVersions and use dedicated providers instead. func (p *Provider) convertCycle(engine, product string, cycle *ProductCycle) (*types.VersionLifecycle, error) { version := cycle.Cycle - // Add engine-specific prefix for consistency - if engine == "kubernetes" || engine == "k8s" || engine == "eks" { - if !strings.HasPrefix(version, "k8s-") { - version = "k8s-" + version - } - } - lifecycle := &types.VersionLifecycle{ Version: version, Engine: engine, @@ -198,18 +247,18 @@ func (p *Provider) convertCycle(engine, product string, cycle *ProductCycle) (*t } } - // Parse EOL date + // Parse EOL date (STANDARD semantics: true end of life) var eolDate *time.Time - if cycle.EOL != "" && cycle.EOL != "false" { + if cycle.EOL != "" && cycle.EOL != falseBool { if parsed, err := parseDate(cycle.EOL); err == nil { eolDate = &parsed lifecycle.EOLDate = eolDate } } - // Parse support date (end of standard support) + // Parse support date (STANDARD semantics: end of standard support) var supportDate *time.Time - if cycle.Support != "" && cycle.Support != "false" { + if cycle.Support != "" && cycle.Support != falseBool { if parsed, err := parseDate(cycle.Support); err == nil { supportDate = &parsed lifecycle.DeprecationDate = supportDate @@ -221,7 +270,7 @@ func (p *Provider) convertCycle(engine, product string, cycle *ProductCycle) (*t if cycle.ExtendedSupport != nil { switch v := cycle.ExtendedSupport.(type) { case string: - if v != "" && v != "false" { + if v != "" && v != falseBool { if parsed, err := parseDate(v); err == nil { extendedSupportDate = &parsed lifecycle.ExtendedSupportEnd = extendedSupportDate diff --git a/pkg/eol/endoflife/provider_test.go b/pkg/eol/endoflife/provider_test.go index 446da6a..1b41f26 100644 --- a/pkg/eol/endoflife/provider_test.go +++ b/pkg/eol/endoflife/provider_test.go @@ -2,43 +2,45 @@ package endoflife import ( "context" + "strings" "testing" "time" "github.com/block/Version-Guard/pkg/types" ) -func TestProvider_GetVersionLifecycle_EKS(t *testing.T) { +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) { - if product != "amazon-eks" { - t.Errorf("Expected product amazon-eks, got %s", product) + if product != "amazon-rds-postgresql" { + t.Errorf("Expected product amazon-rds-postgresql, got %s", product) } return []*ProductCycle{ { // Current version - still in standard support - Cycle: "1.35", - ReleaseDate: "2025-11-19", - Support: "2027-12-19", // Future - EOL: "2029-05-19", // Far future - ExtendedSupport: true, + Cycle: "16.2", + ReleaseDate: "2024-05-09", + Support: "2028-11-09", // Future + EOL: "2028-11-09", // Same as support end + ExtendedSupport: false, }, { // Extended support version - past standard, before EOL - Cycle: "1.32", - ReleaseDate: "2024-05-29", - Support: "2025-06-29", // Past - EOL: "2027-11-29", // Future - ExtendedSupport: true, + Cycle: "14.10", + ReleaseDate: "2022-11-10", + Support: "2024-11-12", // Past + EOL: "2027-11-12", // Future (extended support) + ExtendedSupport: "2027-11-12", }, { // EOL version - past all support dates - Cycle: "1.28", - ReleaseDate: "2023-09-26", - Support: "2024-10-26", // Past - EOL: "2026-03-26", // Past (before 2026-04-08) - ExtendedSupport: true, + Cycle: "12.18", + ReleaseDate: "2020-11-12", + Support: "2024-11-14", // Past + EOL: "2024-11-14", // Past (before 2026-04-08) + ExtendedSupport: false, }, }, nil }, @@ -56,46 +58,37 @@ func TestProvider_GetVersionLifecycle_EKS(t *testing.T) { wantEOL bool }{ { - name: "current version 1.35", - engine: "kubernetes", - version: "1.35", - wantVersion: "k8s-1.35", + name: "current version 16.2", + engine: "postgres", + version: "16.2", + wantVersion: "16.2", wantSupported: true, wantDeprecated: false, wantEOL: false, }, { - name: "version with k8s- prefix", - engine: "kubernetes", - version: "k8s-1.35", - wantVersion: "k8s-1.35", + name: "postgresql engine variant", + engine: "postgresql", + version: "16.2", + wantVersion: "16.2", wantSupported: true, wantDeprecated: false, wantEOL: false, }, { - name: "eks engine variant", - engine: "eks", - version: "1.35", - wantVersion: "k8s-1.35", - wantSupported: true, - wantDeprecated: false, - wantEOL: false, - }, - { - name: "extended support version 1.32", - engine: "kubernetes", - version: "1.32", - wantVersion: "k8s-1.32", + name: "extended support version 14.10", + engine: "postgres", + version: "14.10", + wantVersion: "14.10", wantSupported: true, // Still in extended support wantDeprecated: true, // Past standard support wantEOL: false, // Not yet EOL }, { - name: "eol version 1.28", - engine: "kubernetes", - version: "1.28", - wantVersion: "k8s-1.28", + name: "eol version 12.18", + engine: "postgres", + version: "12.18", + wantVersion: "12.18", wantSupported: false, // Past all support wantDeprecated: true, // Deprecated wantEOL: true, // Past EOL date @@ -141,16 +134,16 @@ func TestProvider_ListAllVersions(t *testing.T) { GetProductCyclesFunc: func(ctx context.Context, product string) ([]*ProductCycle, error) { return []*ProductCycle{ { - Cycle: "1.35", - ReleaseDate: "2025-11-19", - Support: "2027-12-19", - EOL: "2029-05-19", + Cycle: "16.2", + ReleaseDate: "2024-05-09", + Support: "2028-11-09", + EOL: "2028-11-09", }, { - Cycle: "1.34", - ReleaseDate: "2025-05-29", - Support: "2027-06-29", - EOL: "2028-11-29", + Cycle: "15.6", + ReleaseDate: "2023-05-11", + Support: "2027-11-11", + EOL: "2027-11-11", }, }, nil }, @@ -158,7 +151,7 @@ func TestProvider_ListAllVersions(t *testing.T) { provider := NewProvider(mockClient, 1*time.Hour) - versions, err := provider.ListAllVersions(context.Background(), "kubernetes") + versions, err := provider.ListAllVersions(context.Background(), "postgres") if err != nil { t.Fatalf("ListAllVersions() error = %v", err) } @@ -168,11 +161,11 @@ func TestProvider_ListAllVersions(t *testing.T) { } // Verify first version - if versions[0].Version != "k8s-1.35" { - t.Errorf("First version = %s, want k8s-1.35", versions[0].Version) + if versions[0].Version != "16.2" { + t.Errorf("First version = %s, want 16.2", versions[0].Version) } - if versions[0].Engine != "kubernetes" { - t.Errorf("First version engine = %s, want kubernetes", versions[0].Engine) + if versions[0].Engine != "postgres" { + t.Errorf("First version engine = %s, want postgres", versions[0].Engine) } if versions[0].Source != "endoflife-date-api" { t.Errorf("Source = %s, want endoflife-date-api", versions[0].Source) @@ -186,10 +179,10 @@ func TestProvider_Caching(t *testing.T) { callCount++ return []*ProductCycle{ { - Cycle: "1.35", - ReleaseDate: "2025-11-19", - Support: "2027-12-19", - EOL: "2029-05-19", + Cycle: "16.2", + ReleaseDate: "2024-05-09", + Support: "2028-11-09", + EOL: "2028-11-09", }, }, nil }, @@ -198,7 +191,7 @@ func TestProvider_Caching(t *testing.T) { provider := NewProvider(mockClient, 1*time.Hour) // First call - should hit API - _, err := provider.ListAllVersions(context.Background(), "kubernetes") + _, err := provider.ListAllVersions(context.Background(), "postgres") if err != nil { t.Fatalf("First call error = %v", err) } @@ -207,7 +200,7 @@ func TestProvider_Caching(t *testing.T) { } // Second call - should use cache - _, err = provider.ListAllVersions(context.Background(), "kubernetes") + _, err = provider.ListAllVersions(context.Background(), "postgres") if err != nil { t.Fatalf("Second call error = %v", err) } @@ -216,7 +209,7 @@ func TestProvider_Caching(t *testing.T) { } // Third call - should still use cache - _, err = provider.GetVersionLifecycle(context.Background(), "kubernetes", "1.35") + _, err = provider.GetVersionLifecycle(context.Background(), "postgres", "16.2") if err != nil { t.Fatalf("Third call error = %v", err) } @@ -232,10 +225,10 @@ func TestProvider_CacheExpiration(t *testing.T) { callCount++ return []*ProductCycle{ { - Cycle: "1.35", - ReleaseDate: "2025-11-19", - Support: "2027-12-19", - EOL: "2029-05-19", + Cycle: "16.2", + ReleaseDate: "2024-05-09", + Support: "2028-11-09", + EOL: "2028-11-09", }, }, nil }, @@ -245,7 +238,7 @@ func TestProvider_CacheExpiration(t *testing.T) { provider := NewProvider(mockClient, 50*time.Millisecond) // First call - _, err := provider.ListAllVersions(context.Background(), "kubernetes") + _, err := provider.ListAllVersions(context.Background(), "postgres") if err != nil { t.Fatalf("First call error = %v", err) } @@ -257,7 +250,7 @@ func TestProvider_CacheExpiration(t *testing.T) { time.Sleep(100 * time.Millisecond) // Second call after expiration - should hit API again - _, err = provider.ListAllVersions(context.Background(), "kubernetes") + _, err = provider.ListAllVersions(context.Background(), "postgres") if err != nil { t.Fatalf("Second call error = %v", err) } @@ -281,10 +274,10 @@ func TestProvider_VersionNotFound(t *testing.T) { GetProductCyclesFunc: func(ctx context.Context, product string) ([]*ProductCycle, error) { return []*ProductCycle{ { - Cycle: "1.31", - ReleaseDate: "2024-11-19", - Support: "2025-12-19", - EOL: "2027-05-19", + Cycle: "16.2", + ReleaseDate: "2024-05-09", + Support: "2028-11-09", + EOL: "2028-11-09", }, }, nil }, @@ -292,7 +285,7 @@ func TestProvider_VersionNotFound(t *testing.T) { provider := NewProvider(mockClient, 1*time.Hour) - lifecycle, err := provider.GetVersionLifecycle(context.Background(), "kubernetes", "99.99") + lifecycle, err := provider.GetVersionLifecycle(context.Background(), "postgres", "99.99") if err != nil { t.Fatalf("Expected no error for unknown version, got %v", err) } @@ -304,8 +297,8 @@ func TestProvider_VersionNotFound(t *testing.T) { if lifecycle.Version != "" { t.Errorf("Version = %s, want empty string (signals data gap)", lifecycle.Version) } - if lifecycle.Engine != "kubernetes" { - t.Errorf("Engine = %s, want kubernetes", lifecycle.Engine) + if lifecycle.Engine != "postgres" { + t.Errorf("Engine = %s, want postgres", lifecycle.Engine) } } @@ -326,7 +319,9 @@ func TestProvider_Engines(t *testing.T) { engineMap[e] = true } - requiredEngines := []string{"kubernetes", "eks", "postgres", "mysql", "redis"} + // Note: EKS/kubernetes are NOT in this list because they use non-standard schema + // and must use dedicated EKSEOLProvider instead + requiredEngines := []string{"postgres", "mysql", "redis"} for _, required := range requiredEngines { if !engineMap[required] { t.Errorf("Expected engine %s to be present", required) @@ -334,6 +329,40 @@ func TestProvider_Engines(t *testing.T) { } } +func TestProvider_BlocksNonStandardSchema(t *testing.T) { + // EKS/kubernetes should be blocked because it uses non-standard endoflife.date schema + // where cycle.EOL means "end of standard support" NOT "true EOL" + mockClient := &MockClient{ + GetProductCyclesFunc: func(ctx context.Context, product string) ([]*ProductCycle, error) { + // This should never be called because the guard should reject it first + t.Error("GetProductCycles should not be called for blocked products") + return nil, nil + }, + } + + provider := NewProvider(mockClient, 1*time.Hour) + + // Test that all EKS-related engine names are blocked + blockedEngines := []string{"kubernetes", "k8s", "eks"} + for _, engine := range blockedEngines { + t.Run(engine, func(t *testing.T) { + _, err := provider.ListAllVersions(context.Background(), engine) + if err == nil { + t.Errorf("Expected error for %s (non-standard schema), got nil", engine) + } + if err != nil && !strings.Contains(err.Error(), "non-standard") { + t.Errorf("Error should mention 'non-standard schema', got: %v", err) + } + + // GetVersionLifecycle should also be blocked + _, err = provider.GetVersionLifecycle(context.Background(), engine, "1.35") + if err == nil { + t.Errorf("Expected error for %s in GetVersionLifecycle, got nil", engine) + } + }) + } +} + func TestConvertCycle_ExtendedSupport(t *testing.T) { provider := NewProvider(&MockClient{}, 1*time.Hour) diff --git a/pkg/inventory/wiz/aurora.go b/pkg/inventory/wiz/aurora.go index bc77231..ee03b1d 100644 --- a/pkg/inventory/wiz/aurora.go +++ b/pkg/inventory/wiz/aurora.go @@ -80,46 +80,18 @@ func (s *AuroraInventorySource) ListResources(ctx context.Context, resourceType return nil, fmt.Errorf("unsupported resource type: %s (only AURORA supported)", resourceType) } - // Fetch report data - rows, err := s.client.GetReportData(ctx, s.reportID) - if err != nil { - return nil, errors.Wrap(err, "failed to fetch Wiz report data") - } - - if len(rows) < 2 { - // Empty report (only header row) - return []*types.Resource{}, nil - } - - // Skip header row, parse data rows - var resources []*types.Resource - for i, row := range rows[1:] { - if len(row) < colMinRequired { - // Skip malformed rows - continue - } - - // Filter for Aurora clusters only - nativeType := row[colNativeType] - if !isAuroraResource(nativeType) { - continue - } - - resource, err := s.parseAuroraRow(ctx, row) - if err != nil { - // Log error but continue processing other rows - // In production, you'd use proper logging here - // TODO: add proper logging - _ = fmt.Sprintf("row %d: failed to parse Aurora resource: %v", i+1, err) - continue - } - - if resource != nil { - resources = append(resources, resource) - } - } - - return resources, nil + // Use shared helper to parse Wiz report + return parseWizReport( + ctx, + s.client, + s.reportID, + colMinRequired, // Minimum required columns + func(row []string) bool { + // Filter for Aurora clusters only + return isAuroraResource(row[colNativeType]) + }, + s.parseAuroraRow, + ) } // GetResource fetches a specific Aurora resource by ARN diff --git a/pkg/inventory/wiz/eks.go b/pkg/inventory/wiz/eks.go index 759bcb5..c6ed12c 100644 --- a/pkg/inventory/wiz/eks.go +++ b/pkg/inventory/wiz/eks.go @@ -3,12 +3,9 @@ package wiz import ( "context" "fmt" - "log" "strings" "time" - "github.com/pkg/errors" - "github.com/block/Version-Guard/pkg/registry" "github.com/block/Version-Guard/pkg/types" ) @@ -73,53 +70,23 @@ func (s *EKSInventorySource) CloudProvider() types.CloudProvider { } // ListResources fetches all EKS clusters from Wiz -// -//nolint:dupl // acceptable duplication with Aurora inventory source func (s *EKSInventorySource) ListResources(ctx context.Context, resourceType types.ResourceType) ([]*types.Resource, error) { if resourceType != types.ResourceTypeEKS { return nil, fmt.Errorf("unsupported resource type: %s (only EKS supported)", resourceType) } - // Fetch report data - rows, err := s.client.GetReportData(ctx, s.reportID) - if err != nil { - return nil, errors.Wrap(err, "failed to fetch Wiz report data") - } - - if len(rows) < 2 { - // Empty report (only header row) - return []*types.Resource{}, nil - } - - // Skip header row, parse data rows - var resources []*types.Resource - for i, row := range rows[1:] { - // Ensure row has minimum required columns - if len(row) < colEKSTypeFieldsKind+1 { - // Skip malformed rows - continue - } - - // Filter for EKS clusters - nativeType should be "cluster" - nativeType := row[colEKSNativeType] - if !isEKSResource(nativeType) { - continue - } - - resource, err := s.parseEKSRow(ctx, row) - if err != nil { - // Log error but continue processing other rows - // TODO: wire through proper structured logger (e.g., *slog.Logger) - log.Printf("WARN: row %d: failed to parse EKS resource: %v", i+1, err) - continue - } - - if resource != nil { - resources = append(resources, resource) - } - } - - return resources, nil + // Use shared helper to parse Wiz report + return parseWizReport( + ctx, + s.client, + s.reportID, + colEKSTypeFieldsKind+1, // Minimum required columns + func(row []string) bool { + // Filter for EKS clusters - nativeType should be "cluster" + return isEKSResource(row[colEKSNativeType]) + }, + s.parseEKSRow, + ) } // GetResource fetches a specific EKS cluster by ARN diff --git a/pkg/inventory/wiz/elasticache.go b/pkg/inventory/wiz/elasticache.go index beaa786..55eda42 100644 --- a/pkg/inventory/wiz/elasticache.go +++ b/pkg/inventory/wiz/elasticache.go @@ -63,45 +63,18 @@ func (s *ElastiCacheInventorySource) ListResources(ctx context.Context, resource return nil, fmt.Errorf("unsupported resource type: %s (only ELASTICACHE supported)", resourceType) } - // Fetch report data - rows, err := s.client.GetReportData(ctx, s.reportID) - if err != nil { - return nil, errors.Wrap(err, "failed to fetch Wiz report data") - } - - if len(rows) < 2 { - // Empty report (only header row) - return []*types.Resource{}, nil - } - - // Skip header row, parse data rows - var resources []*types.Resource - for i, row := range rows[1:] { - if len(row) < colMinRequired { - // Skip malformed rows - continue - } - - // Filter for ElastiCache resources only - nativeType := row[colNativeType] - if !isElastiCacheResource(nativeType) { - continue - } - - resource, err := s.parseElastiCacheRow(ctx, row) - if err != nil { - // Log error but continue processing other rows - // TODO: add proper logging - _ = fmt.Sprintf("row %d: failed to parse ElastiCache resource: %v", i+1, err) - continue - } - - if resource != nil { - resources = append(resources, resource) - } - } - - return resources, nil + // Use shared helper to parse Wiz report + return parseWizReport( + ctx, + s.client, + s.reportID, + colMinRequired, // Minimum required columns + func(row []string) bool { + // Filter for ElastiCache resources only + return isElastiCacheResource(row[colNativeType]) + }, + s.parseElastiCacheRow, + ) } // GetResource fetches a specific ElastiCache resource by ARN diff --git a/pkg/inventory/wiz/helpers.go b/pkg/inventory/wiz/helpers.go new file mode 100644 index 0000000..f14b238 --- /dev/null +++ b/pkg/inventory/wiz/helpers.go @@ -0,0 +1,85 @@ +package wiz + +import ( + "context" + "log" + + "github.com/pkg/errors" + + "github.com/block/Version-Guard/pkg/types" +) + +// rowFilterFunc decides whether a CSV row should be processed +// Returns true if the row should be parsed, false to skip it +type rowFilterFunc func(row []string) bool + +// rowParserFunc parses a CSV row into a Resource +// Returns nil resource to skip, non-nil to include in results +type rowParserFunc func(ctx context.Context, row []string) (*types.Resource, error) + +// parseWizReport is a shared helper that implements the common CSV-row-iteration pattern +// used by Aurora, EKS, and ElastiCache inventory sources. +// +// This eliminates ~90 lines of duplicated code across the three sources and ensures +// consistent error handling and processing logic. +// +// Parameters: +// - ctx: Context for the operation +// - client: Wiz client for fetching report data +// - reportID: The Wiz report ID to fetch +// - minColumns: Minimum number of columns required in each row +// - filterRow: Function to filter rows (e.g., check nativeType) +// - parseRow: Function to parse a valid row into a Resource +// +// Returns: +// - List of successfully parsed resources +// - Error if report fetching fails (not if individual rows fail to parse) +func parseWizReport( + ctx context.Context, + client *Client, + reportID string, + minColumns int, + filterRow rowFilterFunc, + parseRow rowParserFunc, +) ([]*types.Resource, error) { + // Fetch report data + rows, err := client.GetReportData(ctx, reportID) + if err != nil { + return nil, errors.Wrap(err, "failed to fetch Wiz report data") + } + + if len(rows) < 2 { + // Empty report (only header row) + return []*types.Resource{}, nil + } + + // Skip header row, parse data rows + var resources []*types.Resource + for i, row := range rows[1:] { + // Ensure row has minimum required columns + if len(row) < minColumns { + // Skip malformed rows + continue + } + + // Apply resource type filter + if !filterRow(row) { + continue + } + + // Parse the row + resource, err := parseRow(ctx, row) + if err != nil { + // Log error but continue processing other rows + // TODO: wire through proper structured logger (e.g., *slog.Logger) + log.Printf("WARN: row %d: failed to parse resource: %v", i+1, err) + continue + } + + if resource != nil { + resources = append(resources, resource) + } + } + + return resources, nil +} diff --git a/pkg/inventory/wiz/http_client.go b/pkg/inventory/wiz/http_client.go index cc60680..7e3f77d 100644 --- a/pkg/inventory/wiz/http_client.go +++ b/pkg/inventory/wiz/http_client.go @@ -147,7 +147,7 @@ func (c *HTTPClient) GetReport(ctx context.Context, accessToken, reportID string // DownloadReport downloads the report CSV from the provided URL func (c *HTTPClient) DownloadReport(ctx context.Context, downloadURL string) (io.ReadCloser, error) { - req, err := http.NewRequestWithContext(ctx, http.MethodGet, downloadURL, nil) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, downloadURL, http.NoBody) if err != nil { return nil, errors.Wrap(err, "failed to create download request") } diff --git a/pkg/snapshot/store.go b/pkg/snapshot/store.go index cf91496..46283bf 100644 --- a/pkg/snapshot/store.go +++ b/pkg/snapshot/store.go @@ -239,9 +239,13 @@ func (s *S3Store) ListSnapshots(ctx context.Context, limit int) ([]*SnapshotMeta meta.SnapshotID = val } if val, ok := headResult.Metadata["total-resources"]; ok { + // Ignore scan error - if parsing fails, field remains zero + //nolint:errcheck // Intentionally ignoring parse errors - metadata is best-effort fmt.Sscanf(val, "%d", &meta.TotalResources) } if val, ok := headResult.Metadata["compliance-percentage"]; ok { + // Ignore scan error - if parsing fails, field remains zero + //nolint:errcheck // Intentionally ignoring parse errors - metadata is best-effort fmt.Sscanf(val, "%f", &meta.CompliancePercentage) } diff --git a/pkg/workflow/detection/activities.go b/pkg/workflow/detection/activities.go index f41e7a8..469b274 100644 --- a/pkg/workflow/detection/activities.go +++ b/pkg/workflow/detection/activities.go @@ -108,7 +108,11 @@ func (a *Activities) loadResources(batchID string, fallback []*types.Resource) ( if !ok { return nil, fmt.Errorf("resource batch %q not found", batchID) } - return v.([]*types.Resource), nil + resources, ok := v.([]*types.Resource) + if !ok { + return nil, fmt.Errorf("resource batch %q has invalid type", batchID) + } + return resources, nil } func (a *Activities) loadFindings(batchID string, fallback []*types.Finding) ([]*types.Finding, error) { @@ -119,7 +123,11 @@ func (a *Activities) loadFindings(batchID string, fallback []*types.Finding) ([] if !ok { return nil, fmt.Errorf("findings batch %q not found", batchID) } - return v.([]*types.Finding), nil + findings, ok := v.([]*types.Finding) + if !ok { + return nil, fmt.Errorf("findings batch %q has invalid type", batchID) + } + return findings, nil } // FetchInventory fetches all resources of a given type from the inventory source