Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions cmd/server/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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")

Expand Down
62 changes: 45 additions & 17 deletions pkg/eol/aws/eks.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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 {
Expand All @@ -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
Expand Down Expand Up @@ -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
}
}
}
16 changes: 14 additions & 2 deletions pkg/eol/aws/rds.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down
7 changes: 5 additions & 2 deletions pkg/eol/endoflife/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
Expand All @@ -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))
}

Expand Down
4 changes: 2 additions & 2 deletions pkg/eol/endoflife/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
}
89 changes: 69 additions & 20 deletions pkg/eol/endoflife/provider.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -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()
Expand Down Expand Up @@ -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,
Expand All @@ -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
Expand All @@ -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
Expand Down
Loading
Loading