From 463ca4b642883cfbf14df1dd1aeb1438b6b8fdf0 Mon Sep 17 00:00:00 2001 From: dawang Date: Thu, 15 Jan 2026 19:51:04 +0800 Subject: [PATCH 01/14] HYPERFLEET-267 - feat: Implement real GCP validator --- validator/cmd/validator/main.go | 136 +++++++++ validator/go.mod | 46 +++ validator/go.sum | 212 +++++++++++++ validator/pkg/config/config.go | 125 ++++++++ validator/pkg/config/config_suite_test.go | 13 + validator/pkg/config/config_test.go | 245 +++++++++++++++ validator/pkg/gcp/client.go | 227 ++++++++++++++ validator/pkg/gcp/client_test.go | 189 ++++++++++++ validator/pkg/gcp/gcp_suite_test.go | 13 + validator/pkg/validator/context.go | 30 ++ validator/pkg/validator/executor.go | 172 +++++++++++ validator/pkg/validator/executor_test.go | 287 ++++++++++++++++++ validator/pkg/validator/registry.go | 83 +++++ validator/pkg/validator/registry_test.go | 144 +++++++++ validator/pkg/validator/resolver.go | 148 +++++++++ validator/pkg/validator/resolver_test.go | 275 +++++++++++++++++ validator/pkg/validator/validator.go | 108 +++++++ .../pkg/validator/validator_suite_test.go | 13 + validator/pkg/validators/api_enabled.go | 171 +++++++++++ validator/pkg/validators/api_enabled_test.go | 148 +++++++++ validator/pkg/validators/quota_check.go | 87 ++++++ validator/pkg/validators/quota_check_test.go | 104 +++++++ .../pkg/validators/validators_suite_test.go | 13 + 23 files changed, 2989 insertions(+) create mode 100644 validator/cmd/validator/main.go create mode 100644 validator/go.mod create mode 100644 validator/go.sum create mode 100644 validator/pkg/config/config.go create mode 100644 validator/pkg/config/config_suite_test.go create mode 100644 validator/pkg/config/config_test.go create mode 100644 validator/pkg/gcp/client.go create mode 100644 validator/pkg/gcp/client_test.go create mode 100644 validator/pkg/gcp/gcp_suite_test.go create mode 100644 validator/pkg/validator/context.go create mode 100644 validator/pkg/validator/executor.go create mode 100644 validator/pkg/validator/executor_test.go create mode 100644 validator/pkg/validator/registry.go create mode 100644 validator/pkg/validator/registry_test.go create mode 100644 validator/pkg/validator/resolver.go create mode 100644 validator/pkg/validator/resolver_test.go create mode 100644 validator/pkg/validator/validator.go create mode 100644 validator/pkg/validator/validator_suite_test.go create mode 100644 validator/pkg/validators/api_enabled.go create mode 100644 validator/pkg/validators/api_enabled_test.go create mode 100644 validator/pkg/validators/quota_check.go create mode 100644 validator/pkg/validators/quota_check_test.go create mode 100644 validator/pkg/validators/validators_suite_test.go diff --git a/validator/cmd/validator/main.go b/validator/cmd/validator/main.go new file mode 100644 index 0000000..1be1a21 --- /dev/null +++ b/validator/cmd/validator/main.go @@ -0,0 +1,136 @@ +package main + +import ( + "context" + "encoding/json" + "log/slog" + "os" + "os/signal" + "strings" + "syscall" + "time" + + "validator/pkg/config" + "validator/pkg/validator" + _ "validator/pkg/validators" // Import to trigger init() registration +) + +const ( + // Maximum time for all validators to complete + validationTimeout = 5 * time.Minute +) + +func main() { + // Load configuration first to get log level + cfg, err := config.LoadFromEnv() + if err != nil { + slog.Error("Configuration error", "error", err) + os.Exit(1) + } + + // Set up structured logger based on log level + logLevel := parseLogLevel(cfg.LogLevel) + logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{ + Level: logLevel, + })) + slog.SetDefault(logger) + + logger.Info("Starting GCP Validator") + logger.Info("Loaded configuration", + "gcp_project", cfg.ProjectID, + "results_path", cfg.ResultsPath, + "log_level", cfg.LogLevel) + + // Validate disabled validators against registry + if len(cfg.DisabledValidators) > 0 { + logger.Info("Disabled validators", "validators", cfg.DisabledValidators) + for _, name := range cfg.DisabledValidators { + if _, exists := validator.Get(name); !exists { + logger.Warn("Unknown validator in DISABLED_VALIDATORS - will be ignored", + "validator", name, + "hint", "Check for typos. Run without DISABLED_VALIDATORS to see available validators.") + } + } + } + + // Create validation context + vctx := &validator.Context{ + Config: cfg, + Results: make(map[string]*validator.Result), + } + + // Create context with timeout (max time for all validators) + ctx, cancel := context.WithTimeout(context.Background(), validationTimeout) + defer cancel() + + // Set up signal handling for graceful shutdown + sigCh := make(chan os.Signal, 1) + signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM) + go func() { + sig := <-sigCh + logger.Warn("Received shutdown signal, cancelling validation", "signal", sig) + cancel() + }() + + // Execute all validators + executor := validator.NewExecutor(vctx, logger) + + results, err := executor.ExecuteAll(ctx) + if err != nil { + logger.Error("Validator execution failed", "error", err) + os.Exit(1) + } + + // Aggregate results + aggregated := validator.Aggregate(results) + + // Write to output file + outputFile := cfg.ResultsPath + logger.Info("Writing results", "path", outputFile) + + data, err := json.MarshalIndent(aggregated, "", " ") + if err != nil { + logger.Error("Failed to marshal results", "error", err) + os.Exit(1) + } + + // Ensure output directory exists + // Note: In Kubernetes, the /results directory should be pre-created via volumeMounts + if err := os.WriteFile(outputFile, data, 0644); err != nil { + logger.Error("Failed to write results", "error", err, "path", outputFile) + os.Exit(1) + } + + // Log the results content for easy access via logs (useful in containerized environments) + logger.Info("Results written successfully", + "path", outputFile, + "content", string(data)) + + logger.Info("Validation completed", + "status", aggregated.Status, + "message", aggregated.Message) + + // Exit with appropriate code + if aggregated.Status == validator.StatusFailure { + logger.Warn("Validation FAILED - exiting with code 1") + os.Exit(1) + } + + logger.Info("Validation PASSED - exiting with code 0") +} + +// parseLogLevel converts string log level to slog.Level +func parseLogLevel(level string) slog.Level { + switch strings.ToLower(level) { + case "debug": + return slog.LevelDebug + case "info": + return slog.LevelInfo + case "warn", "warning": + return slog.LevelWarn + case "error": + return slog.LevelError + default: + return slog.LevelInfo + } +} diff --git a/validator/go.mod b/validator/go.mod new file mode 100644 index 0000000..a476bbe --- /dev/null +++ b/validator/go.mod @@ -0,0 +1,46 @@ +module validator + +go 1.25.0 + +require ( + github.com/onsi/ginkgo/v2 v2.27.5 + github.com/onsi/gomega v1.39.0 + golang.org/x/oauth2 v0.15.0 + google.golang.org/api v0.154.0 +) + +require ( + cloud.google.com/go/compute v1.23.3 // indirect + cloud.google.com/go/compute/metadata v0.2.3 // indirect + github.com/Masterminds/semver/v3 v3.4.0 // indirect + github.com/felixge/httpsnoop v1.0.4 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/go-task/slim-sprig/v3 v3.0.0 // indirect + github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect + github.com/golang/protobuf v1.5.3 // indirect + github.com/google/go-cmp v0.7.0 // indirect + github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 // indirect + github.com/google/s2a-go v0.1.7 // indirect + github.com/google/uuid v1.4.0 // indirect + github.com/googleapis/enterprise-certificate-proxy v0.3.2 // indirect + github.com/googleapis/gax-go/v2 v2.12.0 // indirect + go.opencensus.io v0.24.0 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.46.1 // indirect + go.opentelemetry.io/otel v1.21.0 // indirect + go.opentelemetry.io/otel/metric v1.21.0 // indirect + go.opentelemetry.io/otel/trace v1.21.0 // indirect + go.yaml.in/yaml/v3 v3.0.4 // indirect + golang.org/x/crypto v0.41.0 // indirect + golang.org/x/mod v0.27.0 // indirect + golang.org/x/net v0.43.0 // indirect + golang.org/x/sync v0.16.0 // indirect + golang.org/x/sys v0.35.0 // indirect + golang.org/x/text v0.28.0 // indirect + golang.org/x/tools v0.36.0 // indirect + google.golang.org/appengine v1.6.7 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20231127180814-3a041ad873d4 // indirect + google.golang.org/grpc v1.59.0 // indirect + google.golang.org/protobuf v1.36.7 // indirect + gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect +) diff --git a/validator/go.sum b/validator/go.sum new file mode 100644 index 0000000..e2d9de1 --- /dev/null +++ b/validator/go.sum @@ -0,0 +1,212 @@ +cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= +cloud.google.com/go/compute v1.23.3 h1:6sVlXXBmbd7jNX0Ipq0trII3e4n1/MsADLK6a+aiVlk= +cloud.google.com/go/compute v1.23.3/go.mod h1:VCgBUoMnIVIR0CscqQiPJLAG25E3ZRZMzcFZeQ+h8CI= +cloud.google.com/go/compute/metadata v0.2.3 h1:mg4jlk7mCAj6xXp9UJ4fjI9VUI5rubuGBW5aJ7UnBMY= +cloud.google.com/go/compute/metadata v0.2.3/go.mod h1:VAV5nSsACxMJvgaAuX6Pk2AawlZn8kiOGuCv6gTkwuA= +github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= +github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0= +github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= +github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= +github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= +github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= +github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= +github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= +github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= +github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= +github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/gkampitakis/ciinfo v0.3.2 h1:JcuOPk8ZU7nZQjdUhctuhQofk7BGHuIy0c9Ez8BNhXs= +github.com/gkampitakis/ciinfo v0.3.2/go.mod h1:1NIwaOcFChN4fa/B0hEBdAb6npDlFL8Bwx4dfRLRqAo= +github.com/gkampitakis/go-diff v1.3.2 h1:Qyn0J9XJSDTgnsgHRdz9Zp24RaJeKMUHg2+PDZZdC4M= +github.com/gkampitakis/go-diff v1.3.2/go.mod h1:LLgOrpqleQe26cte8s36HTWcTmMEur6OPYerdAAS9tk= +github.com/gkampitakis/go-snaps v0.5.15 h1:amyJrvM1D33cPHwVrjo9jQxX8g/7E2wYdZ+01KS3zGE= +github.com/gkampitakis/go-snaps v0.5.15/go.mod h1:HNpx/9GoKisdhw9AFOBT1N7DBs9DiHo/hGheFGBZ+mc= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= +github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= +github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw= +github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= +github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= +github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE= +github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= +github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= +github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= +github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= +github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= +github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8= +github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= +github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= +github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg= +github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= +github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= +github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 h1:BHT72Gu3keYf3ZEu2J0b1vyeLSOYI8bm5wbJM/8yDe8= +github.com/google/pprof v0.0.0-20250403155104-27863c87afa6/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= +github.com/google/s2a-go v0.1.7 h1:60BLSyTrOV4/haCDW4zb1guZItoSq8foHCXrAnjBo/o= +github.com/google/s2a-go v0.1.7/go.mod h1:50CgR4k1jNlWBu4UfS4AcfhVe1r6pdZPygJ3R8F0Qdw= +github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/google/uuid v1.4.0 h1:MtMxsa51/r9yyhkyLsVeVt0B+BGQZzpQiTQ4eHZ8bc4= +github.com/google/uuid v1.4.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/googleapis/enterprise-certificate-proxy v0.3.2 h1:Vie5ybvEvT75RniqhfFxPRy3Bf7vr3h0cechB90XaQs= +github.com/googleapis/enterprise-certificate-proxy v0.3.2/go.mod h1:VLSiSSBs/ksPL8kq3OBOQ6WRI2QnaFynd1DCjZ62+V0= +github.com/googleapis/gax-go/v2 v2.12.0 h1:A+gCJKdRfqXkr+BIRGtZLibNXf0m1f9E4HG56etFpas= +github.com/googleapis/gax-go/v2 v2.12.0/go.mod h1:y+aIqrI5eb1YGMVJfuV3185Ts/D7qKpsEkdD5+I6QGU= +github.com/joshdk/go-junit v1.0.0 h1:S86cUKIdwBHWwA6xCmFlf3RTLfVXYQfvanM5Uh+K6GE= +github.com/joshdk/go-junit v1.0.0/go.mod h1:TiiV0PqkaNfFXjEiyjWM3XXrhVyCa1K4Zfga6W52ung= +github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/maruel/natural v1.1.1 h1:Hja7XhhmvEFhcByqDoHz9QZbkWey+COd9xWfCfn1ioo= +github.com/maruel/natural v1.1.1/go.mod h1:v+Rfd79xlw1AgVBjbO0BEQmptqb5HvL/k9GRHB7ZKEg= +github.com/mfridman/tparse v0.18.0 h1:wh6dzOKaIwkUGyKgOntDW4liXSo37qg5AXbIhkMV3vE= +github.com/mfridman/tparse v0.18.0/go.mod h1:gEvqZTuCgEhPbYk/2lS3Kcxg1GmTxxU7kTC8DvP0i/A= +github.com/onsi/ginkgo/v2 v2.27.5 h1:ZeVgZMx2PDMdJm/+w5fE/OyG6ILo1Y3e+QX4zSR0zTE= +github.com/onsi/ginkgo/v2 v2.27.5/go.mod h1:ArE1D/XhNXBXCBkKOLkbsb2c81dQHCRcF5zwn/ykDRo= +github.com/onsi/gomega v1.39.0 h1:y2ROC3hKFmQZJNFeGAMeHZKkjBL65mIZcvrLQBF9k6Q= +github.com/onsi/gomega v1.39.0/go.mod h1:ZCU1pkQcXDO5Sl9/VVEGlDyp+zm0m1cmeG5TOzLgdh4= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= +github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= +github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= +github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= +github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= +github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= +github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= +github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= +go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0= +go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.46.1 h1:aFJWCqJMNjENlcleuuOkGAPH82y0yULBScfXcIEdS24= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.46.1/go.mod h1:sEGXWArGqc3tVa+ekntsN65DmVbVeW+7lTKTjZF3/Fo= +go.opentelemetry.io/otel v1.21.0 h1:hzLeKBZEL7Okw2mGzZ0cc4k/A7Fta0uoPgaJCr8fsFc= +go.opentelemetry.io/otel v1.21.0/go.mod h1:QZzNPQPm1zLX4gZK4cMi+71eaorMSGT3A4znnUvNNEo= +go.opentelemetry.io/otel/metric v1.21.0 h1:tlYWfeo+Bocx5kLEloTjbcDwBuELRrIFxwdQ36PlJu4= +go.opentelemetry.io/otel/metric v1.21.0/go.mod h1:o1p3CA8nNHW8j5yuQLdc1eeqEaPfzug24uvsyIEJRWM= +go.opentelemetry.io/otel/trace v1.21.0 h1:WD9i5gzvoUPuXIXH24ZNBudiarZDKuekPqi/E8fpfLc= +go.opentelemetry.io/otel/trace v1.21.0/go.mod h1:LGbsEB0f9LGjN+OZaQQ26sohbOmiMR+BaslueVtS/qQ= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4= +golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc= +golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= +golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= +golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= +golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ= +golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc= +golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= +golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE= +golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg= +golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= +golang.org/x/oauth2 v0.15.0 h1:s8pnnxNVzjWyrvYdFUQq5llS1PX2zhPXmccZv99h7uQ= +golang.org/x/oauth2 v0.15.0/go.mod h1:q48ptWNTY5XWf+JNten23lcvHpLJ0ZSxF5ttTHKVCAM= +golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= +golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= +golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= +golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= +golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= +golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg= +golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/api v0.154.0 h1:X7QkVKZBskztmpPKWQXgjJRPA2dJYrL6r+sYPRLj050= +google.golang.org/api v0.154.0/go.mod h1:qhSMkM85hgqiokIYsrRyKxrjfBeIhgl4Z2JmeRkYylc= +google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= +google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= +google.golang.org/appengine v1.6.7 h1:FZR1q0exgwxzPzp/aF+VccGrSfxfPpkBqjIIEq3ru6c= +google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= +google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= +google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= +google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo= +google.golang.org/genproto v0.0.0-20231120223509-83a465c0220f h1:Vn+VyHU5guc9KjB5KrjI2q0wCOWEOIh0OEsleqakHJg= +google.golang.org/genproto v0.0.0-20231120223509-83a465c0220f/go.mod h1:nWSwAFPb+qfNJXsoeO3Io7zf4tMSfN8EA8RlDA04GhY= +google.golang.org/genproto/googleapis/api v0.0.0-20231120223509-83a465c0220f h1:2yNACc1O40tTnrsbk9Cv6oxiW8pxI/pXj0wRtdlYmgY= +google.golang.org/genproto/googleapis/api v0.0.0-20231120223509-83a465c0220f/go.mod h1:Uy9bTZJqmfrw2rIBxgGLnamc78euZULUBrLZ9XTITKI= +google.golang.org/genproto/googleapis/rpc v0.0.0-20231127180814-3a041ad873d4 h1:DC7wcm+i+P1rN3Ff07vL+OndGg5OhNddHyTA+ocPqYE= +google.golang.org/genproto/googleapis/rpc v0.0.0-20231127180814-3a041ad873d4/go.mod h1:eJVxU6o+4G1PSczBr85xmyvSNYAKvAYgkub40YGomFM= +google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= +google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= +google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY= +google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= +google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc= +google.golang.org/grpc v1.59.0 h1:Z5Iec2pjwb+LEOqzpB2MR12/eKFhDPhuqW91O+4bwUk= +google.golang.org/grpc v1.59.0/go.mod h1:aUPDwccQo6OTjy7Hct4AfBPD1GptF4fyUjIkQ9YtF98= +google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= +google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= +google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= +google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= +google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= +google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c= +google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= +google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= +google.golang.org/protobuf v1.36.7 h1:IgrO7UwFQGJdRNXH/sQux4R1Dj1WAKcLElzeeRaXV2A= +google.golang.org/protobuf v1.36.7/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= diff --git a/validator/pkg/config/config.go b/validator/pkg/config/config.go new file mode 100644 index 0000000..f07dd41 --- /dev/null +++ b/validator/pkg/config/config.go @@ -0,0 +1,125 @@ +package config + +import ( + "fmt" + "os" + "strconv" + "strings" +) + +// Config holds all configuration from environment variables +type Config struct { + // Output + ResultsPath string // Default: /results/adapter-result.json + + // GCP Configuration + ProjectID string // Required + GCPRegion string // Optional, for regional checks + + // Validator Control + DisabledValidators []string // Comma-separated list of validators to disable + StopOnFirstFailure bool // Default: false + + // API Validator Config + RequiredAPIs []string // Default: compute.googleapis.com, iam.googleapis.com, etc. + + // Quota Validator Config (Post-MVP) + RequiredVCPUs int // Default: 0 (skip quota check) + RequiredDiskGB int + RequiredIPAddresses int + + // Network Validator Config (Post-MVP) + VPCName string + SubnetName string + + // Logging + LogLevel string // debug, info, warn, error +} + +// LoadFromEnv loads configuration from environment variables +func LoadFromEnv() (*Config, error) { + cfg := &Config{ + ResultsPath: getEnv("RESULTS_PATH", "/results/adapter-result.json"), + ProjectID: os.Getenv("PROJECT_ID"), + GCPRegion: getEnv("GCP_REGION", ""), + StopOnFirstFailure: getEnvBool("STOP_ON_FIRST_FAILURE", false), + LogLevel: getEnv("LOG_LEVEL", "info"), + RequiredVCPUs: getEnvInt("REQUIRED_VCPUS", 0), + RequiredDiskGB: getEnvInt("REQUIRED_DISK_GB", 0), + RequiredIPAddresses: getEnvInt("REQUIRED_IP_ADDRESSES", 0), + VPCName: getEnv("VPC_NAME", ""), + SubnetName: getEnv("SUBNET_NAME", ""), + } + + // Parse disabled validators + if disabled := os.Getenv("DISABLED_VALIDATORS"); disabled != "" { + cfg.DisabledValidators = strings.Split(disabled, ",") + // Trim whitespace + for i, v := range cfg.DisabledValidators { + cfg.DisabledValidators[i] = strings.TrimSpace(v) + } + } + + // Parse required APIs + defaultAPIs := []string{ + "compute.googleapis.com", + "iam.googleapis.com", + "cloudresourcemanager.googleapis.com", + } + if apis := os.Getenv("REQUIRED_APIS"); apis != "" { + cfg.RequiredAPIs = strings.Split(apis, ",") + // Trim whitespace + for i, v := range cfg.RequiredAPIs { + cfg.RequiredAPIs[i] = strings.TrimSpace(v) + } + } else { + cfg.RequiredAPIs = defaultAPIs + } + + // Validation + if cfg.ProjectID == "" { + return nil, fmt.Errorf("PROJECT_ID is required") + } + + return cfg, nil +} + +func getEnv(key, defaultValue string) string { + if value := os.Getenv(key); value != "" { + return value + } + return defaultValue +} + +func getEnvBool(key string, defaultValue bool) bool { + if value := os.Getenv(key); value != "" { + b, err := strconv.ParseBool(value) + if err == nil { + return b + } + } + return defaultValue +} + +func getEnvInt(key string, defaultValue int) int { + if value := os.Getenv(key); value != "" { + i, err := strconv.Atoi(value) + if err == nil { + return i + } + } + return defaultValue +} + +// IsValidatorEnabled checks if a validator should run +// All validators are enabled by default unless explicitly disabled +func (c *Config) IsValidatorEnabled(name string) bool { + // Check if explicitly disabled + for _, disabled := range c.DisabledValidators { + if disabled == name { + return false + } + } + // Not disabled = enabled + return true +} diff --git a/validator/pkg/config/config_suite_test.go b/validator/pkg/config/config_suite_test.go new file mode 100644 index 0000000..c6e29ba --- /dev/null +++ b/validator/pkg/config/config_suite_test.go @@ -0,0 +1,13 @@ +package config_test + +import ( + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestConfig(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Config Suite") +} diff --git a/validator/pkg/config/config_test.go b/validator/pkg/config/config_test.go new file mode 100644 index 0000000..f268995 --- /dev/null +++ b/validator/pkg/config/config_test.go @@ -0,0 +1,245 @@ +package config_test + +import ( + "os" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "validator/pkg/config" +) + +var _ = Describe("Config", func() { + var originalEnv map[string]string + + BeforeEach(func() { + // Save original environment + originalEnv = make(map[string]string) + envVars := []string{ + "RESULTS_PATH", "PROJECT_ID", "GCP_REGION", + "DISABLED_VALIDATORS", "STOP_ON_FIRST_FAILURE", + "REQUIRED_APIS", "LOG_LEVEL", + "REQUIRED_VCPUS", "REQUIRED_DISK_GB", "REQUIRED_IP_ADDRESSES", + "VPC_NAME", "SUBNET_NAME", + } + for _, v := range envVars { + originalEnv[v] = os.Getenv(v) + Expect(os.Unsetenv(v)).To(Succeed()) + } + }) + + AfterEach(func() { + // Restore original environment + for k, v := range originalEnv { + if v != "" { + Expect(os.Setenv(k, v)).To(Succeed()) + } else { + Expect(os.Unsetenv(k)).To(Succeed()) + } + } + }) + + Describe("LoadFromEnv", func() { + Context("with minimal required configuration", func() { + BeforeEach(func() { + Expect(os.Setenv("PROJECT_ID", "test-project-123")).To(Succeed()) + }) + + It("should load config with defaults", func() { + cfg, err := config.LoadFromEnv() + Expect(err).NotTo(HaveOccurred()) + Expect(cfg.ProjectID).To(Equal("test-project-123")) + Expect(cfg.ResultsPath).To(Equal("/results/adapter-result.json")) + Expect(cfg.LogLevel).To(Equal("info")) + Expect(cfg.StopOnFirstFailure).To(BeFalse()) + }) + + It("should set default required APIs", func() { + cfg, err := config.LoadFromEnv() + Expect(err).NotTo(HaveOccurred()) + Expect(cfg.RequiredAPIs).To(ConsistOf( + "compute.googleapis.com", + "iam.googleapis.com", + "cloudresourcemanager.googleapis.com", + )) + }) + }) + + Context("without required PROJECT_ID", func() { + It("should return an error", func() { + _, err := config.LoadFromEnv() + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("PROJECT_ID is required")) + }) + }) + + Context("with custom configuration", func() { + BeforeEach(func() { + Expect(os.Setenv("PROJECT_ID", "custom-project")).To(Succeed()) + Expect(os.Setenv("RESULTS_PATH", "/custom/path/results.json")).To(Succeed()) + Expect(os.Setenv("GCP_REGION", "us-central1")).To(Succeed()) + Expect(os.Setenv("LOG_LEVEL", "debug")).To(Succeed()) + Expect(os.Setenv("STOP_ON_FIRST_FAILURE", "true")).To(Succeed()) + }) + + It("should load all custom values", func() { + cfg, err := config.LoadFromEnv() + Expect(err).NotTo(HaveOccurred()) + Expect(cfg.ProjectID).To(Equal("custom-project")) + Expect(cfg.ResultsPath).To(Equal("/custom/path/results.json")) + Expect(cfg.GCPRegion).To(Equal("us-central1")) + Expect(cfg.LogLevel).To(Equal("debug")) + Expect(cfg.StopOnFirstFailure).To(BeTrue()) + }) + }) + + Context("with disabled validators", func() { + BeforeEach(func() { + Expect(os.Setenv("PROJECT_ID", "test-project")).To(Succeed()) + Expect(os.Setenv("DISABLED_VALIDATORS", "quota-check,network-check")).To(Succeed()) + }) + + It("should parse the disabled validators list", func() { + cfg, err := config.LoadFromEnv() + Expect(err).NotTo(HaveOccurred()) + Expect(cfg.DisabledValidators).To(ConsistOf("quota-check", "network-check")) + }) + }) + + Context("with disabled validators containing whitespace", func() { + BeforeEach(func() { + Expect(os.Setenv("PROJECT_ID", "test-project")).To(Succeed()) + Expect(os.Setenv("DISABLED_VALIDATORS", " quota-check , network-check ")).To(Succeed()) + }) + + It("should trim whitespace from validator names", func() { + cfg, err := config.LoadFromEnv() + Expect(err).NotTo(HaveOccurred()) + Expect(cfg.DisabledValidators).To(ConsistOf("quota-check", "network-check")) + }) + }) + + Context("with custom required APIs", func() { + BeforeEach(func() { + Expect(os.Setenv("PROJECT_ID", "test-project")).To(Succeed()) + Expect(os.Setenv("REQUIRED_APIS", "compute.googleapis.com,storage.googleapis.com")).To(Succeed()) + }) + + It("should parse the required APIs list", func() { + cfg, err := config.LoadFromEnv() + Expect(err).NotTo(HaveOccurred()) + Expect(cfg.RequiredAPIs).To(ConsistOf("compute.googleapis.com", "storage.googleapis.com")) + }) + }) + + Context("with integer configurations", func() { + BeforeEach(func() { + Expect(os.Setenv("PROJECT_ID", "test-project")).To(Succeed()) + Expect(os.Setenv("REQUIRED_VCPUS", "100")).To(Succeed()) + Expect(os.Setenv("REQUIRED_DISK_GB", "500")).To(Succeed()) + Expect(os.Setenv("REQUIRED_IP_ADDRESSES", "10")).To(Succeed()) + }) + + It("should parse integer values", func() { + cfg, err := config.LoadFromEnv() + Expect(err).NotTo(HaveOccurred()) + Expect(cfg.RequiredVCPUs).To(Equal(100)) + Expect(cfg.RequiredDiskGB).To(Equal(500)) + Expect(cfg.RequiredIPAddresses).To(Equal(10)) + }) + }) + + Context("with invalid integer values", func() { + BeforeEach(func() { + Expect(os.Setenv("PROJECT_ID", "test-project")).To(Succeed()) + Expect(os.Setenv("REQUIRED_VCPUS", "not-a-number")).To(Succeed()) + }) + + It("should use default value for invalid integers", func() { + cfg, err := config.LoadFromEnv() + Expect(err).NotTo(HaveOccurred()) + Expect(cfg.RequiredVCPUs).To(Equal(0)) + }) + }) + + Context("with invalid boolean values", func() { + BeforeEach(func() { + Expect(os.Setenv("PROJECT_ID", "test-project")).To(Succeed()) + Expect(os.Setenv("STOP_ON_FIRST_FAILURE", "not-a-bool")).To(Succeed()) + }) + + It("should use default value for invalid booleans", func() { + cfg, err := config.LoadFromEnv() + Expect(err).NotTo(HaveOccurred()) + Expect(cfg.StopOnFirstFailure).To(BeFalse()) + }) + }) + + Context("with network validator config", func() { + BeforeEach(func() { + Expect(os.Setenv("PROJECT_ID", "test-project")).To(Succeed()) + Expect(os.Setenv("VPC_NAME", "my-vpc")).To(Succeed()) + Expect(os.Setenv("SUBNET_NAME", "my-subnet")).To(Succeed()) + }) + + It("should load network configuration", func() { + cfg, err := config.LoadFromEnv() + Expect(err).NotTo(HaveOccurred()) + Expect(cfg.VPCName).To(Equal("my-vpc")) + Expect(cfg.SubnetName).To(Equal("my-subnet")) + }) + }) + }) + + Describe("IsValidatorEnabled", func() { + var cfg *config.Config + + BeforeEach(func() { + Expect(os.Setenv("PROJECT_ID", "test-project")).To(Succeed()) + }) + + Context("with no disabled list", func() { + BeforeEach(func() { + var err error + cfg, err = config.LoadFromEnv() + Expect(err).NotTo(HaveOccurred()) + }) + + It("should enable all validators by default", func() { + Expect(cfg.IsValidatorEnabled("api-enabled")).To(BeTrue()) + Expect(cfg.IsValidatorEnabled("quota-check")).To(BeTrue()) + Expect(cfg.IsValidatorEnabled("any-validator")).To(BeTrue()) + }) + }) + + Context("with disabled validators list", func() { + BeforeEach(func() { + Expect(os.Setenv("DISABLED_VALIDATORS", "quota-check")).To(Succeed()) + var err error + cfg, err = config.LoadFromEnv() + Expect(err).NotTo(HaveOccurred()) + }) + + It("should disable validators in the list", func() { + Expect(cfg.IsValidatorEnabled("quota-check")).To(BeFalse()) + Expect(cfg.IsValidatorEnabled("api-enabled")).To(BeTrue()) + Expect(cfg.IsValidatorEnabled("network-check")).To(BeTrue()) + }) + }) + + Context("with multiple disabled validators", func() { + BeforeEach(func() { + Expect(os.Setenv("DISABLED_VALIDATORS", "quota-check,network-check")).To(Succeed()) + var err error + cfg, err = config.LoadFromEnv() + Expect(err).NotTo(HaveOccurred()) + }) + + It("should disable all validators in the list", func() { + Expect(cfg.IsValidatorEnabled("quota-check")).To(BeFalse()) + Expect(cfg.IsValidatorEnabled("network-check")).To(BeFalse()) + Expect(cfg.IsValidatorEnabled("api-enabled")).To(BeTrue()) + }) + }) + }) +}) diff --git a/validator/pkg/gcp/client.go b/validator/pkg/gcp/client.go new file mode 100644 index 0000000..71b1407 --- /dev/null +++ b/validator/pkg/gcp/client.go @@ -0,0 +1,227 @@ +package gcp + +import ( + "context" + "fmt" + "log/slog" + "net/http" + "time" + + "golang.org/x/oauth2/google" + "google.golang.org/api/cloudresourcemanager/v1" + "google.golang.org/api/compute/v1" + "google.golang.org/api/googleapi" + "google.golang.org/api/iam/v1" + "google.golang.org/api/monitoring/v3" + "google.golang.org/api/option" + "google.golang.org/api/serviceusage/v1" +) + +const ( + // Retry configuration + initialBackoff = 100 * time.Millisecond + maxBackoff = 30 * time.Second + maxRetries = 5 + + // Retryable HTTP status codes + statusRateLimited = 429 + statusServiceUnavail = 503 + statusInternalError = 500 +) + +// getDefaultClient creates an HTTP client with WIF authentication +// Creates a new client for each call with the specified scopes +// google.DefaultClient handles connection pooling and credential caching internally +func getDefaultClient(ctx context.Context, scopes ...string) (*http.Client, error) { + return google.DefaultClient(ctx, scopes...) +} + +// retryWithBackoff wraps GCP API calls with exponential backoff retry logic +func retryWithBackoff(ctx context.Context, operation func() error) error { + var lastErr error + backoff := initialBackoff + + for attempt := 0; attempt < maxRetries; attempt++ { + if attempt > 0 { + // Calculate exponential backoff with jitter + if backoff < maxBackoff { + backoff = backoff * 2 + if backoff > maxBackoff { + backoff = maxBackoff + } + } + slog.Debug("Retrying GCP API call", "attempt", attempt, "backoff", backoff) + + select { + case <-time.After(backoff): + case <-ctx.Done(): + return fmt.Errorf("context cancelled during retry: %w", ctx.Err()) + } + } + + lastErr = operation() + if lastErr == nil { + return nil // Success + } + + // Check if error is retryable + if apiErr, ok := lastErr.(*googleapi.Error); ok { + // Retry on rate limit, service unavailable, and internal errors + if apiErr.Code == statusRateLimited || + apiErr.Code == statusServiceUnavail || + apiErr.Code == statusInternalError { + continue + } + // Don't retry on other errors (4xx client errors, etc.) + return lastErr + } + + // Retry on network/context errors + if ctx.Err() != nil { + return fmt.Errorf("context error: %w", ctx.Err()) + } + } + + return fmt.Errorf("max retries exceeded: %w", lastErr) +} + +// ClientFactory creates GCP service clients with WIF authentication +type ClientFactory struct { + projectID string + logger *slog.Logger +} + +// NewClientFactory creates a new GCP client factory +func NewClientFactory(projectID string, logger *slog.Logger) *ClientFactory { + return &ClientFactory{ + projectID: projectID, + logger: logger, + } +} + +// CreateComputeService creates a Compute Engine service client with minimal scopes +func (f *ClientFactory) CreateComputeService(ctx context.Context) (*compute.Service, error) { + f.logger.Debug("Creating Compute Engine service client with WIF") + + // Use readonly scope for read-only operations (quota checks, list instances, etc.) + client, err := getDefaultClient(ctx, compute.ComputeReadonlyScope) + if err != nil { + return nil, fmt.Errorf("failed to create default client: %w", err) + } + + var svc *compute.Service + err = retryWithBackoff(ctx, func() error { + var createErr error + svc, createErr = compute.NewService(ctx, option.WithHTTPClient(client)) + return createErr + }) + if err != nil { + return nil, fmt.Errorf("failed to create compute service: %w", err) + } + + return svc, nil +} + +// CreateIAMService creates an IAM service client with minimal scopes +func (f *ClientFactory) CreateIAMService(ctx context.Context) (*iam.Service, error) { + f.logger.Debug("Creating IAM service client with WIF") + + // Use readonly scope for validation (checking service accounts, roles, etc.) + client, err := getDefaultClient(ctx, "https://www.googleapis.com/auth/cloud-platform.read-only") + if err != nil { + return nil, fmt.Errorf("failed to create default client: %w", err) + } + + var svc *iam.Service + err = retryWithBackoff(ctx, func() error { + var createErr error + svc, createErr = iam.NewService(ctx, option.WithHTTPClient(client)) + return createErr + }) + if err != nil { + return nil, fmt.Errorf("failed to create IAM service: %w", err) + } + + return svc, nil +} + +// CreateCloudResourceManagerService creates a Cloud Resource Manager service client with minimal scopes +func (f *ClientFactory) CreateCloudResourceManagerService(ctx context.Context) (*cloudresourcemanager.Service, error) { + f.logger.Debug("Creating Cloud Resource Manager service client with WIF") + + // Use readonly scope for read-only project operations + client, err := getDefaultClient(ctx, cloudresourcemanager.CloudPlatformReadOnlyScope) + if err != nil { + return nil, fmt.Errorf("failed to create default client: %w", err) + } + + var svc *cloudresourcemanager.Service + err = retryWithBackoff(ctx, func() error { + var createErr error + svc, createErr = cloudresourcemanager.NewService(ctx, option.WithHTTPClient(client)) + return createErr + }) + if err != nil { + return nil, fmt.Errorf("failed to create cloud resource manager service: %w", err) + } + + return svc, nil +} + +// CreateServiceUsageService creates a Service Usage service client with minimal scopes +func (f *ClientFactory) CreateServiceUsageService(ctx context.Context) (*serviceusage.Service, error) { + f.logger.Debug("Creating Service Usage service client with WIF") + + // Use readonly scope for checking API enablement status + client, err := getDefaultClient(ctx, serviceusage.CloudPlatformReadOnlyScope) + if err != nil { + return nil, fmt.Errorf("failed to create default client: %w", err) + } + + var svc *serviceusage.Service + err = retryWithBackoff(ctx, func() error { + var createErr error + svc, createErr = serviceusage.NewService(ctx, option.WithHTTPClient(client)) + return createErr + }) + if err != nil { + return nil, fmt.Errorf("failed to create service usage service: %w", err) + } + + return svc, nil +} + +// CreateMonitoringService creates a Monitoring service client with minimal scopes +func (f *ClientFactory) CreateMonitoringService(ctx context.Context) (*monitoring.Service, error) { + f.logger.Debug("Creating Monitoring service client with WIF") + + // Use readonly scope for reading metrics/alerts + client, err := getDefaultClient(ctx, monitoring.MonitoringReadScope) + if err != nil { + return nil, fmt.Errorf("failed to create default client: %w", err) + } + + var svc *monitoring.Service + err = retryWithBackoff(ctx, func() error { + var createErr error + svc, createErr = monitoring.NewService(ctx, option.WithHTTPClient(client)) + return createErr + }) + if err != nil { + return nil, fmt.Errorf("failed to create monitoring service: %w", err) + } + + return svc, nil +} + +// Test helpers - exported for testing purposes only + +// GetDefaultClientForTesting exposes getDefaultClient for testing +func GetDefaultClientForTesting(ctx context.Context, scopes ...string) (*http.Client, error) { + return getDefaultClient(ctx, scopes...) +} + +// RetryWithBackoffForTesting exposes retryWithBackoff for testing +func RetryWithBackoffForTesting(ctx context.Context, operation func() error) error { + return retryWithBackoff(ctx, operation) +} diff --git a/validator/pkg/gcp/client_test.go b/validator/pkg/gcp/client_test.go new file mode 100644 index 0000000..7c424d6 --- /dev/null +++ b/validator/pkg/gcp/client_test.go @@ -0,0 +1,189 @@ +package gcp_test + +import ( + "context" + "errors" + "log/slog" + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "google.golang.org/api/googleapi" + + "validator/pkg/gcp" +) + +var _ = Describe("GCP Client", func() { + Describe("getDefaultClient", func() { + Context("with different scopes", func() { + It("should create new clients for each scope", func() { + ctx := context.Background() + scopes1 := []string{"https://www.googleapis.com/auth/cloud-platform.read-only"} + scopes2 := []string{"https://www.googleapis.com/auth/compute.readonly"} + + // First call with scopes1 + client1, err1 := gcp.GetDefaultClientForTesting(ctx, scopes1...) + Expect(err1).NotTo(HaveOccurred()) + Expect(client1).NotTo(BeNil()) + + // Second call with scopes2 should return a different instance + client2, err2 := gcp.GetDefaultClientForTesting(ctx, scopes2...) + Expect(err2).NotTo(HaveOccurred()) + Expect(client2).NotTo(BeNil()) + Expect(client2).NotTo(BeIdenticalTo(client1), "Expected different client instances for different scopes") + }) + + It("should create valid clients", func() { + ctx := context.Background() + scopes := []string{"https://www.googleapis.com/auth/cloud-platform.read-only"} + + client, err := gcp.GetDefaultClientForTesting(ctx, scopes...) + Expect(err).NotTo(HaveOccurred()) + Expect(client).NotTo(BeNil()) + Expect(client.Transport).NotTo(BeNil()) + }) + }) + }) + + Describe("retryWithBackoff", func() { + var ctx context.Context + + BeforeEach(func() { + ctx = context.Background() + }) + + Context("when operation succeeds on first attempt", func() { + It("should return success without retrying", func() { + callCount := 0 + operation := func() error { + callCount++ + return nil + } + + err := gcp.RetryWithBackoffForTesting(ctx, operation) + Expect(err).NotTo(HaveOccurred()) + Expect(callCount).To(Equal(1), "Should only call once on success") + }) + }) + + Context("with retryable errors", func() { + DescribeTable("should retry based on error code", + func(errorCode int, shouldRetry bool, expectedAttempts int) { + callCount := 0 + operation := func() error { + callCount++ + return &googleapi.Error{Code: errorCode} + } + + err := gcp.RetryWithBackoffForTesting(ctx, operation) + Expect(err).To(HaveOccurred(), "Should return error") + Expect(callCount).To(Equal(expectedAttempts)) + }, + Entry("429 Rate Limit - should retry", 429, true, 5), + Entry("503 Service Unavailable - should retry", 503, true, 5), + Entry("500 Internal Error - should retry", 500, true, 5), + Entry("404 Not Found - should not retry", 404, false, 1), + Entry("403 Forbidden - should not retry", 403, false, 1), + ) + }) + + Context("when context is cancelled during retry", func() { + It("should stop retrying and return context error", func() { + ctx, cancel := context.WithCancel(context.Background()) + callCount := 0 + + operation := func() error { + callCount++ + if callCount == 2 { + cancel() // Cancel on second attempt + } + return &googleapi.Error{Code: 503} // Retryable error + } + + err := gcp.RetryWithBackoffForTesting(ctx, operation) + Expect(err).To(HaveOccurred()) + Expect(errors.Is(err, context.Canceled)).To(BeTrue(), "Should return context.Canceled error") + Expect(callCount).To(Equal(2), "Should have attempted twice before cancellation") + }) + }) + + Context("when context times out", func() { + It("should return deadline exceeded error", func() { + ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond) + defer cancel() + + operation := func() error { + return &googleapi.Error{Code: 503} // Keep retrying + } + + err := gcp.RetryWithBackoffForTesting(ctx, operation) + Expect(err).To(HaveOccurred()) + Expect(errors.Is(err, context.DeadlineExceeded)).To(BeTrue(), "Should return deadline exceeded error") + }) + }) + + Context("when max retries are exceeded", func() { + It("should return error after 5 attempts", func() { + callCount := 0 + operation := func() error { + callCount++ + return &googleapi.Error{Code: 503} // Always fail with retryable error + } + + err := gcp.RetryWithBackoffForTesting(ctx, operation) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("max retries exceeded")) + Expect(callCount).To(Equal(5), "Should attempt 5 times (initial + 4 retries)") + }) + }) + + Context("with non-googleapi errors", func() { + It("should retry generic errors until max retries", func() { + callCount := 0 + operation := func() error { + callCount++ + return errors.New("generic error") + } + + err := gcp.RetryWithBackoffForTesting(ctx, operation) + Expect(err).To(HaveOccurred()) + Expect(callCount).To(Equal(5), "Should retry generic errors until max retries") + }) + }) + }) + + Describe("ClientFactory", func() { + var ( + projectID string + logger *slog.Logger + ) + + BeforeEach(func() { + projectID = "test-project" + logger = slog.Default() + }) + + Describe("NewClientFactory", func() { + It("should create a new factory with correct values", func() { + factory := gcp.NewClientFactory(projectID, logger) + Expect(factory).NotTo(BeNil()) + + // Note: We can't directly test private fields, but we can test behavior + // by using the factory to create services (which would fail if projectID is wrong) + }) + + It("should accept different project IDs", func() { + factory := gcp.NewClientFactory("my-test-project", logger) + Expect(factory).NotTo(BeNil()) + }) + }) + + // Note: Testing actual GCP service creation requires either: + // 1. Mocking google.DefaultClient (complex, requires dependency injection) + // 2. Integration tests with real GCP credentials + // 3. Using interfaces and dependency injection (architectural change) + // + // For now, we test the factory creation and leave service creation for integration tests. + // The CreateXXXService methods follow the same pattern, so testing one validates the pattern. + }) +}) diff --git a/validator/pkg/gcp/gcp_suite_test.go b/validator/pkg/gcp/gcp_suite_test.go new file mode 100644 index 0000000..50fc1ff --- /dev/null +++ b/validator/pkg/gcp/gcp_suite_test.go @@ -0,0 +1,13 @@ +package gcp_test + +import ( + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestGCP(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "GCP Suite") +} diff --git a/validator/pkg/validator/context.go b/validator/pkg/validator/context.go new file mode 100644 index 0000000..56cffd7 --- /dev/null +++ b/validator/pkg/validator/context.go @@ -0,0 +1,30 @@ +package validator + +import ( + compute "google.golang.org/api/compute/v1" + iam "google.golang.org/api/iam/v1" + cloudresourcemanager "google.golang.org/api/cloudresourcemanager/v1" + serviceusage "google.golang.org/api/serviceusage/v1" + monitoring "google.golang.org/api/monitoring/v3" + + "validator/pkg/config" +) + +// Context provides shared resources and configuration to all validators +type Context struct { + // Configuration + Config *config.Config + + // GCP Clients (lazily initialized, shared across validators) + ComputeService *compute.Service + IAMService *iam.Service + CloudResourceManagerSvc *cloudresourcemanager.Service + ServiceUsageService *serviceusage.Service + MonitoringService *monitoring.Service + + // Shared state between validators + ProjectNumber int64 + + // Results from previous validators (for dependency checking) + Results map[string]*Result +} diff --git a/validator/pkg/validator/executor.go b/validator/pkg/validator/executor.go new file mode 100644 index 0000000..083ca33 --- /dev/null +++ b/validator/pkg/validator/executor.go @@ -0,0 +1,172 @@ +package validator + +import ( + "context" + "fmt" + "log/slog" + "runtime/debug" + "sync" + "time" +) + +// Executor orchestrates validator execution +type Executor struct { + ctx *Context + logger *slog.Logger + mu sync.Mutex // Protects results map during parallel execution +} + +// NewExecutor creates a new executor +func NewExecutor(ctx *Context, logger *slog.Logger) *Executor { + return &Executor{ + ctx: ctx, + logger: logger, + } +} + +// ExecuteAll runs validators with dependency resolution and parallel execution +func (e *Executor) ExecuteAll(ctx context.Context) ([]*Result, error) { + // 1. Get all registered validators + allValidators := GetAll() + + // 2. Filter enabled validators + enabledValidators := []Validator{} + for _, v := range allValidators { + if v.Enabled(e.ctx) { + enabledValidators = append(enabledValidators, v) + } else { + meta := v.Metadata() + e.logger.Info("Validator disabled, skipping", "validator", meta.Name) + } + } + + if len(enabledValidators) == 0 { + return nil, fmt.Errorf("no validators enabled") + } + + e.logger.Info("Found enabled validators", "count", len(enabledValidators)) + + // 3. Resolve dependencies and build execution plan + resolver := NewDependencyResolver(enabledValidators) + groups, err := resolver.ResolveExecutionGroups() + if err != nil { + return nil, fmt.Errorf("dependency resolution failed: %w", err) + } + + e.logger.Info("Execution plan created", "groups", len(groups)) + for _, group := range groups { + e.logger.Debug("Execution group", + "level", group.Level, + "validators", len(group.Validators), + "mode", "parallel") + } + + // 4. Execute validators group by group + allResults := []*Result{} + for _, group := range groups { + e.logger.Info("Executing level", + "level", group.Level, + "validators", len(group.Validators)) + + groupResults := e.executeGroup(ctx, group) + allResults = append(allResults, groupResults...) + + // Check stop on failure + if e.ctx.Config.StopOnFirstFailure { + for _, result := range groupResults { + if result.Status == StatusFailure { + e.logger.Warn("Stopping due to failure", "validator", result.ValidatorName) + return allResults, nil + } + } + } + } + + return allResults, nil +} + +// executeGroup runs all validators in a group in parallel +func (e *Executor) executeGroup(ctx context.Context, group ExecutionGroup) []*Result { + var wg sync.WaitGroup + results := make([]*Result, len(group.Validators)) + + for i, v := range group.Validators { + wg.Add(1) + go func(index int, validator Validator) { + defer wg.Done() + + // Add panic recovery to prevent one validator from crashing all validators + defer func() { + if r := recover(); r != nil { + stack := string(debug.Stack()) + meta := validator.Metadata() + e.logger.Error("Validator panicked", + "validator", meta.Name, + "panic", r, + "stack", stack) + + // Create failure result for panicked validator + panicResult := &Result{ + ValidatorName: meta.Name, + Status: StatusFailure, + Reason: "ValidatorPanic", + Message: fmt.Sprintf("Validator crashed: %v", r), + Details: map[string]interface{}{ + "panic": fmt.Sprint(r), + "panic_type": fmt.Sprintf("%T", r), + "stack": stack, + }, + Duration: 0, + Timestamp: time.Now().UTC(), + } + + // Thread-safe result storage + e.mu.Lock() + e.ctx.Results[meta.Name] = panicResult + results[index] = panicResult + e.mu.Unlock() + } + }() + + meta := validator.Metadata() + e.logger.Info("Running validator", "validator", meta.Name) + + start := time.Now() + result := validator.Validate(ctx, e.ctx) + result.Duration = time.Since(start) + result.Timestamp = time.Now().UTC() + result.ValidatorName = meta.Name + + // Thread-safe result storage + e.mu.Lock() + e.ctx.Results[meta.Name] = result + e.mu.Unlock() + + results[index] = result + + // Log based on result status + logAttrs := []any{ + "validator", meta.Name, + "status", result.Status, + "duration", result.Duration, + } + switch result.Status { + case StatusFailure: + // Add reason and message for failures to help with debugging + logAttrs = append(logAttrs, + "reason", result.Reason, + "message", result.Message) + e.logger.Warn("Validator completed with failure", logAttrs...) + case StatusSkipped: + // Add reason for skipped validators + logAttrs = append(logAttrs, "reason", result.Reason) + e.logger.Info("Validator skipped", logAttrs...) + default: + e.logger.Info("Validator completed", logAttrs...) + } + }(i, v) + } + + wg.Wait() // Wait for all validators in this group + return results +} diff --git a/validator/pkg/validator/executor_test.go b/validator/pkg/validator/executor_test.go new file mode 100644 index 0000000..df39278 --- /dev/null +++ b/validator/pkg/validator/executor_test.go @@ -0,0 +1,287 @@ +package validator_test + +import ( + "context" + "log/slog" + "os" + "sync" + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "validator/pkg/config" + "validator/pkg/validator" +) + +var _ = Describe("Executor", func() { + var ( + ctx context.Context + vctx *validator.Context + executor *validator.Executor + logger *slog.Logger + ) + + BeforeEach(func() { + ctx = context.Background() + logger = slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{ + Level: slog.LevelWarn, // Reduce noise in test output + })) + + // Clear the global registry before each test + validator.ClearRegistry() + + // Set up minimal config + Expect(os.Setenv("PROJECT_ID", "test-project")).To(Succeed()) + cfg, err := config.LoadFromEnv() + Expect(err).NotTo(HaveOccurred()) + + vctx = &validator.Context{ + Config: cfg, + Results: make(map[string]*validator.Result), + } + }) + + AfterEach(func() { + Expect(os.Unsetenv("PROJECT_ID")).To(Succeed()) + }) + + Describe("ExecuteAll", func() { + Context("with no validators registered", func() { + It("should return error when no validators are enabled", func() { + executor = validator.NewExecutor(vctx, logger) + results, err := executor.ExecuteAll(ctx) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("no validators enabled")) + Expect(results).To(BeNil()) + }) + }) + + Context("with single validator", func() { + var mockValidator *MockValidator + + BeforeEach(func() { + mockValidator = &MockValidator{ + name: "test-validator", + enabled: true, + validateFunc: func(ctx context.Context, vctx *validator.Context) *validator.Result { + return &validator.Result{ + ValidatorName: "test-validator", + Status: validator.StatusSuccess, + Reason: "TestPassed", + Message: "Test validation successful", + } + }, + } + validator.Register(mockValidator) + }) + + It("should execute the validator", func() { + executor = validator.NewExecutor(vctx, logger) + results, err := executor.ExecuteAll(ctx) + Expect(err).NotTo(HaveOccurred()) + Expect(results).To(HaveLen(1)) + Expect(results[0].ValidatorName).To(Equal("test-validator")) + Expect(results[0].Status).To(Equal(validator.StatusSuccess)) + }) + + It("should store result in context", func() { + executor = validator.NewExecutor(vctx, logger) + _, err := executor.ExecuteAll(ctx) + Expect(err).NotTo(HaveOccurred()) + Expect(vctx.Results).To(HaveKey("test-validator")) + }) + + It("should set timestamp and duration", func() { + executor = validator.NewExecutor(vctx, logger) + results, err := executor.ExecuteAll(ctx) + Expect(err).NotTo(HaveOccurred()) + Expect(results[0].Timestamp).NotTo(BeZero()) + Expect(results[0].Duration).To(BeNumerically(">", 0)) + }) + }) + + Context("with disabled validator", func() { + var mockValidator *MockValidator + + BeforeEach(func() { + mockValidator = &MockValidator{ + name: "disabled-validator", + enabled: false, + } + validator.Register(mockValidator) + }) + + It("should skip disabled validators", func() { + executor = validator.NewExecutor(vctx, logger) + _, err := executor.ExecuteAll(ctx) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("no validators enabled")) + }) + }) + + Context("with multiple independent validators", func() { + BeforeEach(func() { + for i := 1; i <= 3; i++ { + name := "validator-" + string(rune('a'+i-1)) + n := name // Capture loop variable for closure + validator.Register(&MockValidator{ + name: n, + enabled: true, + validateFunc: func(ctx context.Context, vctx *validator.Context) *validator.Result { + time.Sleep(10 * time.Millisecond) // Simulate work + return &validator.Result{ + ValidatorName: n, + Status: validator.StatusSuccess, + Reason: "Success", + Message: "Passed", + } + }, + }) + } + }) + + It("should execute all validators in parallel", func() { + executor = validator.NewExecutor(vctx, logger) + start := time.Now() + results, err := executor.ExecuteAll(ctx) + duration := time.Since(start) + + Expect(err).NotTo(HaveOccurred()) + Expect(results).To(HaveLen(3)) + // Parallel execution should take ~10ms, not ~30ms (sequential) + Expect(duration).To(BeNumerically("<", 100*time.Millisecond)) + }) + + It("should store all results in context", func() { + executor = validator.NewExecutor(vctx, logger) + _, err := executor.ExecuteAll(ctx) + Expect(err).NotTo(HaveOccurred()) + Expect(vctx.Results).To(HaveLen(3)) + }) + }) + + Context("with dependent validators", func() { + var executionOrder []string + var mu sync.Mutex + + BeforeEach(func() { + executionOrder = []string{} + + // Level 0 validator + validator.Register(&MockValidator{ + name: "validator-a", + runAfter: []string{}, + enabled: true, + validateFunc: func(ctx context.Context, vctx *validator.Context) *validator.Result { + mu.Lock() + executionOrder = append(executionOrder, "validator-a") + mu.Unlock() + return &validator.Result{ + ValidatorName: "validator-a", + Status: validator.StatusSuccess, + } + }, + }) + + // Level 1 validators (depend on validator-a) + for _, name := range []string{"validator-b", "validator-c"} { + n := name + validator.Register(&MockValidator{ + name: n, + runAfter: []string{"validator-a"}, + enabled: true, + validateFunc: func(ctx context.Context, vctx *validator.Context) *validator.Result { + mu.Lock() + executionOrder = append(executionOrder, n) + mu.Unlock() + return &validator.Result{ + ValidatorName: n, + Status: validator.StatusSuccess, + } + }, + }) + } + }) + + It("should execute validators in dependency order", func() { + executor = validator.NewExecutor(vctx, logger) + results, err := executor.ExecuteAll(ctx) + Expect(err).NotTo(HaveOccurred()) + Expect(results).To(HaveLen(3)) + + // validator-a should execute before b and c + Expect(executionOrder[0]).To(Equal("validator-a")) + Expect(executionOrder[1:]).To(ConsistOf("validator-b", "validator-c")) + }) + }) + + Context("with StopOnFirstFailure enabled", func() { + BeforeEach(func() { + vctx.Config.StopOnFirstFailure = true + + // First validator fails + validator.Register(&MockValidator{ + name: "failing-validator", + enabled: true, + validateFunc: func(ctx context.Context, vctx *validator.Context) *validator.Result { + return &validator.Result{ + ValidatorName: "failing-validator", + Status: validator.StatusFailure, + Reason: "TestFailure", + Message: "Intentional failure", + } + }, + }) + + // Second validator should not run + validator.Register(&MockValidator{ + name: "should-not-run", + runAfter: []string{"failing-validator"}, + enabled: true, + validateFunc: func(ctx context.Context, vctx *validator.Context) *validator.Result { + Fail("This validator should not execute") + return nil + }, + }) + }) + + It("should stop execution after first failure", func() { + executor = validator.NewExecutor(vctx, logger) + results, err := executor.ExecuteAll(ctx) + Expect(err).NotTo(HaveOccurred()) + Expect(results).To(HaveLen(1)) + Expect(results[0].Status).To(Equal(validator.StatusFailure)) + }) + }) + + Context("with validator that returns failure", func() { + BeforeEach(func() { + validator.Register(&MockValidator{ + name: "failing-validator", + enabled: true, + validateFunc: func(ctx context.Context, vctx *validator.Context) *validator.Result { + return &validator.Result{ + ValidatorName: "failing-validator", + Status: validator.StatusFailure, + Reason: "ValidationFailed", + Message: "Validation check failed", + Details: map[string]interface{}{ + "error": "Test error", + }, + } + }, + }) + }) + + It("should return the failure result", func() { + executor = validator.NewExecutor(vctx, logger) + results, err := executor.ExecuteAll(ctx) + Expect(err).NotTo(HaveOccurred()) + Expect(results).To(HaveLen(1)) + Expect(results[0].Status).To(Equal(validator.StatusFailure)) + Expect(results[0].Reason).To(Equal("ValidationFailed")) + }) + }) + }) +}) diff --git a/validator/pkg/validator/registry.go b/validator/pkg/validator/registry.go new file mode 100644 index 0000000..9dc204d --- /dev/null +++ b/validator/pkg/validator/registry.go @@ -0,0 +1,83 @@ +package validator + +import ( + "fmt" + "sync" +) + +// Registry holds all registered validators +var globalRegistry = NewRegistry() + +type Registry struct { + mu sync.RWMutex + validators map[string]Validator +} + +// NewRegistry creates a new validator registry +func NewRegistry() *Registry { + return &Registry{ + validators: make(map[string]Validator), + } +} + +// Register adds a validator to the registry +func (r *Registry) Register(v Validator) { + r.mu.Lock() + defer r.mu.Unlock() + + meta := v.Metadata() + // Allow overwriting for testing purposes + r.validators[meta.Name] = v +} + +// GetAll returns all registered validators +func (r *Registry) GetAll() []Validator { + r.mu.RLock() + defer r.mu.RUnlock() + + validators := make([]Validator, 0, len(r.validators)) + for _, v := range r.validators { + validators = append(validators, v) + } + return validators +} + +// Get retrieves a validator by name +func (r *Registry) Get(name string) (Validator, bool) { + r.mu.RLock() + defer r.mu.RUnlock() + v, ok := r.validators[name] + return v, ok +} + +// Package-level functions for global registry + +// Register adds a validator to the global registry +// This is called from init() functions in validator implementations +func Register(v Validator) { + meta := v.Metadata() + globalRegistry.mu.Lock() + defer globalRegistry.mu.Unlock() + + if _, exists := globalRegistry.validators[meta.Name]; exists { + panic(fmt.Sprintf("validator already registered: %s", meta.Name)) + } + globalRegistry.validators[meta.Name] = v +} + +// GetAll returns all registered validators from global registry +func GetAll() []Validator { + return globalRegistry.GetAll() +} + +// Get retrieves a validator by name from global registry +func Get(name string) (Validator, bool) { + return globalRegistry.Get(name) +} + +// ClearRegistry clears all validators from the global registry (for testing) +func ClearRegistry() { + globalRegistry.mu.Lock() + defer globalRegistry.mu.Unlock() + globalRegistry.validators = make(map[string]Validator) +} diff --git a/validator/pkg/validator/registry_test.go b/validator/pkg/validator/registry_test.go new file mode 100644 index 0000000..9bea53a --- /dev/null +++ b/validator/pkg/validator/registry_test.go @@ -0,0 +1,144 @@ +package validator_test + +import ( + "context" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "validator/pkg/validator" +) + +// Mock validator for testing +type MockValidator struct { + name string + description string + runAfter []string + tags []string + enabled bool + validateFunc func(ctx context.Context, vctx *validator.Context) *validator.Result +} + +func (m *MockValidator) Metadata() validator.ValidatorMetadata { + return validator.ValidatorMetadata{ + Name: m.name, + Description: m.description, + RunAfter: m.runAfter, + Tags: m.tags, + } +} + +func (m *MockValidator) Enabled(ctx *validator.Context) bool { + return m.enabled +} + +func (m *MockValidator) Validate(ctx context.Context, vctx *validator.Context) *validator.Result { + if m.validateFunc != nil { + return m.validateFunc(ctx, vctx) + } + return &validator.Result{ + ValidatorName: m.name, + Status: validator.StatusSuccess, + Reason: "TestSuccess", + Message: "Test validation passed", + } +} + +var _ = Describe("Registry", func() { + var ( + testRegistry *validator.Registry + mockValidator1 *MockValidator + mockValidator2 *MockValidator + ) + + BeforeEach(func() { + testRegistry = validator.NewRegistry() + mockValidator1 = &MockValidator{ + name: "test-validator-1", + description: "First test validator", + runAfter: []string{}, + tags: []string{"test", "mock"}, + enabled: true, + } + mockValidator2 = &MockValidator{ + name: "test-validator-2", + description: "Second test validator", + runAfter: []string{"test-validator-1"}, + tags: []string{"test", "dependent"}, + enabled: true, + } + }) + + Describe("Register", func() { + Context("when registering a new validator", func() { + It("should add the validator to the registry", func() { + testRegistry.Register(mockValidator1) + validators := testRegistry.GetAll() + Expect(validators).To(HaveLen(1)) + Expect(validators[0].Metadata().Name).To(Equal("test-validator-1")) + }) + }) + + Context("when registering multiple validators", func() { + It("should add all validators to the registry", func() { + testRegistry.Register(mockValidator1) + testRegistry.Register(mockValidator2) + validators := testRegistry.GetAll() + Expect(validators).To(HaveLen(2)) + }) + }) + + Context("when registering a validator with duplicate name", func() { + It("should overwrite the existing validator", func() { + testRegistry.Register(mockValidator1) + duplicate := &MockValidator{ + name: "test-validator-1", + description: "Duplicate validator", + enabled: true, + } + testRegistry.Register(duplicate) + validators := testRegistry.GetAll() + Expect(validators).To(HaveLen(1)) + Expect(validators[0].Metadata().Description).To(Equal("Duplicate validator")) + }) + }) + }) + + Describe("GetAll", func() { + Context("when registry is empty", func() { + It("should return an empty slice", func() { + validators := testRegistry.GetAll() + Expect(validators).To(BeEmpty()) + }) + }) + + Context("when registry has validators", func() { + It("should return all registered validators", func() { + testRegistry.Register(mockValidator1) + testRegistry.Register(mockValidator2) + validators := testRegistry.GetAll() + Expect(validators).To(HaveLen(2)) + }) + }) + }) + + Describe("Get", func() { + BeforeEach(func() { + testRegistry.Register(mockValidator1) + testRegistry.Register(mockValidator2) + }) + + Context("when getting a validator by name", func() { + It("should return the validator if it exists", func() { + v, exists := testRegistry.Get("test-validator-1") + Expect(exists).To(BeTrue()) + Expect(v.Metadata().Name).To(Equal("test-validator-1")) + }) + + It("should return false if validator doesn't exist", func() { + _, exists := testRegistry.Get("non-existent") + Expect(exists).To(BeFalse()) + }) + }) + }) +}) diff --git a/validator/pkg/validator/resolver.go b/validator/pkg/validator/resolver.go new file mode 100644 index 0000000..6e66c10 --- /dev/null +++ b/validator/pkg/validator/resolver.go @@ -0,0 +1,148 @@ +package validator + +import ( + "fmt" + "sort" +) + +// ExecutionGroup represents validators that can run in parallel +type ExecutionGroup struct { + Level int // Execution level (0 = first, 1 = second, etc.) + Validators []Validator // Validators to run in parallel at this level +} + +// DependencyResolver builds execution plan from validators +type DependencyResolver struct { + validators map[string]Validator +} + +// NewDependencyResolver creates a new resolver +func NewDependencyResolver(validators []Validator) *DependencyResolver { + m := make(map[string]Validator) + for _, v := range validators { + meta := v.Metadata() + m[meta.Name] = v + } + return &DependencyResolver{validators: m} +} + +// ResolveExecutionGroups organizes validators into parallel execution groups +// Validators with no dependencies or same dependencies can run in parallel +func (r *DependencyResolver) ResolveExecutionGroups() ([]ExecutionGroup, error) { + // 1. Detect cycles + if err := r.detectCycles(); err != nil { + return nil, err + } + + // 2. Topological sort with level assignment + levels := r.assignLevels() + + // 3. Group by level + groups := make([]ExecutionGroup, 0) + for level := 0; ; level++ { + var validators []Validator + for _, v := range r.validators { + meta := v.Metadata() + if levels[meta.Name] == level { + validators = append(validators, v) + } + } + if len(validators) == 0 { + break + } + + // Sort alphabetically by name within the same level for deterministic execution + sort.Slice(validators, func(i, j int) bool { + return validators[i].Metadata().Name < validators[j].Metadata().Name + }) + + groups = append(groups, ExecutionGroup{ + Level: level, + Validators: validators, + }) + } + + return groups, nil +} + +// assignLevels performs topological sort and assigns execution levels +func (r *DependencyResolver) assignLevels() map[string]int { + levels := make(map[string]int) + + // Recursive DFS to calculate max depth + var calcLevel func(name string) int + calcLevel = func(name string) int { + if level, ok := levels[name]; ok { + return level + } + + v := r.validators[name] + meta := v.Metadata() + + maxDepLevel := -1 + // Check dependencies from metadata + for _, dep := range meta.RunAfter { + if depValidator, exists := r.validators[dep]; exists { + depLevel := calcLevel(depValidator.Metadata().Name) + if depLevel > maxDepLevel { + maxDepLevel = depLevel + } + } + } + // If RunAfter is empty, maxDepLevel stays -1, so level = 0 + + level := maxDepLevel + 1 + levels[name] = level + return level + } + + for name := range r.validators { + calcLevel(name) + } + + return levels +} + +// detectCycles detects circular dependencies using DFS +func (r *DependencyResolver) detectCycles() error { + visited := make(map[string]bool) + recStack := make(map[string]bool) + + var dfs func(name string) error + dfs = func(name string) error { + visited[name] = true + recStack[name] = true + + v := r.validators[name] + meta := v.Metadata() + + // Check all dependencies from metadata + for _, dep := range meta.RunAfter { + // Skip dependencies that don't exist (will be ignored in level assignment) + if _, exists := r.validators[dep]; !exists { + continue + } + + if !visited[dep] { + if err := dfs(dep); err != nil { + return err + } + } else if recStack[dep] { + return fmt.Errorf("circular dependency detected: %s -> %s", name, dep) + } + } + + recStack[name] = false + return nil + } + + for name := range r.validators { + if !visited[name] { + if err := dfs(name); err != nil { + return err + } + } + } + + return nil +} diff --git a/validator/pkg/validator/resolver_test.go b/validator/pkg/validator/resolver_test.go new file mode 100644 index 0000000..2f72c0b --- /dev/null +++ b/validator/pkg/validator/resolver_test.go @@ -0,0 +1,275 @@ +package validator_test + +import ( + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "validator/pkg/validator" +) + +var _ = Describe("DependencyResolver", func() { + var ( + resolver *validator.DependencyResolver + validators []validator.Validator + ) + + Describe("ResolveExecutionGroups", func() { + Context("with validators that have no dependencies", func() { + BeforeEach(func() { + validators = []validator.Validator{ + &MockValidator{ + name: "validator-a", + runAfter: []string{}, + enabled: true, + }, + &MockValidator{ + name: "validator-b", + runAfter: []string{}, + enabled: true, + }, + &MockValidator{ + name: "validator-c", + runAfter: []string{}, + enabled: true, + }, + } + resolver = validator.NewDependencyResolver(validators) + }) + + It("should place all validators in level 0", func() { + groups, err := resolver.ResolveExecutionGroups() + Expect(err).NotTo(HaveOccurred()) + Expect(groups).To(HaveLen(1)) + Expect(groups[0].Level).To(Equal(0)) + Expect(groups[0].Validators).To(HaveLen(3)) + }) + + It("should sort validators alphabetically within the same level", func() { + groups, err := resolver.ResolveExecutionGroups() + Expect(err).NotTo(HaveOccurred()) + names := make([]string, len(groups[0].Validators)) + for i, v := range groups[0].Validators { + names[i] = v.Metadata().Name + } + Expect(names).To(Equal([]string{"validator-a", "validator-b", "validator-c"})) + }) + }) + + Context("with linear dependencies", func() { + BeforeEach(func() { + validators = []validator.Validator{ + &MockValidator{ + name: "validator-a", + runAfter: []string{}, + enabled: true, + }, + &MockValidator{ + name: "validator-b", + runAfter: []string{"validator-a"}, + enabled: true, + }, + &MockValidator{ + name: "validator-c", + runAfter: []string{"validator-b"}, + enabled: true, + }, + } + resolver = validator.NewDependencyResolver(validators) + }) + + It("should create separate levels for each validator", func() { + groups, err := resolver.ResolveExecutionGroups() + Expect(err).NotTo(HaveOccurred()) + Expect(groups).To(HaveLen(3)) + + Expect(groups[0].Level).To(Equal(0)) + Expect(groups[0].Validators).To(HaveLen(1)) + Expect(groups[0].Validators[0].Metadata().Name).To(Equal("validator-a")) + + Expect(groups[1].Level).To(Equal(1)) + Expect(groups[1].Validators).To(HaveLen(1)) + Expect(groups[1].Validators[0].Metadata().Name).To(Equal("validator-b")) + + Expect(groups[2].Level).To(Equal(2)) + Expect(groups[2].Validators).To(HaveLen(1)) + Expect(groups[2].Validators[0].Metadata().Name).To(Equal("validator-c")) + }) + }) + + Context("with parallel dependencies", func() { + BeforeEach(func() { + validators = []validator.Validator{ + &MockValidator{ + name: "wif-check", + runAfter: []string{}, + enabled: true, + }, + &MockValidator{ + name: "api-enabled", + runAfter: []string{"wif-check"}, + enabled: true, + }, + &MockValidator{ + name: "quota-check", + runAfter: []string{"wif-check"}, + enabled: true, + }, + &MockValidator{ + name: "network-check", + runAfter: []string{"wif-check"}, + enabled: true, + }, + } + resolver = validator.NewDependencyResolver(validators) + }) + + It("should group validators with same dependencies at the same level", func() { + groups, err := resolver.ResolveExecutionGroups() + Expect(err).NotTo(HaveOccurred()) + Expect(groups).To(HaveLen(2)) + + // Level 0: wif-check + Expect(groups[0].Level).To(Equal(0)) + Expect(groups[0].Validators).To(HaveLen(1)) + Expect(groups[0].Validators[0].Metadata().Name).To(Equal("wif-check")) + + // Level 1: api-enabled, quota-check, network-check (parallel) + Expect(groups[1].Level).To(Equal(1)) + Expect(groups[1].Validators).To(HaveLen(3)) + names := make([]string, 3) + for i, v := range groups[1].Validators { + names[i] = v.Metadata().Name + } + Expect(names).To(ConsistOf("api-enabled", "quota-check", "network-check")) + }) + }) + + Context("with complex dependency graph", func() { + BeforeEach(func() { + validators = []validator.Validator{ + &MockValidator{ + name: "wif-check", + runAfter: []string{}, + enabled: true, + }, + &MockValidator{ + name: "api-enabled", + runAfter: []string{"wif-check"}, + enabled: true, + }, + &MockValidator{ + name: "quota-check", + runAfter: []string{"wif-check"}, + enabled: true, + }, + &MockValidator{ + name: "iam-check", + runAfter: []string{"api-enabled"}, + enabled: true, + }, + &MockValidator{ + name: "network-check", + runAfter: []string{"api-enabled", "quota-check"}, + enabled: true, + }, + } + resolver = validator.NewDependencyResolver(validators) + }) + + It("should create correct levels based on dependencies", func() { + groups, err := resolver.ResolveExecutionGroups() + Expect(err).NotTo(HaveOccurred()) + Expect(groups).To(HaveLen(3)) + + // Level 0: wif-check + Expect(groups[0].Level).To(Equal(0)) + Expect(groups[0].Validators[0].Metadata().Name).To(Equal("wif-check")) + + // Level 1: api-enabled, quota-check + Expect(groups[1].Level).To(Equal(1)) + Expect(groups[1].Validators).To(HaveLen(2)) + + // Level 2: iam-check, network-check + Expect(groups[2].Level).To(Equal(2)) + Expect(groups[2].Validators).To(HaveLen(2)) + }) + }) + + Context("with circular dependencies", func() { + BeforeEach(func() { + validators = []validator.Validator{ + &MockValidator{ + name: "validator-a", + runAfter: []string{"validator-b"}, + enabled: true, + }, + &MockValidator{ + name: "validator-b", + runAfter: []string{"validator-a"}, + enabled: true, + }, + } + resolver = validator.NewDependencyResolver(validators) + }) + + It("should detect the circular dependency and return an error", func() { + _, err := resolver.ResolveExecutionGroups() + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("circular dependency")) + }) + }) + + Context("with self-referencing dependency", func() { + BeforeEach(func() { + validators = []validator.Validator{ + &MockValidator{ + name: "validator-a", + runAfter: []string{"validator-a"}, + enabled: true, + }, + } + resolver = validator.NewDependencyResolver(validators) + }) + + It("should detect the circular dependency and return an error", func() { + _, err := resolver.ResolveExecutionGroups() + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("circular dependency")) + }) + }) + + Context("with missing dependency", func() { + BeforeEach(func() { + validators = []validator.Validator{ + &MockValidator{ + name: "validator-a", + runAfter: []string{"non-existent"}, + enabled: true, + }, + } + resolver = validator.NewDependencyResolver(validators) + }) + + It("should handle missing dependencies gracefully", func() { + groups, err := resolver.ResolveExecutionGroups() + Expect(err).NotTo(HaveOccurred()) + // Missing dependencies are ignored, validator runs at level 0 + Expect(groups).To(HaveLen(1)) + Expect(groups[0].Level).To(Equal(0)) + }) + }) + + Context("with empty validator list", func() { + BeforeEach(func() { + validators = []validator.Validator{} + resolver = validator.NewDependencyResolver(validators) + }) + + It("should return empty groups", func() { + groups, err := resolver.ResolveExecutionGroups() + Expect(err).NotTo(HaveOccurred()) + Expect(groups).To(BeEmpty()) + }) + }) + }) +}) diff --git a/validator/pkg/validator/validator.go b/validator/pkg/validator/validator.go new file mode 100644 index 0000000..a10ea0f --- /dev/null +++ b/validator/pkg/validator/validator.go @@ -0,0 +1,108 @@ +package validator + +import ( + "context" + "fmt" + "strings" + "time" +) + +// ValidatorMetadata contains all validator configuration +// This is the single source of truth for validator properties +type ValidatorMetadata struct { + Name string // Unique identifier (e.g., "wif-check") + Description string // Human-readable description + RunAfter []string // Validators this should run after (dependencies) + Tags []string // For grouping/filtering (e.g., "mvp", "network", "quota") +} + +// Validator is the core interface all validators must implement +type Validator interface { + // Metadata returns validator configuration (name, dependencies, etc.) + Metadata() ValidatorMetadata + + // Enabled determines if this validator should run based on context/config + Enabled(ctx *Context) bool + + // Validate performs the actual validation logic + Validate(ctx context.Context, vctx *Context) *Result +} + +// Status represents the validation outcome +type Status string + +const ( + StatusSuccess Status = "success" + StatusFailure Status = "failure" + StatusSkipped Status = "skipped" +) + +// Result represents the outcome of a single validator +type Result struct { + ValidatorName string `json:"validator_name"` + Status Status `json:"status"` + Reason string `json:"reason"` + Message string `json:"message"` + Details map[string]interface{} `json:"details,omitempty"` + Duration time.Duration `json:"duration_ns"` + Timestamp time.Time `json:"timestamp"` +} + +// AggregatedResult combines all validator results into the expected output format +type AggregatedResult struct { + Status Status `json:"status"` + Reason string `json:"reason"` + Message string `json:"message"` + Details map[string]interface{} `json:"details"` +} + +// Aggregate combines multiple validator results into final output +func Aggregate(results []*Result) *AggregatedResult { + checksRun := len(results) + checksPassed := 0 + var failedChecks []string + var failureDescriptions []string + + // Single pass to collect all failure information + for _, r := range results { + switch r.Status { + case StatusSuccess: + checksPassed++ + case StatusFailure: + failedChecks = append(failedChecks, r.ValidatorName) + failureDescriptions = append(failureDescriptions, fmt.Sprintf("%s (%s)", r.ValidatorName, r.Reason)) + } + } + + details := map[string]interface{}{ + "checks_run": checksRun, + "checks_passed": checksPassed, + "timestamp": time.Now().UTC().Format(time.RFC3339), + "validators": results, + } + + if checksPassed == checksRun { + return &AggregatedResult{ + Status: StatusSuccess, + Reason: "ValidationPassed", + Message: "All GCP validation checks passed successfully", + Details: details, + } + } + + details["failed_checks"] = failedChecks + + // Build informative failure message with pass ratio and reasons + message := fmt.Sprintf("%d validation check(s) failed: %s. Passed: %d/%d", + len(failureDescriptions), + strings.Join(failureDescriptions, ", "), + checksPassed, + checksRun) + + return &AggregatedResult{ + Status: StatusFailure, + Reason: "ValidationFailed", + Message: message, + Details: details, + } +} diff --git a/validator/pkg/validator/validator_suite_test.go b/validator/pkg/validator/validator_suite_test.go new file mode 100644 index 0000000..b8fe992 --- /dev/null +++ b/validator/pkg/validator/validator_suite_test.go @@ -0,0 +1,13 @@ +package validator_test + +import ( + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestValidator(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Validator Suite") +} diff --git a/validator/pkg/validators/api_enabled.go b/validator/pkg/validators/api_enabled.go new file mode 100644 index 0000000..b45a14e --- /dev/null +++ b/validator/pkg/validators/api_enabled.go @@ -0,0 +1,171 @@ +package validators + +import ( + "context" + "errors" + "fmt" + "log/slog" + "time" + + "google.golang.org/api/googleapi" + "validator/pkg/gcp" + "validator/pkg/validator" +) + +const ( + // Timeout for overall API validation + apiValidationTimeout = 2 * time.Minute + // Timeout for individual API check requests + apiRequestTimeout = 30 * time.Second +) + +// extractErrorReason extracts a structured error reason from GCP API errors +// Prioritizes GCP-specific error reasons, falls back to HTTP status code +func extractErrorReason(err error, fallbackReason string) string { + if err == nil { + return fallbackReason + } + + var apiErr *googleapi.Error + if errors.As(err, &apiErr) { + // First, try to get GCP-specific reason (more detailed) + if len(apiErr.Errors) > 0 && apiErr.Errors[0].Reason != "" { + return apiErr.Errors[0].Reason + } + + // No specific reason provided, return generic HTTP code + return fmt.Sprintf("HTTP_%d", apiErr.Code) + } + + // Not a GCP API error, use fallback + return fallbackReason +} + +// APIEnabledValidator checks if required GCP APIs are enabled +type APIEnabledValidator struct{} + +func init() { + validator.Register(&APIEnabledValidator{}) +} + +func (v *APIEnabledValidator) Metadata() validator.ValidatorMetadata { + return validator.ValidatorMetadata{ + Name: "api-enabled", + Description: "Verify required GCP APIs are enabled in the target project", + RunAfter: []string{}, // No dependencies - WIF is implicitly validated when API calls succeed + Tags: []string{"mvp", "gcp-api"}, + } +} + +func (v *APIEnabledValidator) Enabled(ctx *validator.Context) bool { + return ctx.Config.IsValidatorEnabled("api-enabled") +} + +func (v *APIEnabledValidator) Validate(ctx context.Context, vctx *validator.Context) *validator.Result { + slog.Info("Checking if required GCP APIs are enabled") + + // Add timeout for overall validation + ctx, cancel := context.WithTimeout(ctx, apiValidationTimeout) + defer cancel() + + // Create Service Usage client (uses WIF implicitly) + factory := gcp.NewClientFactory(vctx.Config.ProjectID, slog.Default()) + svc, err := factory.CreateServiceUsageService(ctx) + if err != nil { + // Log full error for debugging + slog.Error("Failed to create Service Usage client", + "error", err.Error(), + "project_id", vctx.Config.ProjectID) + + // Extract structured reason + reason := extractErrorReason(err, "ServiceUsageClientError") + + return &validator.Result{ + Status: validator.StatusFailure, + Reason: reason, + Message: fmt.Sprintf("Failed to create Service Usage client (check WIF configuration): %v", err), + Details: map[string]interface{}{ + //"error": err.Error(), + "error_type": fmt.Sprintf("%T", err), + "project_id": vctx.Config.ProjectID, + "hint": "Verify WIF annotation on KSA and IAM bindings for GSA", + }, + } + } + + // Check each required API + requiredAPIs := vctx.Config.RequiredAPIs + enabledAPIs := []string{} + disabledAPIs := []string{} + + for _, apiName := range requiredAPIs { + // Add per-request timeout + reqCtx, reqCancel := context.WithTimeout(ctx, apiRequestTimeout) + + serviceName := fmt.Sprintf("projects/%s/services/%s", vctx.Config.ProjectID, apiName) + + slog.Debug("Checking API", "api", apiName) + service, err := svc.Services.Get(serviceName).Context(reqCtx).Do() + reqCancel() // Clean up context + + if err != nil { + // Log full error for debugging + slog.Error("Failed to check API", + "api", apiName, + "error", err.Error(), + "project_id", vctx.Config.ProjectID, + "service_name", serviceName) + + // Extract structured reason + reason := extractErrorReason(err, "APICheckFailed") + + return &validator.Result{ + Status: validator.StatusFailure, + Reason: reason, + Message: fmt.Sprintf("Failed to check API %s: %v", apiName, err), + Details: map[string]interface{}{ + "api": apiName, + //"error": err.Error(), + "error_type": fmt.Sprintf("%T", err), + "project_id": vctx.Config.ProjectID, + "service_name": serviceName, + }, + } + } + + if service.State == "ENABLED" { + enabledAPIs = append(enabledAPIs, apiName) + slog.Debug("API is enabled", "api", apiName) + } else { + disabledAPIs = append(disabledAPIs, apiName) + slog.Warn("API is NOT enabled", "api", apiName, "state", service.State) + } + } + + // Check if any APIs are disabled + if len(disabledAPIs) > 0 { + return &validator.Result{ + Status: validator.StatusFailure, + Reason: "RequiredAPIsDisabled", + Message: fmt.Sprintf("%d required API(s) are not enabled", len(disabledAPIs)), + Details: map[string]interface{}{ + "disabled_apis": disabledAPIs, + "enabled_apis": enabledAPIs, + "project_id": vctx.Config.ProjectID, + "hint": "Enable APIs with: gcloud services enable ", + }, + } + } + + slog.Info("All required APIs are enabled", "count", len(enabledAPIs)) + + return &validator.Result{ + Status: validator.StatusSuccess, + Reason: "AllAPIsEnabled", + Message: fmt.Sprintf("All %d required APIs are enabled", len(enabledAPIs)), + Details: map[string]interface{}{ + "enabled_apis": enabledAPIs, + "project_id": vctx.Config.ProjectID, + }, + } +} diff --git a/validator/pkg/validators/api_enabled_test.go b/validator/pkg/validators/api_enabled_test.go new file mode 100644 index 0000000..ac4e5db --- /dev/null +++ b/validator/pkg/validators/api_enabled_test.go @@ -0,0 +1,148 @@ +package validators_test + +import ( + "os" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "validator/pkg/config" + "validator/pkg/validator" + "validator/pkg/validators" +) + +var _ = Describe("APIEnabledValidator", func() { + var ( + v *validators.APIEnabledValidator + vctx *validator.Context + ) + + BeforeEach(func() { + v = &validators.APIEnabledValidator{} + + // Set up minimal config + Expect(os.Setenv("PROJECT_ID", "test-project")).To(Succeed()) + cfg, err := config.LoadFromEnv() + Expect(err).NotTo(HaveOccurred()) + + vctx = &validator.Context{ + Config: cfg, + Results: make(map[string]*validator.Result), + } + }) + + AfterEach(func() { + Expect(os.Unsetenv("PROJECT_ID")).To(Succeed()) + Expect(os.Unsetenv("REQUIRED_APIS")).To(Succeed()) + }) + + Describe("Metadata", func() { + It("should return correct metadata", func() { + meta := v.Metadata() + Expect(meta.Name).To(Equal("api-enabled")) + Expect(meta.Description).To(ContainSubstring("GCP APIs")) + Expect(meta.RunAfter).To(BeEmpty()) // No dependencies - WIF is implicitly validated + Expect(meta.Tags).To(ContainElement("mvp")) + Expect(meta.Tags).To(ContainElement("gcp-api")) + }) + + It("should have no dependencies (Level 0)", func() { + meta := v.Metadata() + Expect(meta.RunAfter).To(BeEmpty()) + }) + }) + + Describe("Enabled", func() { + Context("when validator is not explicitly disabled", func() { + It("should be enabled by default", func() { + enabled := v.Enabled(vctx) + Expect(enabled).To(BeTrue()) + }) + }) + + Context("when validator is explicitly disabled", func() { + BeforeEach(func() { + Expect(os.Setenv("DISABLED_VALIDATORS", "api-enabled")).To(Succeed()) + cfg, err := config.LoadFromEnv() + Expect(err).NotTo(HaveOccurred()) + vctx.Config = cfg + }) + + AfterEach(func() { + Expect(os.Unsetenv("DISABLED_VALIDATORS")).To(Succeed()) + }) + + It("should be disabled", func() { + enabled := v.Enabled(vctx) + Expect(enabled).To(BeFalse()) + }) + }) + + }) + + Describe("Configuration", func() { + It("should use default required APIs", func() { + Expect(vctx.Config.RequiredAPIs).To(ConsistOf( + "compute.googleapis.com", + "iam.googleapis.com", + "cloudresourcemanager.googleapis.com", + )) + }) + + Context("with custom required APIs", func() { + BeforeEach(func() { + Expect(os.Setenv("REQUIRED_APIS", "storage.googleapis.com,bigquery.googleapis.com")).To(Succeed()) + cfg, err := config.LoadFromEnv() + Expect(err).NotTo(HaveOccurred()) + vctx.Config = cfg + }) + + It("should use custom APIs list", func() { + Expect(vctx.Config.RequiredAPIs).To(ConsistOf( + "storage.googleapis.com", + "bigquery.googleapis.com", + )) + }) + }) + + Context("with APIs containing whitespace", func() { + BeforeEach(func() { + Expect(os.Setenv("REQUIRED_APIS", " storage.googleapis.com , bigquery.googleapis.com ")).To(Succeed()) + cfg, err := config.LoadFromEnv() + Expect(err).NotTo(HaveOccurred()) + vctx.Config = cfg + }) + + It("should trim whitespace from API names", func() { + Expect(vctx.Config.RequiredAPIs).To(ConsistOf( + "storage.googleapis.com", + "bigquery.googleapis.com", + )) + }) + }) + }) + + Describe("GCP Project Configuration", func() { + It("should have GCP project ID from config", func() { + Expect(vctx.Config.ProjectID).To(Equal("test-project")) + }) + + Context("with different project ID", func() { + BeforeEach(func() { + Expect(os.Setenv("PROJECT_ID", "production-project-456")).To(Succeed()) + cfg, err := config.LoadFromEnv() + Expect(err).NotTo(HaveOccurred()) + vctx.Config = cfg + }) + + It("should use the specified project ID", func() { + Expect(vctx.Config.ProjectID).To(Equal("production-project-456")) + }) + }) + }) + + // Note: Testing Validate() method requires either: + // 1. A real GCP project with Service Usage API enabled (integration test) + // 2. Mocked GCP client (complex setup) + // These tests would be added in integration test suite +}) diff --git a/validator/pkg/validators/quota_check.go b/validator/pkg/validators/quota_check.go new file mode 100644 index 0000000..e2888bf --- /dev/null +++ b/validator/pkg/validators/quota_check.go @@ -0,0 +1,87 @@ +package validators + +import ( + "context" + "log/slog" + + "validator/pkg/validator" +) + +// QuotaCheckValidator verifies sufficient GCP quota is available +// TODO: Implement actual quota checking logic +type QuotaCheckValidator struct{} + +func init() { + validator.Register(&QuotaCheckValidator{}) +} + +func (v *QuotaCheckValidator) Metadata() validator.ValidatorMetadata { + return validator.ValidatorMetadata{ + Name: "quota-check", + Description: "Verify sufficient GCP quota is available (stub - requires implementation)", + RunAfter: []string{"api-enabled"}, // Depends on api-enabled to ensure GCP access works + Tags: []string{"post-mvp", "quota", "stub"}, + } +} + +func (v *QuotaCheckValidator) Enabled(ctx *validator.Context) bool { + return ctx.Config.IsValidatorEnabled("quota-check") +} + +func (v *QuotaCheckValidator) Validate(ctx context.Context, vctx *validator.Context) *validator.Result { + slog.Info("Running quota check validator (stub implementation)") + + // TODO: Implement actual quota validation + // This should check: + // 1. Compute Engine quota (CPUs, disk, IPs, etc.) + // 2. Use the Compute API to get quota information + // 3. Compare against required resources for cluster creation + // + // Example implementation structure: + // + // factory := gcp.NewClientFactory(vctx.Config.ProjectID, slog.Default()) + // computeSvc, err := factory.CreateComputeService(ctx) + // if err != nil { + // return &validator.Result{ + // Status: validator.StatusFailure, + // Reason: "ComputeClientError", + // Message: fmt.Sprintf("Failed to create Compute client: %v", err), + // } + // } + // + // // Get project quota + // project, err := computeSvc.Projects.Get(vctx.Config.ProjectID).Context(ctx).Do() + // if err != nil { + // return &validator.Result{ + // Status: validator.StatusFailure, + // Reason: "QuotaCheckFailed", + // Message: fmt.Sprintf("Failed to get project quota: %v", err), + // } + // } + // + // // Check specific quotas + // for _, quota := range project.Quotas { + // if quota.Metric == "CPUS" && quota.Limit-quota.Usage < requiredCPUs { + // return &validator.Result{ + // Status: validator.StatusFailure, + // Reason: "InsufficientQuota", + // Message: fmt.Sprintf("Insufficient CPU quota: available=%d, required=%d", + // int(quota.Limit-quota.Usage), requiredCPUs), + // } + // } + // } + + slog.Warn("Quota check not yet implemented - returning success by default") + + return &validator.Result{ + Status: validator.StatusSuccess, + Reason: "QuotaCheckStub", + Message: "Quota check validation not yet implemented (stub returning success)", + Details: map[string]interface{}{ + "stub": true, + "implemented": false, + "project_id": vctx.Config.ProjectID, + "note": "This validator needs to be implemented to check actual GCP quotas", + }, + } +} diff --git a/validator/pkg/validators/quota_check_test.go b/validator/pkg/validators/quota_check_test.go new file mode 100644 index 0000000..3700003 --- /dev/null +++ b/validator/pkg/validators/quota_check_test.go @@ -0,0 +1,104 @@ +package validators_test + +import ( + "context" + "os" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "validator/pkg/config" + "validator/pkg/validator" + "validator/pkg/validators" +) + +var _ = Describe("QuotaCheckValidator", func() { + var ( + v *validators.QuotaCheckValidator + vctx *validator.Context + ) + + BeforeEach(func() { + v = &validators.QuotaCheckValidator{} + + // Set up minimal config + Expect(os.Setenv("PROJECT_ID", "test-project")).To(Succeed()) + cfg, err := config.LoadFromEnv() + Expect(err).NotTo(HaveOccurred()) + + vctx = &validator.Context{ + Config: cfg, + Results: make(map[string]*validator.Result), + } + }) + + AfterEach(func() { + Expect(os.Unsetenv("PROJECT_ID")).To(Succeed()) + }) + + Describe("Metadata", func() { + It("should return correct metadata", func() { + meta := v.Metadata() + Expect(meta.Name).To(Equal("quota-check")) + Expect(meta.Description).To(ContainSubstring("quota")) + Expect(meta.Description).To(ContainSubstring("stub")) + Expect(meta.RunAfter).To(ConsistOf("api-enabled")) // Depends on api-enabled + Expect(meta.Tags).To(ContainElement("post-mvp")) + Expect(meta.Tags).To(ContainElement("quota")) + Expect(meta.Tags).To(ContainElement("stub")) + }) + + It("should depend on api-enabled (Level 1)", func() { + meta := v.Metadata() + Expect(meta.RunAfter).To(ConsistOf("api-enabled")) + }) + }) + + Describe("Enabled", func() { + Context("when validator is not explicitly disabled", func() { + It("should be enabled by default", func() { + enabled := v.Enabled(vctx) + Expect(enabled).To(BeTrue()) + }) + }) + + Context("when validator is explicitly disabled", func() { + BeforeEach(func() { + Expect(os.Setenv("DISABLED_VALIDATORS", "quota-check")).To(Succeed()) + cfg, err := config.LoadFromEnv() + Expect(err).NotTo(HaveOccurred()) + vctx.Config = cfg + }) + + AfterEach(func() { + Expect(os.Unsetenv("DISABLED_VALIDATORS")).To(Succeed()) + }) + + It("should be disabled", func() { + enabled := v.Enabled(vctx) + Expect(enabled).To(BeFalse()) + }) + }) + + }) + + Describe("Validate", func() { + It("should return success with stub message", func() { + ctx := context.Background() + result := v.Validate(ctx, vctx) + Expect(result).NotTo(BeNil()) + Expect(result.Status).To(Equal(validator.StatusSuccess)) + Expect(result.Reason).To(Equal("QuotaCheckStub")) + Expect(result.Message).To(ContainSubstring("not yet implemented")) + }) + + It("should include stub metadata in details", func() { + ctx := context.Background() + result := v.Validate(ctx, vctx) + Expect(result.Details).To(HaveKey("stub")) + Expect(result.Details["stub"]).To(BeTrue()) + Expect(result.Details).To(HaveKey("implemented")) + Expect(result.Details["implemented"]).To(BeFalse()) + }) + }) +}) diff --git a/validator/pkg/validators/validators_suite_test.go b/validator/pkg/validators/validators_suite_test.go new file mode 100644 index 0000000..3a6cb6f --- /dev/null +++ b/validator/pkg/validators/validators_suite_test.go @@ -0,0 +1,13 @@ +package validators_test + +import ( + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestValidators(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Validators Suite") +} From a5adda65e1ded328818570b9958098fc342c0f77 Mon Sep 17 00:00:00 2001 From: dawang Date: Thu, 15 Jan 2026 19:52:02 +0800 Subject: [PATCH 02/14] Add Dockerfile and Makefile --- validator/Dockerfile | 41 ++++++++++ validator/Makefile | 188 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 229 insertions(+) create mode 100644 validator/Dockerfile create mode 100644 validator/Makefile diff --git a/validator/Dockerfile b/validator/Dockerfile new file mode 100644 index 0000000..11dce5c --- /dev/null +++ b/validator/Dockerfile @@ -0,0 +1,41 @@ +ARG BASE_IMAGE=gcr.io/distroless/static-debian12:nonroot + +# Build stage +FROM golang:1.25-alpine AS builder + +# Build arguments passed from build machine +ARG VERSION=0.0.1 +ARG GIT_COMMIT=unknown + +# Install build dependencies +RUN apk add --no-cache make + +WORKDIR /build + +# Copy source code +COPY . . + +# Tidy and verify Go module dependencies +RUN go mod tidy && go mod verify + +# Build binary using make to include version, commit, and build date +RUN make binary VERSION=${VERSION} GIT_COMMIT=${GIT_COMMIT} + +# Runtime stage +FROM ${BASE_IMAGE} + +# Build arguments for labels (must be redeclared after FROM) +ARG VERSION=0.0.1 + +WORKDIR /app + +# Copy binary from builder (make binary outputs to bin/) +COPY --from=builder /build/bin/validator /app/validator + +ENTRYPOINT ["/app/validator"] + +LABEL name="gcp-validator" \ + vendor="Red Hat" \ + version="${VERSION}" \ + summary="GCP Validator - Pre-provisioning validation for GCP clusters" \ + description="Validates GCP prerequisites before cluster provisioning, including API enablement and quota checks" diff --git a/validator/Makefile b/validator/Makefile new file mode 100644 index 0000000..b2a61ae --- /dev/null +++ b/validator/Makefile @@ -0,0 +1,188 @@ +# Makefile for GCP Validator + +# Project metadata +PROJECT_NAME := gcp-validator +VERSION ?= 0.0.1 +IMAGE_REGISTRY ?= quay.io/rh-ee-dawang +IMAGE_TAG ?= latest + +# Build metadata +GIT_COMMIT := $(shell git rev-parse --short HEAD 2>/dev/null || echo "unknown") +GIT_TAG := $(shell git describe --tags --exact-match 2>/dev/null || echo "") + +# Dev image configuration - set QUAY_USER to push to personal registry +# Usage: QUAY_USER=myuser make image-dev +QUAY_USER ?= +DEV_TAG ?= dev-$(GIT_COMMIT) +BUILD_DATE := $(shell date -u +'%Y-%m-%dT%H:%M:%SZ') + +# LDFLAGS for build +# Note: Variables are in package main, so use main.varName (not full import path) +LDFLAGS := -w -s +LDFLAGS += -X main.version=$(VERSION) +LDFLAGS += -X main.commit=$(GIT_COMMIT) +LDFLAGS += -X main.buildDate=$(BUILD_DATE) +ifneq ($(GIT_TAG),) +LDFLAGS += -X main.tag=$(GIT_TAG) +endif + +# Go parameters +GOCMD := go +GOBUILD := $(GOCMD) build +GOTEST := $(GOCMD) test +GOMOD := $(GOCMD) mod +GOFMT := gofmt +GOIMPORTS := goimports + +# Test parameters +TEST_TIMEOUT := 10m +RACE_FLAG := -race +COVERAGE_OUT := coverage.out +COVERAGE_HTML := coverage.html + +# Container runtime detection +DOCKER_AVAILABLE := $(shell if docker info >/dev/null 2>&1; then echo "true"; else echo "false"; fi) +PODMAN_AVAILABLE := $(shell if podman info >/dev/null 2>&1; then echo "true"; else echo "false"; fi) + +ifeq ($(DOCKER_AVAILABLE),true) + CONTAINER_RUNTIME := docker + CONTAINER_CMD := docker +else ifeq ($(PODMAN_AVAILABLE),true) + CONTAINER_RUNTIME := podman + CONTAINER_CMD := podman +else + CONTAINER_RUNTIME := none + CONTAINER_CMD := sh -c 'echo "No container runtime found. Please install Docker or Podman." && exit 1' +endif + +# Install directory (defaults to $GOPATH/bin or $HOME/go/bin) +GOPATH ?= $(shell $(GOCMD) env GOPATH) +BINDIR ?= $(GOPATH)/bin + +# Directories +# Find all Go packages, excluding vendor and test directories +PKG_DIRS := $(shell $(GOCMD) list ./... 2>/dev/null | grep -v /vendor/ | grep -v /test/) + +.PHONY: help +help: ## Display this help message + @echo "Available targets:" + @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-20s\033[0m %s\n", $$1, $$2}' + +.PHONY: test +test: ## Run unit tests with race detection + @echo "Running unit tests..." + $(GOTEST) -v $(RACE_FLAG) -timeout $(TEST_TIMEOUT) $(PKG_DIRS) + +.PHONY: test-coverage +test-coverage: ## Run unit tests with coverage report + @echo "Running unit tests with coverage..." + $(GOTEST) -v $(RACE_FLAG) -timeout $(TEST_TIMEOUT) -coverprofile=$(COVERAGE_OUT) -covermode=atomic $(PKG_DIRS) + @echo "Coverage report generated: $(COVERAGE_OUT)" + @echo "To view HTML coverage report, run: make test-coverage-html" + +.PHONY: test-coverage-html +test-coverage-html: test-coverage ## Generate HTML coverage report + @echo "Generating HTML coverage report..." + $(GOCMD) tool cover -html=$(COVERAGE_OUT) -o $(COVERAGE_HTML) + @echo "HTML coverage report generated: $(COVERAGE_HTML)" + +.PHONY: lint +lint: ## Run golangci-lint + @echo "Running golangci-lint..." + @if command -v golangci-lint > /dev/null; then \ + golangci-lint cache clean && golangci-lint run; \ + else \ + echo "Error: golangci-lint not found. Please install it:"; \ + echo " go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest"; \ + exit 1; \ + fi + +.PHONY: fmt +fmt: ## Format code with gofmt and goimports + @echo "Formatting code..." + @if command -v $(GOIMPORTS) > /dev/null; then \ + $(GOIMPORTS) -w .; \ + else \ + $(GOFMT) -w .; \ + fi + +.PHONY: mod-tidy +mod-tidy: ## Tidy Go module dependencies + @echo "Tidying Go modules..." + $(GOMOD) tidy + $(GOMOD) verify + +.PHONY: binary +binary: ## Build binary + @echo "Building $(PROJECT_NAME)..." + @echo "Version: $(VERSION), Commit: $(GIT_COMMIT), BuildDate: $(BUILD_DATE)" + @mkdir -p bin + CGO_ENABLED=0 $(GOBUILD) -ldflags="$(LDFLAGS)" -o bin/validator ./cmd/validator + +.PHONY: build +build: binary ## Alias for 'binary' + +.PHONY: install +install: binary ## Install binary to BINDIR (default: $GOPATH/bin) + @echo "Installing $(PROJECT_NAME) to $(BINDIR)..." + @mkdir -p $(BINDIR) + cp bin/validator $(BINDIR)/validator + @echo "✅ Installed: $(BINDIR)/validator" + +.PHONY: clean +clean: ## Clean build artifacts and test coverage files + @echo "Cleaning..." + rm -rf bin/ + rm -f $(COVERAGE_OUT) $(COVERAGE_HTML) + +.PHONY: image +image: ## Build container image with Docker or Podman +ifeq ($(CONTAINER_RUNTIME),none) + @echo "❌ ERROR: No container runtime found" + @echo "Please install Docker or Podman" + @exit 1 +else + @echo "Building container image with $(CONTAINER_RUNTIME)..." + $(CONTAINER_CMD) build --platform linux/amd64 --no-cache --build-arg VERSION=$(VERSION) --build-arg GIT_COMMIT=$(GIT_COMMIT) -t $(IMAGE_REGISTRY)/$(PROJECT_NAME):$(IMAGE_TAG) . + @echo "✅ Image built: $(IMAGE_REGISTRY)/$(PROJECT_NAME):$(IMAGE_TAG)" +endif + +.PHONY: image-push +image-push: image ## Build and push container image to registry +ifeq ($(CONTAINER_RUNTIME),none) + @echo "❌ ERROR: No container runtime found" + @echo "Please install Docker or Podman" + @exit 1 +else + @echo "Pushing image $(IMAGE_REGISTRY)/$(PROJECT_NAME):$(IMAGE_TAG)..." + $(CONTAINER_CMD) push $(IMAGE_REGISTRY)/$(PROJECT_NAME):$(IMAGE_TAG) + @echo "✅ Image pushed: $(IMAGE_REGISTRY)/$(PROJECT_NAME):$(IMAGE_TAG)" +endif + +.PHONY: image-dev +image-dev: ## Build and push to personal Quay registry (requires QUAY_USER) +ifndef QUAY_USER + @echo "❌ ERROR: QUAY_USER is not set" + @echo "" + @echo "Usage: QUAY_USER=myuser make image-dev" + @echo "" + @echo "This will build and push to: quay.io/$$QUAY_USER/$(PROJECT_NAME):$(DEV_TAG)" + @exit 1 +endif +ifeq ($(CONTAINER_RUNTIME),none) + @echo "❌ ERROR: No container runtime found" + @echo "Please install Docker or Podman" + @exit 1 +else + @echo "Building dev image quay.io/$(QUAY_USER)/$(PROJECT_NAME):$(DEV_TAG)..." + $(CONTAINER_CMD) build --platform linux/amd64 --build-arg BASE_IMAGE=alpine:3.21 --build-arg VERSION=$(VERSION) --build-arg GIT_COMMIT=$(GIT_COMMIT) -t quay.io/$(QUAY_USER)/$(PROJECT_NAME):$(DEV_TAG) . + @echo "Pushing dev image..." + $(CONTAINER_CMD) push quay.io/$(QUAY_USER)/$(PROJECT_NAME):$(DEV_TAG) + @echo "" + @echo "✅ Dev image pushed: quay.io/$(QUAY_USER)/$(PROJECT_NAME):$(DEV_TAG)" +endif + +.PHONY: verify +verify: lint test ## Run all verification checks (lint + test) + +.DEFAULT_GOAL := help From 7bb6b2dde53ee16c622ef37cfa3fbe7c44658617 Mon Sep 17 00:00:00 2001 From: dawang Date: Fri, 16 Jan 2026 09:45:56 +0800 Subject: [PATCH 03/14] Add README.md --- validator/README.md | 176 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 176 insertions(+) create mode 100644 validator/README.md diff --git a/validator/README.md b/validator/README.md new file mode 100644 index 0000000..7b2c4e0 --- /dev/null +++ b/validator/README.md @@ -0,0 +1,176 @@ +# GCP Validator + +Extensible Go-based validation framework for GCP prerequisites before cluster provisioning. + +## Features + +- **Parallel Execution**: Validators run concurrently when dependencies allow +- **Dependency Management**: DAG-based scheduling with automatic cycle detection +- **Auto-discovery**: Validators self-register via `init()` +- **WIF Authentication**: Workload Identity Federation for secure GCP access + +## Current Validators + +1. **api-enabled**: Verifies required GCP APIs are enabled +2. **quota-check**: Placeholder stub for future quota validation + +## Quick Start + +### Build + +```bash +make build # Build binary +make test # Run tests +make image # Build container image +``` + +### Run Locally + +```bash +export PROJECT_ID=my-gcp-project +export RESULTS_PATH=/tmp/results.json + +./bin/validator +cat /tmp/results.json +``` + +### Run in Docker + +```bash +docker run --rm \ + -e PROJECT_ID=my-project \ + -v /tmp/results:/results \ + gcp-validator +``` + +## Configuration + +### Required +- `PROJECT_ID` - GCP project ID to validate + +### Optional +- `RESULTS_PATH` - Output file path (default: `/results/adapter-result.json`) +- `DISABLED_VALIDATORS` - Comma-separated list to disable (e.g., `quota-check`) +- `STOP_ON_FIRST_FAILURE` - Stop on first failure (default: `false`) +- `REQUIRED_APIS` - APIs to check (default: `compute.googleapis.com,iam.googleapis.com,cloudresourcemanager.googleapis.com`) +- `LOG_LEVEL` - Log level: `debug`, `info`, `warn`, `error` (default: `info`) + +## Output Format + +### Success +```json +{ + "status": "success", + "reason": "ValidationPassed", + "message": "All GCP validation checks passed successfully", + "details": { + "checks_run": 1, + "checks_passed": 1, + "timestamp": "2026-01-15T10:30:00Z", + "validators": [ + { + "validator_name": "api-enabled", + "status": "success", + "reason": "AllAPIsEnabled", + "message": "All 3 required APIs are enabled", + "duration_ns": 234000000, + "timestamp": "2026-01-15T10:30:00Z" + } + ] + } +} +``` + +### Failure +```json +{ + "status": "failure", + "reason": "ValidationFailed", + "message": "1 validation check(s) failed: api-enabled (forbidden). Passed: 0/1", + "details": { + "checks_run": 1, + "checks_passed": 0, + "failed_checks": ["api-enabled"], + "timestamp": "2026-01-15T10:30:00Z", + "validators": [ + { + "validator_name": "api-enabled", + "status": "failure", + "reason": "forbidden", + "message": "Failed to check API compute.googleapis.com: ...", + "duration_ns": 123000000, + "timestamp": "2026-01-15T10:30:00Z" + } + ] + } +} +``` + +## Adding a New Validator + +Create a file in `pkg/validators/` implementing the `Validator` interface: + +```go +package validators + +import ( + "context" + "validator/pkg/validator" +) + +type MyValidator struct{} + +func init() { + validator.Register(&MyValidator{}) +} + +func (v *MyValidator) Metadata() validator.ValidatorMetadata { + return validator.ValidatorMetadata{ + Name: "my-validator", + Description: "Validates something important", + RunAfter: []string{"api-enabled"}, // Dependencies + Tags: []string{"custom"}, + } +} + +func (v *MyValidator) Enabled(ctx *validator.Context) bool { + return ctx.Config.IsValidatorEnabled("my-validator") +} + +func (v *MyValidator) Validate(ctx context.Context, vctx *validator.Context) *validator.Result { + // Validation logic here + return &validator.Result{ + Status: validator.StatusSuccess, + Reason: "CheckPassed", + Message: "Validation successful", + } +} +``` + +The validator is automatically discovered, ordered by dependencies, and executed in parallel. +- Register validator via `init()` +- Define dependency via `RunAfter` in `Metadata` + +## Testing + +```bash +make test # Run all tests +make lint # Run linter +``` + +Tests use Ginkgo/Gomega BDD framework. + +## Architecture + +### Execution Flow +1. Load configuration from environment variables +2. Discover and register all validators via `init()` +3. Build dependency graph (DAG) and detect cycles +4. Execute validators in parallel by dependency level +5. Aggregate results and write to output file + +### Security +- Uses GCP Application Default Credentials (ADC) +- Supports Workload Identity Federation in Kubernetes +- Minimal read-only scopes per service +- Each validator gets only the permissions it needs From 929ad77806a070b65b3b74f5b22b4ed078e1a5f0 Mon Sep 17 00:00:00 2001 From: dawang Date: Fri, 16 Jan 2026 15:06:59 +0800 Subject: [PATCH 04/14] Add GCP validation adapter configurations and support real validator in Helm chart --- charts/configs/validation-adapter.yaml | 317 ++++++++++++++++++ .../configs/validation-job-adapter-task.yaml | 108 ++++++ charts/templates/_helpers.tpl | 2 +- charts/templates/configmap-app.yaml | 4 + charts/templates/deployment.yaml | 14 + charts/values.yaml | 28 +- 6 files changed, 466 insertions(+), 7 deletions(-) create mode 100644 charts/configs/validation-adapter.yaml create mode 100644 charts/configs/validation-job-adapter-task.yaml diff --git a/charts/configs/validation-adapter.yaml b/charts/configs/validation-adapter.yaml new file mode 100644 index 0000000..4a6c5d3 --- /dev/null +++ b/charts/configs/validation-adapter.yaml @@ -0,0 +1,317 @@ +# HyperFleet GCP Validation Adapter Configuration +# +# This adapter creates a validation job for GCP clusters to verify +# cluster readiness and configuration. +apiVersion: hyperfleet.redhat.com/v1alpha1 +kind: AdapterConfig +metadata: + name: validation-adapter + namespace: hyperfleet-system + labels: + hyperfleet.io/adapter-type: validation + hyperfleet.io/component: adapter + hyperfleet.io/provider: gcp +spec: + adapter: + version: "0.1.0" + # ============================================================================ + # HyperFleet API Configuration + # ============================================================================ + hyperfleetApi: + timeout: 2s + retryAttempts: 3 + # ============================================================================ + # Kubernetes Configuration + # ============================================================================ + kubernetes: + apiVersion: "batch/v1" + # ============================================================================ + # Parameters + # ============================================================================ + params: + - name: "hyperfleetApiBaseUrl" + source: "env.HYPERFLEET_API_BASE_URL" + type: "string" + description: "Base URL for the HyperFleet API" + required: true + - name: "hyperfleetApiVersion" + source: "env.HYPERFLEET_API_VERSION" + type: "string" + default: "v1" + description: "API version to use" + - name: "clusterId" + source: "event.id" + type: "string" + description: "Unique identifier for the target cluster" + required: true + - name: "statusReporterImage" + source: "env.STATUS_REPORTER_IMAGE" + type: "string" + default: "quay.io/rh-ee-dawang/status-reporter:dev-04e8d0a" + description: "Container image for the status reporter" + # GCP Validator configuration + - name: "gcpValidatorImage" + source: "env.GCP_VALIDATOR_IMAGE" + type: "string" + default: "quay.io/rh-ee-dawang/gcp-validator:latest" + description: "GCP validator container image" + - name: "disabledValidators" + source: "env.DISABLED_VALIDATORS" + type: "string" + default: "" + description: "Comma-separated list of validators to disable" + - name: "requiredApis" + source: "env.REQUIRED_APIS" + type: "string" + default: "compute.googleapis.com,iam.googleapis.com,cloudresourcemanager.googleapis.com" + description: "Comma-separated list of required GCP APIs to validate" + - name: "resultPath" + source: "env.RESULTS_PATH" + type: "string" + default: "/results/adapter-result.json" + description: "Adapter shared result path with status reporter" + - name: "maxWaitTimeSeconds" + source: "env.MAX_WAIT_TIME_SECONDS" + type: "string" + default: "300" + description: "Maximum time to wait for validation completion" + - name: "gcpValidatorServiceAccount" + source: "env.GCP_VALIDATOR_SERVICE_ACCOUNT" + type: "string" + default: "gcp-validator-job-sa" + description: "Kubernetes ServiceAccount name for the validator job" + - name: "managedByResourceName" + source: "env.MANAGED_By_RESOURCE_NAME" + type: "string" + default: "validation-adapter" + description: "The value for hyperfleet.io/managed-by" + - name: "createdByResourceName" + source: "env.CREATED_BY_RESOURCE_NAME" + type: "string" + default: "hyperfleet-adapter" + description: "The value for hyperfleet.io/created-by:" + # ============================================================================ + # Preconditions + # ============================================================================ + # Preconditions run before resource operations to validate state + preconditions: + - name: "clusterStatus" + apiCall: + method: "GET" + url: "{{ .hyperfleetApiBaseUrl }}/api/hyperfleet/{{ .hyperfleetApiVersion }}/clusters/{{ .clusterId }}" + timeout: 10s + retryAttempts: 3 + retryBackoff: "exponential" + capture: + - name: "clusterName" + field: "name" + - name: "clusterPhase" + field: "status.phase" + - name: "generationId" + field: "generation" + # Customer GCP project ID + - name: "projectId" + field: "spec.platform.gcp.projectID" + conditions: + - field: "clusterPhase" + operator: "in" + values: ["NotReady", "Ready"] + # Ensure Customer GCP project ID is configured + - field: "projectId" + operator: "exists" + + - name: "clusterAdapterStatus" + apiCall: + method: "GET" + url: "{{ .hyperfleetApiBaseUrl }}/api/hyperfleet/{{ .hyperfleetApiVersion }}/clusters/{{ .clusterId }}/statuses" + timeout: 10s + retryAttempts: 3 + retryBackoff: "exponential" + capture: + - name: "clusterNamespaceStatus" + field: "{.items[?(@.adapter=='landing-zone-adapter')].data.namespace.status}" + conditions: + - field: "clusterNamespaceStatus" + operator: "equals" + values: "Active" + # ============================================================================ + # Resources + # ============================================================================ + resources: + # ========================================================================== + # Resource: ServiceAccount for GCP Validation Job + # ========================================================================== + - name: "gcpValidationServiceAccount" + manifest: + # ServiceAccount for the validator job + apiVersion: v1 + kind: ServiceAccount + metadata: + name: "{{ .gcpValidatorServiceAccount }}" + namespace: "{{ .clusterId | lower }}" + labels: + hyperfleet.io/cluster-id: "{{ .clusterId }}" + hyperfleet.io/managed-by: "{{ .managedByResourceName }}" + hyperfleet.io/resource-type: "service-account" + annotations: + hyperfleet.io/created-by: "{{ .createdByResourceName }}" + hyperfleet.io/generation: "{{ .generationId }}" + discovery: + bySelectors: + labelSelector: + hyperfleet.io/resource-type: "service-account" + hyperfleet.io/cluster-id: "{{ .clusterId }}" + hyperfleet.io/managed-by: "{{ .managedByResourceName }}" + # ========================================================================== + # Resource: Role with necessary permissions for status reporter + # ========================================================================== + - name: "gcpValidationRole" + manifest: + # Role with necessary permissions for status reporter + apiVersion: rbac.authorization.k8s.io/v1 + kind: Role + metadata: + name: status-reporter + namespace: "{{ .clusterId | lower }}" + labels: + hyperfleet.io/cluster-id: "{{ .clusterId }}" + hyperfleet.io/managed-by: "{{ .managedByResourceName }}" + hyperfleet.io/resource-type: "role" + annotations: + hyperfleet.io/created-by: "{{ .createdByResourceName }}" + hyperfleet.io/generation: "{{ .generationId }}" + rules: + # Permission to get and update job status + - apiGroups: [ "batch" ] + resources: [ "jobs" ] + verbs: [ "get" ] + - apiGroups: [ "batch" ] + resources: [ "jobs/status" ] + verbs: [ "get", "update", "patch" ] + # Permission to get pod status + - apiGroups: [ "" ] + resources: [ "pods" ] + verbs: [ "get", "list" ] + discovery: + bySelectors: + labelSelector: + hyperfleet.io/resource-type: "role" + hyperfleet.io/cluster-id: "{{ .clusterId }}" + hyperfleet.io/managed-by: "{{ .managedByResourceName }}" + # ========================================================================== + # Rolebinding to grant permissions to the service account + # ========================================================================== + - name: "gcpValidationRoleBinding" + manifest: + # RoleBinding to grant permissions to the service account + apiVersion: rbac.authorization.k8s.io/v1 + kind: RoleBinding + metadata: + name: status-reporter + namespace: "{{ .clusterId | lower }}" + labels: + hyperfleet.io/cluster-id: "{{ .clusterId }}" + hyperfleet.io/managed-by: "{{ .managedByResourceName }}" + hyperfleet.io/resource-type: "role-binding" + annotations: + hyperfleet.io/created-by: "{{ .createdByResourceName }}" + hyperfleet.io/generation: "{{ .generationId }}" + roleRef: + apiGroup: rbac.authorization.k8s.io + kind: Role + name: status-reporter + subjects: + - kind: ServiceAccount + name: "{{ .gcpValidatorServiceAccount }}" + namespace: "{{ .clusterId | lower }}" + discovery: + bySelectors: + labelSelector: + hyperfleet.io/resource-type: "role-binding" + hyperfleet.io/cluster-id: "{{ .clusterId }}" + hyperfleet.io/managed-by: "{{ .managedByResourceName }}" + # ========================================================================== + # Resource: GCP Validation Job + # ========================================================================== + - name: "gcpValidationJob" + manifest: + ref: "./validation-job-adapter-task.yaml" + discovery: + bySelectors: + labelSelector: + hyperfleet.io/resource-type: "validation-job" + hyperfleet.io/cluster-id: "{{ .clusterId }}" + hyperfleet.io/managed-by: "{{ .managedByResourceName }}" + # ============================================================================ + # Post-Processing + # ============================================================================ + post: + payloads: + # Build status payload inline + - name: "clusterStatusPayload" + build: + adapter: "{{ .metadata.name }}" + conditions: + # Applied: Job successfully created + - type: "Applied" + status: + expression: | + has(resources.gcpValidationJob) ? "True" : "False" + reason: + expression: | + has(resources.gcpValidationJob) + ? "JobApplied" + : "JobPending" + message: + expression: | + has(resources.gcpValidationJob) + ? "Validation job applied successfully" + : "Validation job is pending to applied" + # Available: Check job status conditions + - type: "Available" + status: + expression: | + resources.?gcpValidationJob.?status.?conditions.orValue([]).exists(c, c.type == "Available") + ? resources.gcpValidationJob.status.conditions.filter(c, c.type == "Available")[0].status : "False" + reason: + expression: | + resources.?gcpValidationJob.?status.?conditions.orValue([]).exists(c, c.type == "Available") + ? resources.gcpValidationJob.status.conditions.filter(c, c.type == "Available")[0].reason + : resources.?gcpValidationJob.?status.?conditions.orValue([]).exists(c, c.type == "Failed") ? "ValidationFailed" + : resources.?gcpValidationJob.?status.hasValue() ? "ValidationInProgress" : "ValidationPending" + message: + expression: | + resources.?gcpValidationJob.?status.?conditions.orValue([]).exists(c, c.type == "Available") + ? resources.gcpValidationJob.status.conditions.filter(c, c.type == "Available")[0].message + : resources.?gcpValidationJob.?status.?conditions.orValue([]).exists(c, c.type == "Failed") ? "Validation failed" + : resources.?gcpValidationJob.?status.hasValue() ? "Validation in progress" : "Validation is pending" + # Health: Adapter execution status (runtime) + - type: "Health" + status: + expression: | + adapter.?executionStatus.orValue("") == "success" ? "True" : (adapter.?executionStatus.orValue("") == "failed" ? "False" : "Unknown") + reason: + expression: | + adapter.?errorReason.orValue("") != "" ? adapter.?errorReason.orValue("") : "Healthy" + message: + expression: | + adapter.?errorMessage.orValue("") != "" ? adapter.?errorMessage.orValue("") : "All adapter operations completed successfully" + # Event generation ID metadata field needs to use expression to avoid interpolation issues + observed_generation: + expression: "generationId" + observed_time: "{{ now | date \"2006-01-02T15:04:05Z07:00\" }}" + # ============================================================================ + # Post Actions + # ============================================================================ + postActions: + - name: "reportClusterStatus" + apiCall: + method: "POST" + url: "{{ .hyperfleetApiBaseUrl }}/api/hyperfleet/{{ .hyperfleetApiVersion }}/clusters/{{ .clusterId }}/statuses" + body: "{{ .clusterStatusPayload }}" + timeout: 30s + retryAttempts: 3 + retryBackoff: "exponential" + headers: + - name: "Content-Type" + value: "application/json" diff --git a/charts/configs/validation-job-adapter-task.yaml b/charts/configs/validation-job-adapter-task.yaml new file mode 100644 index 0000000..320a8da --- /dev/null +++ b/charts/configs/validation-job-adapter-task.yaml @@ -0,0 +1,108 @@ +apiVersion: batch/v1 +kind: Job +metadata: + name: "gcp-validator-{{ .clusterId | lower }}-{{ .generationId }}" + namespace: "{{ .clusterId | lower }}" + labels: + hyperfleet.io/cluster-id: "{{ .clusterId }}" + hyperfleet.io/managed-by: "{{ .managedByResourceName }}" + hyperfleet.io/resource-type: "validation-job" + app: gcp-validator + annotations: + hyperfleet.io/created-by: "{{ .createdByResourceName }}" + hyperfleet.io/generation: "{{ .generationId }}" +spec: + backoffLimit: 0 + # [TODO] This will be passed via parameter once adapter configuration support int parameter. + activeDeadlineSeconds: 310 # maxWaitTimeSeconds + 10 second buffers, maximum time to wait for k8s job completion" + template: + metadata: + labels: + app: gcp-validator + hyperfleet.io/cluster-id: "{{ .clusterId }}" + spec: + # Created before the job is created, as specified in the adapter configuration. + serviceAccountName: "{{ .gcpValidatorServiceAccount }}" + restartPolicy: Never + volumes: + - name: results + emptyDir: { } + containers: + # GCP Validator Container (replaces dummy-validator) + - name: gcp-validator + image: "{{ .gcpValidatorImage }}" + imagePullPolicy: Always + env: + # Required + - name: PROJECT_ID + value: "{{ .projectId }}" + - name: RESULTS_PATH + value: "{{ .resultPath }}" + + # Validator control + - name: DISABLED_VALIDATORS + value: "{{ .disabledValidators }}" + - name: STOP_ON_FIRST_FAILURE + value: "false" + + # API Validator config + - name: REQUIRED_APIS + value: "{{ .requiredApis }}" + + # Logging + - name: LOG_LEVEL + value: "info" + + volumeMounts: + - name: results + mountPath: /results + + resources: + requests: + memory: "128Mi" + cpu: "200m" + limits: + memory: "256Mi" + cpu: "500m" + + # Status reporter sidecar + - name: status-reporter + image: "{{ .statusReporterImage }}" + imagePullPolicy: Always + env: + # Required environment variables + - name: JOB_NAME + value: "gcp-validator-{{ .clusterId | lower }}-{{ .generationId }}" + - name: JOB_NAMESPACE + valueFrom: + fieldRef: + fieldPath: metadata.namespace + - name: POD_NAME + valueFrom: + fieldRef: + fieldPath: metadata.name + + # Optional configuration + - name: RESULTS_PATH + value: "{{ .resultPath }}" + - name: POLL_INTERVAL_SECONDS + value: "2" + - name: MAX_WAIT_TIME_SECONDS + value: "{{ .maxWaitTimeSeconds }}" + - name: CONDITION_TYPE + value: "Available" + - name: LOG_LEVEL + value: "info" + - name: ADAPTER_CONTAINER_NAME + value: "gcp-validator" + + volumeMounts: + - name: results + mountPath: /results + resources: + requests: + memory: "64Mi" + cpu: "100m" + limits: + memory: "128Mi" + cpu: "200m" diff --git a/charts/templates/_helpers.tpl b/charts/templates/_helpers.tpl index 56bf5f8..350852f 100644 --- a/charts/templates/_helpers.tpl +++ b/charts/templates/_helpers.tpl @@ -82,7 +82,7 @@ Get the adapter config file name based on deployment mode {{- if eq .Values.deploymentMode "dummy" }} {{- "validation-dummy-adapter.yaml" }} {{- else if eq .Values.deploymentMode "real" }} -{{- "validation-gcp-adapter.yaml" }} +{{- "validation-adapter.yaml" }} {{- else }} {{- fail "deploymentMode must be either 'dummy' or 'real'" }} {{- end }} diff --git a/charts/templates/configmap-app.yaml b/charts/templates/configmap-app.yaml index 173e280..efb1446 100644 --- a/charts/templates/configmap-app.yaml +++ b/charts/templates/configmap-app.yaml @@ -15,4 +15,8 @@ data: # Adapter task template referenced by the adapter config validation-dummy-job-adapter-task.yaml: | {{ .Files.Get "configs/validation-dummy-job-adapter-task.yaml" | nindent 4 }} + {{- else if eq .Values.deploymentMode "real" }} + # Adapter task template referenced by the adapter config + validation-job-adapter-task.yaml: | +{{ .Files.Get "configs/validation-job-adapter-task.yaml" | nindent 4 }} {{- end }} diff --git a/charts/templates/deployment.yaml b/charts/templates/deployment.yaml index 17d256c..d45b89a 100644 --- a/charts/templates/deployment.yaml +++ b/charts/templates/deployment.yaml @@ -90,6 +90,20 @@ spec: value: {{ .Values.validation.dummy.resultsPath | quote }} - name: MAX_WAIT_TIME_SECONDS value: {{ .Values.validation.dummy.maxWaitTimeSeconds | quote }} + {{- else if eq .Values.deploymentMode "real" }} + # Real GCP validation specific environment variables + - name: STATUS_REPORTER_IMAGE + value: {{ .Values.validation.statusReporterImage | quote }} + - name: GCP_VALIDATOR_IMAGE + value: {{ .Values.validation.real.gcpValidatorImage | quote }} + - name: DISABLED_VALIDATORS + value: {{ .Values.validation.real.disabledValidators | quote }} + - name: REQUIRED_APIS + value: {{ .Values.validation.real.requiredApis | quote }} + - name: RESULTS_PATH + value: {{ .Values.validation.real.resultsPath | quote }} + - name: MAX_WAIT_TIME_SECONDS + value: {{ .Values.validation.real.maxWaitTimeSeconds | quote }} {{- end }} resources: limits: diff --git a/charts/values.yaml b/charts/values.yaml index 92b989e..dc0e741 100644 --- a/charts/values.yaml +++ b/charts/values.yaml @@ -3,9 +3,10 @@ # Only environment-specific settings are exposed here. # For advanced customization, modify the templates directly. -# Deployment mode: "dummy" for dummy-gcp-validation, "real" for gcp-validation -# Currently only "dummy" is supported -deploymentMode: "dummy" # "dummy" or "real" +# Deployment mode: "dummy" for simulation, "real" for actual GCP validation +# - dummy: Uses Alpine shell script to simulate validation results +# - real: Uses Go-based validator to perform actual GCP API checks +deploymentMode: "real" # "dummy" or "real" replicaCount: 1 @@ -72,12 +73,12 @@ hyperfleetApi: baseUrl: "" version: "v1" -# Validation-specific configuration (only for dummy mode) +# Validation-specific configuration validation: # Status reporter image (sidecar container) - statusReporterImage: "quay.io/rh-ee-dawang/status-reporter:dev-04e8d0a" + statusReporterImage: "registry.ci.openshift.org/ci/status-reporter:latest" - # Dummy validation simulation settings + # Dummy validation simulation settings (for deploymentMode: dummy) dummy: # Simulated result: success, failure, hang, crash, invalid-json, missing-status simulateResult: "success" @@ -86,6 +87,21 @@ validation: # Maximum time to wait for validation completion (seconds) maxWaitTimeSeconds: "300" + # Real GCP validator settings (for deploymentMode: real) + real: + # GCP validator container image + gcpValidatorImage: "registry.ci.openshift.org/ci/gcp-validator:latest" + # Comma-separated list of validators to disable (default: all enabled) + # Note: quota-check is a stub validator (not yet implemented, returns success) + disabledValidators: "" + # Comma-separated list of required GCP APIs to validate (default: empty) + # Example: "compute.googleapis.com,storage-api.googleapis.com" + requiredApis: "compute.googleapis.com,iam.googleapis.com,cloudresourcemanager.googleapis.com" + # Path where validation results are written + resultsPath: "/results/adapter-result.json" + # Maximum time to wait for validation completion (seconds) + maxWaitTimeSeconds: "300" + # Additional environment variables env: [] # - name: MY_VAR From 2e624a647a2f372afb06bc176c8cdd778abb296c Mon Sep 17 00:00:00 2001 From: dawang Date: Fri, 16 Jan 2026 16:54:04 +0800 Subject: [PATCH 05/14] Add docstring and update based on coderabbit review comments --- charts/configs/validation-adapter.yaml | 4 +- charts/configs/validation-dummy-adapter.yaml | 2 +- charts/values.yaml | 2 +- validator/cmd/validator/main.go | 3 + validator/go.mod | 52 +++-- validator/go.sum | 222 ++++++------------- validator/pkg/config/config.go | 3 + validator/pkg/validator/executor.go | 22 +- validator/pkg/validator/executor_test.go | 66 +++--- validator/pkg/validators/api_enabled.go | 13 +- validator/pkg/validators/quota_check.go | 4 + 11 files changed, 166 insertions(+), 227 deletions(-) diff --git a/charts/configs/validation-adapter.yaml b/charts/configs/validation-adapter.yaml index 4a6c5d3..acc00e4 100644 --- a/charts/configs/validation-adapter.yaml +++ b/charts/configs/validation-adapter.yaml @@ -58,7 +58,7 @@ spec: - name: "disabledValidators" source: "env.DISABLED_VALIDATORS" type: "string" - default: "" + default: "quota-check" description: "Comma-separated list of validators to disable" - name: "requiredApis" source: "env.REQUIRED_APIS" @@ -81,7 +81,7 @@ spec: default: "gcp-validator-job-sa" description: "Kubernetes ServiceAccount name for the validator job" - name: "managedByResourceName" - source: "env.MANAGED_By_RESOURCE_NAME" + source: "env.MANAGED_BY_RESOURCE_NAME" type: "string" default: "validation-adapter" description: "The value for hyperfleet.io/managed-by" diff --git a/charts/configs/validation-dummy-adapter.yaml b/charts/configs/validation-dummy-adapter.yaml index a78b2cb..3932227 100644 --- a/charts/configs/validation-dummy-adapter.yaml +++ b/charts/configs/validation-dummy-adapter.yaml @@ -70,7 +70,7 @@ spec: default: "gcp-validator-job-sa" description: "Maximum time to wait for validation completion" - name: "managedByResourceName" - source: "env.MANAGED_By_RESOURCE_NAME" + source: "env.MANAGED_BY_RESOURCE_NAME" type: "string" default: "dummy-validation-adapter" description: "The value for hyperfleet.io/managed-by" diff --git a/charts/values.yaml b/charts/values.yaml index dc0e741..321b76f 100644 --- a/charts/values.yaml +++ b/charts/values.yaml @@ -93,7 +93,7 @@ validation: gcpValidatorImage: "registry.ci.openshift.org/ci/gcp-validator:latest" # Comma-separated list of validators to disable (default: all enabled) # Note: quota-check is a stub validator (not yet implemented, returns success) - disabledValidators: "" + disabledValidators: "quota-check" # Comma-separated list of required GCP APIs to validate (default: empty) # Example: "compute.googleapis.com,storage-api.googleapis.com" requiredApis: "compute.googleapis.com,iam.googleapis.com,cloudresourcemanager.googleapis.com" diff --git a/validator/cmd/validator/main.go b/validator/cmd/validator/main.go index 1be1a21..9e7b467 100644 --- a/validator/cmd/validator/main.go +++ b/validator/cmd/validator/main.go @@ -20,6 +20,9 @@ const ( validationTimeout = 5 * time.Minute ) +// main is the entry point for the GCP validator application. +// It loads configuration, executes all enabled validators, aggregates results, +// and writes the output to a JSON file. func main() { // Load configuration first to get log level cfg, err := config.LoadFromEnv() diff --git a/validator/go.mod b/validator/go.mod index a476bbe..706f420 100644 --- a/validator/go.mod +++ b/validator/go.mod @@ -5,42 +5,40 @@ go 1.25.0 require ( github.com/onsi/ginkgo/v2 v2.27.5 github.com/onsi/gomega v1.39.0 - golang.org/x/oauth2 v0.15.0 - google.golang.org/api v0.154.0 + golang.org/x/oauth2 v0.34.0 + google.golang.org/api v0.260.0 ) require ( - cloud.google.com/go/compute v1.23.3 // indirect - cloud.google.com/go/compute/metadata v0.2.3 // indirect + cloud.google.com/go/auth v0.18.0 // indirect + cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect + cloud.google.com/go/compute/metadata v0.9.0 // indirect github.com/Masterminds/semver/v3 v3.4.0 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/go-task/slim-sprig/v3 v3.0.0 // indirect - github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect - github.com/golang/protobuf v1.5.3 // indirect github.com/google/go-cmp v0.7.0 // indirect github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 // indirect - github.com/google/s2a-go v0.1.7 // indirect - github.com/google/uuid v1.4.0 // indirect - github.com/googleapis/enterprise-certificate-proxy v0.3.2 // indirect - github.com/googleapis/gax-go/v2 v2.12.0 // indirect - go.opencensus.io v0.24.0 // indirect - go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.46.1 // indirect - go.opentelemetry.io/otel v1.21.0 // indirect - go.opentelemetry.io/otel/metric v1.21.0 // indirect - go.opentelemetry.io/otel/trace v1.21.0 // indirect + github.com/google/s2a-go v0.1.9 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/googleapis/enterprise-certificate-proxy v0.3.11 // indirect + github.com/googleapis/gax-go/v2 v2.16.0 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.64.0 // indirect + go.opentelemetry.io/otel v1.39.0 // indirect + go.opentelemetry.io/otel/metric v1.39.0 // indirect + go.opentelemetry.io/otel/trace v1.39.0 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect - golang.org/x/crypto v0.41.0 // indirect - golang.org/x/mod v0.27.0 // indirect - golang.org/x/net v0.43.0 // indirect - golang.org/x/sync v0.16.0 // indirect - golang.org/x/sys v0.35.0 // indirect - golang.org/x/text v0.28.0 // indirect - golang.org/x/tools v0.36.0 // indirect - google.golang.org/appengine v1.6.7 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20231127180814-3a041ad873d4 // indirect - google.golang.org/grpc v1.59.0 // indirect - google.golang.org/protobuf v1.36.7 // indirect - gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect + golang.org/x/crypto v0.47.0 // indirect + golang.org/x/mod v0.31.0 // indirect + golang.org/x/net v0.49.0 // indirect + golang.org/x/sync v0.19.0 // indirect + golang.org/x/sys v0.40.0 // indirect + golang.org/x/text v0.33.0 // indirect + golang.org/x/tools v0.40.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260114163908-3f89685c29c3 // indirect + google.golang.org/grpc v1.78.0 // indirect + google.golang.org/protobuf v1.36.11 // indirect ) diff --git a/validator/go.sum b/validator/go.sum index e2d9de1..045b625 100644 --- a/validator/go.sum +++ b/validator/go.sum @@ -1,21 +1,15 @@ -cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= -cloud.google.com/go/compute v1.23.3 h1:6sVlXXBmbd7jNX0Ipq0trII3e4n1/MsADLK6a+aiVlk= -cloud.google.com/go/compute v1.23.3/go.mod h1:VCgBUoMnIVIR0CscqQiPJLAG25E3ZRZMzcFZeQ+h8CI= -cloud.google.com/go/compute/metadata v0.2.3 h1:mg4jlk7mCAj6xXp9UJ4fjI9VUI5rubuGBW5aJ7UnBMY= -cloud.google.com/go/compute/metadata v0.2.3/go.mod h1:VAV5nSsACxMJvgaAuX6Pk2AawlZn8kiOGuCv6gTkwuA= -github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= +cloud.google.com/go/auth v0.18.0 h1:wnqy5hrv7p3k7cShwAU/Br3nzod7fxoqG+k0VZ+/Pk0= +cloud.google.com/go/auth v0.18.0/go.mod h1:wwkPM1AgE1f2u6dG443MiWoD8C3BtOywNsUMcUTVDRo= +cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc= +cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c= +cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs= +cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10= github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0= github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= -github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= -github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= -github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= -github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= -github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= -github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= -github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/gkampitakis/ciinfo v0.3.2 h1:JcuOPk8ZU7nZQjdUhctuhQofk7BGHuIy0c9Ez8BNhXs= @@ -33,51 +27,24 @@ github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1v github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw= github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= -github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= -github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= -github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE= -github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= -github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= -github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= -github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= -github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= -github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= -github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= -github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= -github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= -github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= -github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8= -github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= -github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= -github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg= -github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= -github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= -github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= -github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= -github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 h1:BHT72Gu3keYf3ZEu2J0b1vyeLSOYI8bm5wbJM/8yDe8= github.com/google/pprof v0.0.0-20250403155104-27863c87afa6/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= -github.com/google/s2a-go v0.1.7 h1:60BLSyTrOV4/haCDW4zb1guZItoSq8foHCXrAnjBo/o= -github.com/google/s2a-go v0.1.7/go.mod h1:50CgR4k1jNlWBu4UfS4AcfhVe1r6pdZPygJ3R8F0Qdw= -github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/google/uuid v1.4.0 h1:MtMxsa51/r9yyhkyLsVeVt0B+BGQZzpQiTQ4eHZ8bc4= -github.com/google/uuid v1.4.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/googleapis/enterprise-certificate-proxy v0.3.2 h1:Vie5ybvEvT75RniqhfFxPRy3Bf7vr3h0cechB90XaQs= -github.com/googleapis/enterprise-certificate-proxy v0.3.2/go.mod h1:VLSiSSBs/ksPL8kq3OBOQ6WRI2QnaFynd1DCjZ62+V0= -github.com/googleapis/gax-go/v2 v2.12.0 h1:A+gCJKdRfqXkr+BIRGtZLibNXf0m1f9E4HG56etFpas= -github.com/googleapis/gax-go/v2 v2.12.0/go.mod h1:y+aIqrI5eb1YGMVJfuV3185Ts/D7qKpsEkdD5+I6QGU= +github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0= +github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/googleapis/enterprise-certificate-proxy v0.3.11 h1:vAe81Msw+8tKUxi2Dqh/NZMz7475yUvmRIkXr4oN2ao= +github.com/googleapis/enterprise-certificate-proxy v0.3.11/go.mod h1:RFV7MUdlb7AgEq2v7FmMCfeSMCllAzWxFgRdusoGks8= +github.com/googleapis/gax-go/v2 v2.16.0 h1:iHbQmKLLZrexmb0OSsNGTeSTS0HO4YvFOG8g5E4Zd0Y= +github.com/googleapis/gax-go/v2 v2.16.0/go.mod h1:o1vfQjjNZn4+dPnRdl/4ZD7S9414Y4xA+a/6Icj6l14= github.com/joshdk/go-junit v1.0.0 h1:S86cUKIdwBHWwA6xCmFlf3RTLfVXYQfvanM5Uh+K6GE= github.com/joshdk/go-junit v1.0.0/go.mod h1:TiiV0PqkaNfFXjEiyjWM3XXrhVyCa1K4Zfga6W52ung= -github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= -github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= -github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/maruel/natural v1.1.1 h1:Hja7XhhmvEFhcByqDoHz9QZbkWey+COd9xWfCfn1ioo= @@ -90,17 +57,10 @@ github.com/onsi/gomega v1.39.0 h1:y2ROC3hKFmQZJNFeGAMeHZKkjBL65mIZcvrLQBF9k6Q= github.com/onsi/gomega v1.39.0/go.mod h1:ZCU1pkQcXDO5Sl9/VVEGlDyp+zm0m1cmeG5TOzLgdh4= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= -github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= -github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= -github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= -github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= -github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= -github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= -github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= -github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= @@ -109,104 +69,54 @@ github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= -go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0= -go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.46.1 h1:aFJWCqJMNjENlcleuuOkGAPH82y0yULBScfXcIEdS24= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.46.1/go.mod h1:sEGXWArGqc3tVa+ekntsN65DmVbVeW+7lTKTjZF3/Fo= -go.opentelemetry.io/otel v1.21.0 h1:hzLeKBZEL7Okw2mGzZ0cc4k/A7Fta0uoPgaJCr8fsFc= -go.opentelemetry.io/otel v1.21.0/go.mod h1:QZzNPQPm1zLX4gZK4cMi+71eaorMSGT3A4znnUvNNEo= -go.opentelemetry.io/otel/metric v1.21.0 h1:tlYWfeo+Bocx5kLEloTjbcDwBuELRrIFxwdQ36PlJu4= -go.opentelemetry.io/otel/metric v1.21.0/go.mod h1:o1p3CA8nNHW8j5yuQLdc1eeqEaPfzug24uvsyIEJRWM= -go.opentelemetry.io/otel/trace v1.21.0 h1:WD9i5gzvoUPuXIXH24ZNBudiarZDKuekPqi/E8fpfLc= -go.opentelemetry.io/otel/trace v1.21.0/go.mod h1:LGbsEB0f9LGjN+OZaQQ26sohbOmiMR+BaslueVtS/qQ= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.64.0 h1:ssfIgGNANqpVFCndZvcuyKbl0g+UAVcbBcqGkG28H0Y= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.64.0/go.mod h1:GQ/474YrbE4Jx8gZ4q5I4hrhUzM6UPzyrqJYV2AqPoQ= +go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48= +go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8= +go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0= +go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs= +go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18= +go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE= +go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8= +go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew= +go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI= +go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= -golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= -golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= -golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4= -golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc= -golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= -golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= -golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= -golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= -golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ= -golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc= -golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= -golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= -golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= -golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= -golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE= -golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg= -golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= -golang.org/x/oauth2 v0.15.0 h1:s8pnnxNVzjWyrvYdFUQq5llS1PX2zhPXmccZv99h7uQ= -golang.org/x/oauth2 v0.15.0/go.mod h1:q48ptWNTY5XWf+JNten23lcvHpLJ0ZSxF5ttTHKVCAM= -golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= -golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= -golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= -golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= -golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= -golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= -golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= -golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= -golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= -golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= -golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= -golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg= -golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s= -golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -google.golang.org/api v0.154.0 h1:X7QkVKZBskztmpPKWQXgjJRPA2dJYrL6r+sYPRLj050= -google.golang.org/api v0.154.0/go.mod h1:qhSMkM85hgqiokIYsrRyKxrjfBeIhgl4Z2JmeRkYylc= -google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= -google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= -google.golang.org/appengine v1.6.7 h1:FZR1q0exgwxzPzp/aF+VccGrSfxfPpkBqjIIEq3ru6c= -google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= -google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= -google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= -google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo= -google.golang.org/genproto v0.0.0-20231120223509-83a465c0220f h1:Vn+VyHU5guc9KjB5KrjI2q0wCOWEOIh0OEsleqakHJg= -google.golang.org/genproto v0.0.0-20231120223509-83a465c0220f/go.mod h1:nWSwAFPb+qfNJXsoeO3Io7zf4tMSfN8EA8RlDA04GhY= -google.golang.org/genproto/googleapis/api v0.0.0-20231120223509-83a465c0220f h1:2yNACc1O40tTnrsbk9Cv6oxiW8pxI/pXj0wRtdlYmgY= -google.golang.org/genproto/googleapis/api v0.0.0-20231120223509-83a465c0220f/go.mod h1:Uy9bTZJqmfrw2rIBxgGLnamc78euZULUBrLZ9XTITKI= -google.golang.org/genproto/googleapis/rpc v0.0.0-20231127180814-3a041ad873d4 h1:DC7wcm+i+P1rN3Ff07vL+OndGg5OhNddHyTA+ocPqYE= -google.golang.org/genproto/googleapis/rpc v0.0.0-20231127180814-3a041ad873d4/go.mod h1:eJVxU6o+4G1PSczBr85xmyvSNYAKvAYgkub40YGomFM= -google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= -google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= -google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY= -google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= -google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc= -google.golang.org/grpc v1.59.0 h1:Z5Iec2pjwb+LEOqzpB2MR12/eKFhDPhuqW91O+4bwUk= -google.golang.org/grpc v1.59.0/go.mod h1:aUPDwccQo6OTjy7Hct4AfBPD1GptF4fyUjIkQ9YtF98= -google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= -google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= -google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= -google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= -google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= -google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= -google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= -google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= -google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c= -google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= -google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= -google.golang.org/protobuf v1.36.7 h1:IgrO7UwFQGJdRNXH/sQux4R1Dj1WAKcLElzeeRaXV2A= -google.golang.org/protobuf v1.36.7/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= +golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8= +golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A= +golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI= +golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg= +golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= +golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= +golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw= +golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= +golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= +golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= +golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE= +golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8= +golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA= +golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc= +gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= +gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= +google.golang.org/api v0.260.0 h1:XbNi5E6bOVEj/uLXQRlt6TKuEzMD7zvW/6tNwltE4P4= +google.golang.org/api v0.260.0/go.mod h1:Shj1j0Phr/9sloYrKomICzdYgsSDImpTxME8rGLaZ/o= +google.golang.org/genproto v0.0.0-20251202230838-ff82c1b0f217 h1:GvESR9BIyHUahIb0NcTum6itIWtdoglGX+rnGxm2934= +google.golang.org/genproto v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:yJ2HH4EHEDTd3JiLmhds6NkJ17ITVYOdV3m3VKOnws0= +google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217 h1:fCvbg86sFXwdrl5LgVcTEvNC+2txB5mgROGmRL5mrls= +google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:+rXWjjaukWZun3mLfjmVnQi18E1AsFbDN9QdJ5YXLto= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260114163908-3f89685c29c3 h1:C4WAdL+FbjnGlpp2S+HMVhBeCq2Lcib4xZqfPNF6OoQ= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260114163908-3f89685c29c3/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ= +google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc= +google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= -gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= -honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= diff --git a/validator/pkg/config/config.go b/validator/pkg/config/config.go index f07dd41..0c9015c 100644 --- a/validator/pkg/config/config.go +++ b/validator/pkg/config/config.go @@ -84,6 +84,7 @@ func LoadFromEnv() (*Config, error) { return cfg, nil } +// getEnv retrieves an environment variable or returns a default value if not set func getEnv(key, defaultValue string) string { if value := os.Getenv(key); value != "" { return value @@ -91,6 +92,7 @@ func getEnv(key, defaultValue string) string { return defaultValue } +// getEnvBool retrieves a boolean environment variable or returns a default value if not set or invalid func getEnvBool(key string, defaultValue bool) bool { if value := os.Getenv(key); value != "" { b, err := strconv.ParseBool(value) @@ -101,6 +103,7 @@ func getEnvBool(key string, defaultValue bool) bool { return defaultValue } +// getEnvInt retrieves an integer environment variable or returns a default value if not set or invalid func getEnvInt(key string, defaultValue int) int { if value := os.Getenv(key); value != "" { i, err := strconv.Atoi(value) diff --git a/validator/pkg/validator/executor.go b/validator/pkg/validator/executor.go index 083ca33..2427505 100644 --- a/validator/pkg/validator/executor.go +++ b/validator/pkg/validator/executor.go @@ -133,9 +133,25 @@ func (e *Executor) executeGroup(ctx context.Context, group ExecutionGroup) []*Re start := time.Now() result := validator.Validate(ctx, e.ctx) - result.Duration = time.Since(start) - result.Timestamp = time.Now().UTC() - result.ValidatorName = meta.Name + + // Defensive nil check - validator.Validate should never return nil, + // but handle it to prevent nil pointer panics + if result == nil { + e.logger.Error("Validator returned nil result", + "validator", meta.Name) + result = &Result{ + ValidatorName: meta.Name, + Status: StatusFailure, + Reason: "NilResult", + Message: "Validator returned nil result (this is a validator implementation bug)", + Duration: time.Since(start), + Timestamp: time.Now().UTC(), + } + } else { + result.Duration = time.Since(start) + result.Timestamp = time.Now().UTC() + result.ValidatorName = meta.Name + } // Thread-safe result storage e.mu.Lock() diff --git a/validator/pkg/validator/executor_test.go b/validator/pkg/validator/executor_test.go index df39278..f01a315 100644 --- a/validator/pkg/validator/executor_test.go +++ b/validator/pkg/validator/executor_test.go @@ -42,10 +42,6 @@ var _ = Describe("Executor", func() { } }) - AfterEach(func() { - Expect(os.Unsetenv("PROJECT_ID")).To(Succeed()) - }) - Describe("ExecuteAll", func() { Context("with no validators registered", func() { It("should return error when no validators are enabled", func() { @@ -161,48 +157,48 @@ var _ = Describe("Executor", func() { }) }) - Context("with dependent validators", func() { - var executionOrder []string - var mu sync.Mutex - - BeforeEach(func() { - executionOrder = []string{} - - // Level 0 validator - validator.Register(&MockValidator{ - name: "validator-a", - runAfter: []string{}, - enabled: true, - validateFunc: func(ctx context.Context, vctx *validator.Context) *validator.Result { - mu.Lock() - executionOrder = append(executionOrder, "validator-a") - mu.Unlock() - return &validator.Result{ - ValidatorName: "validator-a", - Status: validator.StatusSuccess, - } - }, - }) + Context("with dependent validators", func() { + var executionOrder []string + var mu sync.Mutex + + BeforeEach(func() { + executionOrder = []string{} - // Level 1 validators (depend on validator-a) - for _, name := range []string{"validator-b", "validator-c"} { - n := name + // Level 0 validator validator.Register(&MockValidator{ - name: n, - runAfter: []string{"validator-a"}, + name: "validator-a", + runAfter: []string{}, enabled: true, validateFunc: func(ctx context.Context, vctx *validator.Context) *validator.Result { mu.Lock() - executionOrder = append(executionOrder, n) + executionOrder = append(executionOrder, "validator-a") mu.Unlock() return &validator.Result{ - ValidatorName: n, + ValidatorName: "validator-a", Status: validator.StatusSuccess, } }, }) - } - }) + + // Level 1 validators (depend on validator-a) + for _, name := range []string{"validator-b", "validator-c"} { + n := name + validator.Register(&MockValidator{ + name: n, + runAfter: []string{"validator-a"}, + enabled: true, + validateFunc: func(ctx context.Context, vctx *validator.Context) *validator.Result { + mu.Lock() + executionOrder = append(executionOrder, n) + mu.Unlock() + return &validator.Result{ + ValidatorName: n, + Status: validator.StatusSuccess, + } + }, + }) + } + }) It("should execute validators in dependency order", func() { executor = validator.NewExecutor(vctx, logger) diff --git a/validator/pkg/validators/api_enabled.go b/validator/pkg/validators/api_enabled.go index b45a14e..248ad0f 100644 --- a/validator/pkg/validators/api_enabled.go +++ b/validator/pkg/validators/api_enabled.go @@ -44,10 +44,12 @@ func extractErrorReason(err error, fallbackReason string) string { // APIEnabledValidator checks if required GCP APIs are enabled type APIEnabledValidator struct{} +// init registers the APIEnabledValidator with the global validator registry func init() { validator.Register(&APIEnabledValidator{}) } +// Metadata returns the validator configuration including name, description, and dependencies func (v *APIEnabledValidator) Metadata() validator.ValidatorMetadata { return validator.ValidatorMetadata{ Name: "api-enabled", @@ -57,10 +59,12 @@ func (v *APIEnabledValidator) Metadata() validator.ValidatorMetadata { } } +// Enabled determines if this validator should run based on configuration func (v *APIEnabledValidator) Enabled(ctx *validator.Context) bool { return ctx.Config.IsValidatorEnabled("api-enabled") } +// Validate performs the actual validation logic to check if required GCP APIs are enabled func (v *APIEnabledValidator) Validate(ctx context.Context, vctx *validator.Context) *validator.Result { slog.Info("Checking if required GCP APIs are enabled") @@ -157,12 +161,17 @@ func (v *APIEnabledValidator) Validate(ctx context.Context, vctx *validator.Cont } } - slog.Info("All required APIs are enabled", "count", len(enabledAPIs)) + // Build success message based on whether APIs were checked + message := fmt.Sprintf("All %d required APIs are enabled", len(enabledAPIs)) + if len(enabledAPIs) == 0 { + message = "No required APIs to validate" + } + slog.Info(message) return &validator.Result{ Status: validator.StatusSuccess, Reason: "AllAPIsEnabled", - Message: fmt.Sprintf("All %d required APIs are enabled", len(enabledAPIs)), + Message: message, Details: map[string]interface{}{ "enabled_apis": enabledAPIs, "project_id": vctx.Config.ProjectID, diff --git a/validator/pkg/validators/quota_check.go b/validator/pkg/validators/quota_check.go index e2888bf..d6d4655 100644 --- a/validator/pkg/validators/quota_check.go +++ b/validator/pkg/validators/quota_check.go @@ -11,10 +11,12 @@ import ( // TODO: Implement actual quota checking logic type QuotaCheckValidator struct{} +// init registers the QuotaCheckValidator with the global validator registry func init() { validator.Register(&QuotaCheckValidator{}) } +// Metadata returns the validator configuration including name, description, and dependencies func (v *QuotaCheckValidator) Metadata() validator.ValidatorMetadata { return validator.ValidatorMetadata{ Name: "quota-check", @@ -24,10 +26,12 @@ func (v *QuotaCheckValidator) Metadata() validator.ValidatorMetadata { } } +// Enabled determines if this validator should run based on configuration func (v *QuotaCheckValidator) Enabled(ctx *validator.Context) bool { return ctx.Config.IsValidatorEnabled("quota-check") } +// Validate performs the actual validation logic (currently a stub returning success) func (v *QuotaCheckValidator) Validate(ctx context.Context, vctx *validator.Context) *validator.Result { slog.Info("Running quota check validator (stub implementation)") From f22eab0ec225016e1915e77c9747f63321bb9f3a Mon Sep 17 00:00:00 2001 From: dawang Date: Fri, 16 Jan 2026 17:19:19 +0800 Subject: [PATCH 06/14] Update with coderabbit review comments --- charts/configs/validation-adapter.yaml | 4 ++-- charts/configs/validation-dummy-adapter.yaml | 4 ++-- dummy-validator/README.md | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/charts/configs/validation-adapter.yaml b/charts/configs/validation-adapter.yaml index acc00e4..17f8047 100644 --- a/charts/configs/validation-adapter.yaml +++ b/charts/configs/validation-adapter.yaml @@ -47,13 +47,13 @@ spec: - name: "statusReporterImage" source: "env.STATUS_REPORTER_IMAGE" type: "string" - default: "quay.io/rh-ee-dawang/status-reporter:dev-04e8d0a" + default: "registry.ci.openshift.org/ci/status-reporter:latest" description: "Container image for the status reporter" # GCP Validator configuration - name: "gcpValidatorImage" source: "env.GCP_VALIDATOR_IMAGE" type: "string" - default: "quay.io/rh-ee-dawang/gcp-validator:latest" + default: "registry.ci.openshift.org/ci/gcp-validator:latest" description: "GCP validator container image" - name: "disabledValidators" source: "env.DISABLED_VALIDATORS" diff --git a/charts/configs/validation-dummy-adapter.yaml b/charts/configs/validation-dummy-adapter.yaml index 3932227..1d86f68 100644 --- a/charts/configs/validation-dummy-adapter.yaml +++ b/charts/configs/validation-dummy-adapter.yaml @@ -47,7 +47,7 @@ spec: - name: "statusReporterImage" source: "env.STATUS_REPORTER_IMAGE" type: "string" - default: "quay.io/rh-ee-dawang/status-reporter:dev-04e8d0a" + default: "registry.ci.openshift.org/ci/status-reporter:latest" description: "Container image for the status reporter" - name: "simulateResult" source: "env.SIMULATE_RESULT" @@ -68,7 +68,7 @@ spec: source: "env.GCP_VALIDATOR_SERVICE_ACCOUNT" type: "string" default: "gcp-validator-job-sa" - description: "Maximum time to wait for validation completion" + description: "Kubernetes ServiceAccount name for the validator job" - name: "managedByResourceName" source: "env.MANAGED_BY_RESOURCE_NAME" type: "string" diff --git a/dummy-validator/README.md b/dummy-validator/README.md index 12e47b5..46b1195 100644 --- a/dummy-validator/README.md +++ b/dummy-validator/README.md @@ -52,7 +52,7 @@ The validator supports the following simulation scenarios via the `SIMULATE_RESU # Replace placeholders and apply sed -e 's||success|g' \ -e 's||your-namespace|g' \ - -e 's||quay.io/rh-ee-dawang/status-reporter:dev-04e8d0a|g' \ + -e 's||registry.ci.openshift.org/ci/status-reporter:latest|g' \ job-template.yaml | kubectl apply -f - ``` @@ -77,7 +77,7 @@ The `job-template.yaml` file includes the following placeholders that should be |-------------|-------------|----------------| | `` | Your Kubernetes namespace | `default`, `validation-testing` | | `` | The test scenario to run | `success`, `failure`, `hang`, `crash`, `invalid-json`, `missing-status` | -| `` | The status-reporter container image | `quay.io/rh-ee-dawang/status-reporter:dev-04e8d0a` | +| `` | The status-reporter container image | `registry.ci.openshift.org/ci/status-reporter:latest` | The `` placeholder is used in multiple places: - Job name: `dummy-validator-` From cbda303945676bbd9c97bc7a399c5712d9de331c Mon Sep 17 00:00:00 2001 From: dawang Date: Thu, 22 Jan 2026 18:13:41 +0800 Subject: [PATCH 07/14] Introduce useDummy to indicate real or dummy validation, and refine adapter environment variables --- Makefile | 31 +++-- README.md | 109 +++++++++++------- charts/configs/validation-adapter.yaml | 35 +++--- charts/configs/validation-dummy-adapter.yaml | 30 ++--- .../validation-dummy-job-adapter-task.yaml | 3 +- .../configs/validation-job-adapter-task.yaml | 9 +- charts/templates/_helpers.tpl | 6 +- charts/templates/configmap-app.yaml | 12 +- charts/templates/deployment.yaml | 23 ++-- charts/values.yaml | 27 ++--- 10 files changed, 145 insertions(+), 140 deletions(-) diff --git a/Makefile b/Makefile index 48f0464..706081e 100644 --- a/Makefile +++ b/Makefile @@ -54,7 +54,7 @@ helm-template-dummy: ## Render helm templates with dummy validation mode @echo "$(GREEN)Rendering helm templates (dummy validation mode)...$(NC)" helm template $(RELEASE_NAME) $(CHART_DIR) \ --namespace $(NAMESPACE) \ - --set deploymentMode=dummy \ + --set validation.useDummy=true \ --set broker.type=googlepubsub \ --set broker.googlepubsub.projectId=my-project \ --set broker.googlepubsub.topic=my-topic \ @@ -65,20 +65,19 @@ helm-template-dummy: ## Render helm templates with dummy validation mode --set validation.dummy.simulateResult=success \ --set rbac.create=true -# Real mode (not yet available; keep commented until implemented — see HYPERFLEET-267) -#helm-template-real: ## Render helm templates with real validation mode (future) -# @echo "$(GREEN)Rendering helm templates (real validation mode)...$(NC)" -# helm template $(RELEASE_NAME) $(CHART_DIR) \ -# --namespace $(NAMESPACE) \ -# --set deploymentMode=real \ -# --set broker.type=googlepubsub \ -# --set broker.googlepubsub.projectId=my-project \ -# --set broker.googlepubsub.topic=my-topic \ -# --set broker.googlepubsub.subscription=my-subscription \ -# --set broker.googlepubsub.deadLetterTopic=my-dlq \ -# --set broker.subscriber.parallelism=20 \ -# --set hyperfleetApi.baseUrl=https://api.hyperfleet.example.com \ -# --set rbac.create=true +helm-template-real: ## Render helm templates with real validation mode + @echo "$(GREEN)Rendering helm templates (real validation mode)...$(NC)" + helm template $(RELEASE_NAME) $(CHART_DIR) \ + --namespace $(NAMESPACE) \ + --set validation.useDummy=false \ + --set broker.type=googlepubsub \ + --set broker.googlepubsub.projectId=my-project \ + --set broker.googlepubsub.topic=my-topic \ + --set broker.googlepubsub.subscription=my-subscription \ + --set broker.googlepubsub.deadLetterTopic=my-dlq \ + --set broker.subscriber.parallelism=20 \ + --set hyperfleetApi.baseUrl=https://api.hyperfleet.example.com \ + --set rbac.create=true helm-template-full: helm-template-dummy ## Alias for helm-template-dummy (full dummy configuration) @@ -92,7 +91,7 @@ helm-dry-run: ## Simulate helm install (requires cluster connection) --create-namespace \ --dry-run \ --debug \ - --set deploymentMode=dummy \ + --set validation.useDummy=true \ --set broker.type=googlepubsub \ --set broker.googlepubsub.projectId=test-project \ --set broker.googlepubsub.topic=test-topic \ diff --git a/README.md b/README.md index 12e2f9e..bf045b9 100644 --- a/README.md +++ b/README.md @@ -20,10 +20,19 @@ Event-driven adapter for HyperFleet GCP cluster validation. Validates GCP cluste ## Deployment Modes -This adapter supports two deployment modes via the `deploymentMode` parameter: +This adapter supports two deployment modes via the `validation.useDummy` parameter: -### Dummy Mode (Current) -- **Value**: `deploymentMode: "dummy"` +### Real Mode (Default, Production) +- **Value**: `validation.useDummy: false` (default) +- **Description**: Performs actual GCP validation checks +- **Config File**: Uses `charts/configs/validation-adapter.yaml` +- **Features**: + - Real GCP API validation + - Production-ready validation checks + - Comprehensive error reporting + +### Dummy Mode (Testing/Development) +- **Value**: `validation.useDummy: true` - **Description**: Simulates GCP validation for testing and development - **Config File**: Uses `charts/configs/validation-dummy-adapter.yaml` - **Features**: @@ -31,15 +40,6 @@ This adapter supports two deployment modes via the `deploymentMode` parameter: - No actual GCP API calls - Fast validation cycles for testing -### Real Mode (Future) -- **Value**: `deploymentMode: "real"` -- **Description**: Performs actual GCP validation checks -- **Config File**: Will use `charts/configs/validation-gcp-adapter.yaml` (to be created) -- **Features**: - - Real GCP API validation - - Production-ready validation checks - - Comprehensive error reporting - ## Local Development Run the adapter locally for development and testing. @@ -115,7 +115,7 @@ BROKER_TYPE=rabbitmq ./run-local.sh ### Installing the Chart -**Dummy Validation Mode (Default):** +**Real Validation Mode (Default, Production):** ```bash helm install validation-gcp ./charts/ \ @@ -129,20 +129,22 @@ helm install validation-gcp ./charts/ \ **With Specific Deployment Mode:** ```bash -# Dummy mode (simulated validation) +# Dummy mode (simulated validation for testing) helm install validation-gcp ./charts/ \ - --set deploymentMode=dummy \ + --set validation.useDummy=true \ --set validation.dummy.simulateResult=success \ --set broker.type=googlepubsub \ --set broker.googlepubsub.projectId=my-gcp-project \ --set broker.googlepubsub.topic=my-topic \ --set broker.googlepubsub.subscription=my-subscription -# Real mode (not yet available; keep commented until implemented — see HYPERFLEET-267) -# helm install validation-gcp ./charts/ \ -# --set deploymentMode=real \ -# --set broker.type=googlepubsub \ -# ... +# Real mode (production GCP validation - this is the default) +helm install validation-gcp ./charts/ \ + --set validation.useDummy=false \ + --set broker.type=googlepubsub \ + --set broker.googlepubsub.projectId=my-gcp-project \ + --set broker.googlepubsub.topic=my-topic \ + --set broker.googlepubsub.subscription=my-subscription ``` ### Install to a Specific Namespace @@ -170,11 +172,11 @@ helm delete validation-gcp --namespace hyperfleet-system All configurable parameters are in `values.yaml`. For advanced customization, modify the templates directly. -### Deployment Mode +### Validation Mode | Parameter | Description | Default | |-----------|-------------|---------| -| `deploymentMode` | Deployment mode: "dummy" or "real" | `"dummy"` | +| `validation.useDummy` | Use dummy mode for testing (true) or real validation (false) | `false` | ### Image & Replica @@ -259,17 +261,28 @@ When `rbac.create=true`, the adapter gets **minimal permissions** needed for val ### Validation Configuration +#### Common Settings (Both Modes) + | Parameter | Description | Default | |-----------|-------------|---------| -| `validation.statusReporterImage` | Status reporter sidecar image | `` | +| `validation.statusReporterImage` | Status reporter sidecar image | `registry.ci.openshift.org/ci/status-reporter:latest` | +| `validation.resultsPath` | Path where validation results are written | `"/results/adapter-result.json"` | +| `validation.maxWaitTimeSeconds` | Maximum time to wait for validation completion | `"300"` | -#### Dummy Validation Mode Settings +#### Dummy Mode Settings (when `validation.useDummy=true`) | Parameter | Description | Default | |-----------|-------------|---------| | `validation.dummy.simulateResult` | Simulated result (success, failure, hang, crash, invalid-json, missing-status) | `"success"` | -| `validation.dummy.resultsPath` | Path where validation results are written | `"/results/adapter-result.json"` | -| `validation.dummy.maxWaitTimeSeconds` | Maximum time to wait for validation completion | `"300"` | + +#### Real Mode Settings (when `validation.useDummy=false`) + +| Parameter | Description | Default | +|-----------|-------------|---------| +| `validation.real.gcpValidatorImage` | GCP validator container image | `registry.ci.openshift.org/ci/gcp-validator:latest` | +| `validation.real.disabledValidators` | Comma-separated list of validators to disable | `"quota-check"` | +| `validation.real.requiredApis` | Comma-separated list of required GCP APIs to validate | `"compute.googleapis.com,iam.googleapis.com,cloudresourcemanager.googleapis.com"` | +| `validation.real.logLevel` | Log level for validation containers (debug, info, warn, error) | `"info"` | ### Environment Variables @@ -291,11 +304,10 @@ env: ## Examples -### Basic Dummy Validation with Google Pub/Sub +### Basic Real Validation with Google Pub/Sub (Default) ```bash helm install validation-gcp ./charts/ \ - --set deploymentMode=dummy \ --set broker.type=googlepubsub \ --set broker.googlepubsub.projectId=my-gcp-project \ --set broker.googlepubsub.topic=my-topic \ @@ -308,6 +320,7 @@ helm install validation-gcp ./charts/ \ ```bash # Simulate failure helm install validation-gcp ./charts/ \ + --set validation.useDummy=true \ --set validation.dummy.simulateResult=failure \ --set broker.type=googlepubsub \ --set broker.googlepubsub.projectId=my-gcp-project \ @@ -316,8 +329,9 @@ helm install validation-gcp ./charts/ \ # Simulate hang (for timeout testing) helm install validation-gcp ./charts/ \ + --set validation.useDummy=true \ --set validation.dummy.simulateResult=hang \ - --set validation.dummy.maxWaitTimeSeconds=60 \ + --set validation.maxWaitTimeSeconds=60 \ --set broker.type=googlepubsub \ ... ``` @@ -326,7 +340,7 @@ helm install validation-gcp ./charts/ \ ```bash helm install validation-gcp ./charts/ \ - --set deploymentMode=dummy \ + --set validation.useDummy=true \ --set broker.type=rabbitmq \ --set broker.rabbitmq.url="amqp://user:password@rabbitmq.svc:5672/" ``` @@ -354,10 +368,10 @@ gcloud projects add-iam-policy-binding my-gcp-project \ Then deploy: ```bash +# Real validation mode (default) helm install validation-gcp ./charts/ \ --namespace hyperfleet-system \ --create-namespace \ - --set deploymentMode=dummy \ --set image.registry=us-central1-docker.pkg.dev/my-project/my-repo \ --set image.repository=hyperfleet-adapter \ --set image.tag=v0.1.0 \ @@ -375,8 +389,6 @@ helm install validation-gcp ./charts/ \ Example my-values.yaml ```yaml -deploymentMode: dummy - replicaCount: 1 image: @@ -409,11 +421,24 @@ broker: parallelism: 1 validation: - statusReporterImage: + # Use dummy mode for testing, false for production (default) + useDummy: false + + # Common settings + statusReporterImage: registry.ci.openshift.org/ci/status-reporter:latest + resultsPath: /results/adapter-result.json + maxWaitTimeSeconds: "300" + + # Dummy mode settings (only when useDummy=true) dummy: simulateResult: success - resultsPath: /results/adapter-result.json - maxWaitTimeSeconds: "300" + + # Real mode settings (only when useDummy=false) + real: + gcpValidatorImage: registry.ci.openshift.org/ci/gcp-validator:latest + disabledValidators: "quota-check" + requiredApis: "compute.googleapis.com,iam.googleapis.com,cloudresourcemanager.googleapis.com" + logLevel: "info" ``` @@ -437,10 +462,14 @@ The deployment sets these environment variables automatically: | `BROKER_SUBSCRIPTION_ID` | From `broker.googlepubsub.subscription` | When `broker.type=googlepubsub` | | `BROKER_TOPIC` | From `broker.googlepubsub.topic` | When `broker.type=googlepubsub` | | `GCP_PROJECT_ID` | From `broker.googlepubsub.projectId` | When `broker.type=googlepubsub` | -| `STATUS_REPORTER_IMAGE` | From `validation.statusReporterImage` | When `deploymentMode=dummy` | -| `SIMULATE_RESULT` | From `validation.dummy.simulateResult` | When `deploymentMode=dummy` | -| `RESULTS_PATH` | From `validation.dummy.resultsPath` | When `deploymentMode=dummy` | -| `MAX_WAIT_TIME_SECONDS` | From `validation.dummy.maxWaitTimeSeconds` | When `deploymentMode=dummy` | +| `STATUS_REPORTER_IMAGE` | From `validation.statusReporterImage` | Always | +| `RESULTS_PATH` | From `validation.resultsPath` | Always | +| `MAX_WAIT_TIME_SECONDS` | From `validation.maxWaitTimeSeconds` | Always | +| `SIMULATE_RESULT` | From `validation.dummy.simulateResult` | When `validation.useDummy=true` | +| `GCP_VALIDATOR_IMAGE` | From `validation.real.gcpValidatorImage` | When `validation.useDummy=false` | +| `DISABLED_VALIDATORS` | From `validation.real.disabledValidators` | When `validation.useDummy=false` | +| `REQUIRED_APIS` | From `validation.real.requiredApis` | When `validation.useDummy=false` | +| `VALIDATOR_LOG_LEVEL` | From `validation.real.logLevel` | When `validation.useDummy=false` | ## License diff --git a/charts/configs/validation-adapter.yaml b/charts/configs/validation-adapter.yaml index 17f8047..0294712 100644 --- a/charts/configs/validation-adapter.yaml +++ b/charts/configs/validation-adapter.yaml @@ -75,21 +75,21 @@ spec: type: "string" default: "300" description: "Maximum time to wait for validation completion" - - name: "gcpValidatorServiceAccount" - source: "env.GCP_VALIDATOR_SERVICE_ACCOUNT" + - name: "logLevel" + source: "env.VALIDATOR_LOG_LEVEL" type: "string" - default: "gcp-validator-job-sa" - description: "Kubernetes ServiceAccount name for the validator job" + default: "info" + description: "Log level for validation containers (debug, info, warn, error)" + - name: "adapterTaskServiceAccount" + source: "env.ADAPTER_TASK_SERVICE_ACCOUNT" + type: "string" + default: "validation-adapter-task-sa" + description: "Kubernetes ServiceAccount name for the adapter task" - name: "managedByResourceName" source: "env.MANAGED_BY_RESOURCE_NAME" type: "string" default: "validation-adapter" description: "The value for hyperfleet.io/managed-by" - - name: "createdByResourceName" - source: "env.CREATED_BY_RESOURCE_NAME" - type: "string" - default: "hyperfleet-adapter" - description: "The value for hyperfleet.io/created-by:" # ============================================================================ # Preconditions # ============================================================================ @@ -139,22 +139,21 @@ spec: # ============================================================================ resources: # ========================================================================== - # Resource: ServiceAccount for GCP Validation Job + # Resource: ServiceAccount for Adapter Task # ========================================================================== - - name: "gcpValidationServiceAccount" + - name: "adapterTaskServiceAccount" manifest: - # ServiceAccount for the validator job + # ServiceAccount for the adapter task apiVersion: v1 kind: ServiceAccount metadata: - name: "{{ .gcpValidatorServiceAccount }}" + name: "{{ .adapterTaskServiceAccount }}" namespace: "{{ .clusterId | lower }}" labels: hyperfleet.io/cluster-id: "{{ .clusterId }}" hyperfleet.io/managed-by: "{{ .managedByResourceName }}" hyperfleet.io/resource-type: "service-account" annotations: - hyperfleet.io/created-by: "{{ .createdByResourceName }}" hyperfleet.io/generation: "{{ .generationId }}" discovery: bySelectors: @@ -165,7 +164,7 @@ spec: # ========================================================================== # Resource: Role with necessary permissions for status reporter # ========================================================================== - - name: "gcpValidationRole" + - name: "adapterTaskRole" manifest: # Role with necessary permissions for status reporter apiVersion: rbac.authorization.k8s.io/v1 @@ -178,7 +177,6 @@ spec: hyperfleet.io/managed-by: "{{ .managedByResourceName }}" hyperfleet.io/resource-type: "role" annotations: - hyperfleet.io/created-by: "{{ .createdByResourceName }}" hyperfleet.io/generation: "{{ .generationId }}" rules: # Permission to get and update job status @@ -201,7 +199,7 @@ spec: # ========================================================================== # Rolebinding to grant permissions to the service account # ========================================================================== - - name: "gcpValidationRoleBinding" + - name: "adapterTaskRoleBinding" manifest: # RoleBinding to grant permissions to the service account apiVersion: rbac.authorization.k8s.io/v1 @@ -214,7 +212,6 @@ spec: hyperfleet.io/managed-by: "{{ .managedByResourceName }}" hyperfleet.io/resource-type: "role-binding" annotations: - hyperfleet.io/created-by: "{{ .createdByResourceName }}" hyperfleet.io/generation: "{{ .generationId }}" roleRef: apiGroup: rbac.authorization.k8s.io @@ -222,7 +219,7 @@ spec: name: status-reporter subjects: - kind: ServiceAccount - name: "{{ .gcpValidatorServiceAccount }}" + name: "{{ .adapterTaskServiceAccount }}" namespace: "{{ .clusterId | lower }}" discovery: bySelectors: diff --git a/charts/configs/validation-dummy-adapter.yaml b/charts/configs/validation-dummy-adapter.yaml index 1d86f68..dd40959 100644 --- a/charts/configs/validation-dummy-adapter.yaml +++ b/charts/configs/validation-dummy-adapter.yaml @@ -64,21 +64,16 @@ spec: type: "string" default: "300" description: "Maximum time to wait for validation completion" - - name: "gcpValidatorServiceAccount" - source: "env.GCP_VALIDATOR_SERVICE_ACCOUNT" + - name: "adapterTaskServiceAccount" + source: "env.ADAPTER_TASK_SERVICE_ACCOUNT" type: "string" - default: "gcp-validator-job-sa" - description: "Kubernetes ServiceAccount name for the validator job" + default: "validation-adapter-task-sa" + description: "Kubernetes ServiceAccount name for the adapter task" - name: "managedByResourceName" source: "env.MANAGED_BY_RESOURCE_NAME" type: "string" default: "dummy-validation-adapter" description: "The value for hyperfleet.io/managed-by" - - name: "createdByResourceName" - source: "env.CREATED_BY_RESOURCE_NAME" - type: "string" - default: "hyperfleet-adapter" - description: "The value for hyperfleet.io/created-by:" # ============================================================================ # Preconditions # ============================================================================ @@ -122,22 +117,21 @@ spec: # ============================================================================ resources: # ========================================================================== - # Resource: ServiceAccount for GCP Validation Job + # Resource: ServiceAccount for Adapter Task # ========================================================================== - - name: "gcpValidationServiceAccount" + - name: "adapterTaskServiceAccount" manifest: - # ServiceAccount for the validator job + # ServiceAccount for the adapter task apiVersion: v1 kind: ServiceAccount metadata: - name: "{{ .gcpValidatorServiceAccount }}" + name: "{{ .adapterTaskServiceAccount }}" namespace: "{{ .clusterId | lower }}" labels: hyperfleet.io/cluster-id: "{{ .clusterId }}" hyperfleet.io/managed-by: "{{ .managedByResourceName }}" hyperfleet.io/resource-type: "service-account" annotations: - hyperfleet.io/created-by: "{{ .createdByResourceName }}" hyperfleet.io/generation: "{{ .generationId }}" discovery: bySelectors: @@ -148,7 +142,7 @@ spec: # ========================================================================== # Resource: Role with necessary permissions for status reporter # ========================================================================== - - name: "gcpValidationRole" + - name: "adapterTaskRole" manifest: # Role with necessary permissions for status reporter apiVersion: rbac.authorization.k8s.io/v1 @@ -161,7 +155,6 @@ spec: hyperfleet.io/managed-by: "{{ .managedByResourceName }}" hyperfleet.io/resource-type: "role" annotations: - hyperfleet.io/created-by: "{{ .createdByResourceName }}" hyperfleet.io/generation: "{{ .generationId }}" rules: # Permission to get and update job status @@ -184,7 +177,7 @@ spec: # ========================================================================== # Rolebinding to grant permissions to the service account # ========================================================================== - - name: "gcpValidationRoleBinding" + - name: "adapterTaskRoleBinding" manifest: # RoleBinding to grant permissions to the service account apiVersion: rbac.authorization.k8s.io/v1 @@ -197,7 +190,6 @@ spec: hyperfleet.io/managed-by: "{{ .managedByResourceName }}" hyperfleet.io/resource-type: "role-binding" annotations: - hyperfleet.io/created-by: "{{ .createdByResourceName }}" hyperfleet.io/generation: "{{ .generationId }}" roleRef: apiGroup: rbac.authorization.k8s.io @@ -205,7 +197,7 @@ spec: name: status-reporter subjects: - kind: ServiceAccount - name: "{{ .gcpValidatorServiceAccount }}" + name: "{{ .adapterTaskServiceAccount }}" namespace: "{{ .clusterId | lower }}" discovery: bySelectors: diff --git a/charts/configs/validation-dummy-job-adapter-task.yaml b/charts/configs/validation-dummy-job-adapter-task.yaml index 86f8bfc..d71a029 100644 --- a/charts/configs/validation-dummy-job-adapter-task.yaml +++ b/charts/configs/validation-dummy-job-adapter-task.yaml @@ -9,7 +9,6 @@ metadata: hyperfleet.io/resource-type: "validation-job" app: gcp-validator annotations: - hyperfleet.io/created-by: "{{ .createdByResourceName }}" hyperfleet.io/generation: "{{ .generationId }}" spec: backoffLimit: 0 @@ -22,7 +21,7 @@ spec: hyperfleet.io/cluster-id: "{{ .clusterId }}" spec: # Created before the job is created, as specified in the adapter configuration. - serviceAccountName: "{{ .gcpValidatorServiceAccount }}" + serviceAccountName: "{{ .adapterTaskServiceAccount }}" restartPolicy: Never volumes: - name: results diff --git a/charts/configs/validation-job-adapter-task.yaml b/charts/configs/validation-job-adapter-task.yaml index 320a8da..0a1c9cf 100644 --- a/charts/configs/validation-job-adapter-task.yaml +++ b/charts/configs/validation-job-adapter-task.yaml @@ -9,7 +9,6 @@ metadata: hyperfleet.io/resource-type: "validation-job" app: gcp-validator annotations: - hyperfleet.io/created-by: "{{ .createdByResourceName }}" hyperfleet.io/generation: "{{ .generationId }}" spec: backoffLimit: 0 @@ -22,13 +21,13 @@ spec: hyperfleet.io/cluster-id: "{{ .clusterId }}" spec: # Created before the job is created, as specified in the adapter configuration. - serviceAccountName: "{{ .gcpValidatorServiceAccount }}" + serviceAccountName: "{{ .adapterTaskServiceAccount }}" restartPolicy: Never volumes: - name: results emptyDir: { } containers: - # GCP Validator Container (replaces dummy-validator) + # GCP Validator Container - name: gcp-validator image: "{{ .gcpValidatorImage }}" imagePullPolicy: Always @@ -51,7 +50,7 @@ spec: # Logging - name: LOG_LEVEL - value: "info" + value: "{{ .logLevel }}" volumeMounts: - name: results @@ -92,7 +91,7 @@ spec: - name: CONDITION_TYPE value: "Available" - name: LOG_LEVEL - value: "info" + value: "{{ .logLevel }}" - name: ADAPTER_CONTAINER_NAME value: "gcp-validator" diff --git a/charts/templates/_helpers.tpl b/charts/templates/_helpers.tpl index 350852f..1c887e7 100644 --- a/charts/templates/_helpers.tpl +++ b/charts/templates/_helpers.tpl @@ -79,11 +79,9 @@ Create the name of the broker ConfigMap to use Get the adapter config file name based on deployment mode */}} {{- define "validation-gcp.adapterConfigFile" -}} -{{- if eq .Values.deploymentMode "dummy" }} +{{- if .Values.validation.useDummy }} {{- "validation-dummy-adapter.yaml" }} -{{- else if eq .Values.deploymentMode "real" }} -{{- "validation-adapter.yaml" }} {{- else }} -{{- fail "deploymentMode must be either 'dummy' or 'real'" }} +{{- "validation-adapter.yaml" }} {{- end }} {{- end }} diff --git a/charts/templates/configmap-app.yaml b/charts/templates/configmap-app.yaml index efb1446..d215f6e 100644 --- a/charts/templates/configmap-app.yaml +++ b/charts/templates/configmap-app.yaml @@ -7,16 +7,16 @@ metadata: app.kubernetes.io/component: adapter data: # Adapter configuration file - # Dynamically loaded based on deploymentMode - # Edit charts/configs/validation-dummy-adapter.yaml or validation-gcp-adapter.yaml to customize + # Dynamically loaded based on validation.useDummy + # Edit charts/configs/validation-dummy-adapter.yaml or validation-adapter.yaml to customize adapter.yaml: | {{ .Files.Get (printf "configs/%s" (include "validation-gcp.adapterConfigFile" .)) | nindent 4 }} - {{- if eq .Values.deploymentMode "dummy" }} - # Adapter task template referenced by the adapter config + {{- if .Values.validation.useDummy }} + # Adapter task template referenced by the adapter config (dummy mode) validation-dummy-job-adapter-task.yaml: | {{ .Files.Get "configs/validation-dummy-job-adapter-task.yaml" | nindent 4 }} - {{- else if eq .Values.deploymentMode "real" }} - # Adapter task template referenced by the adapter config + {{- else }} + # Adapter task template referenced by the adapter config (real validation) validation-job-adapter-task.yaml: | {{ .Files.Get "configs/validation-job-adapter-task.yaml" | nindent 4 }} {{- end }} diff --git a/charts/templates/deployment.yaml b/charts/templates/deployment.yaml index d45b89a..879b0ae 100644 --- a/charts/templates/deployment.yaml +++ b/charts/templates/deployment.yaml @@ -80,30 +80,27 @@ spec: value: {{ .Values.broker.googlepubsub.projectId | quote }} {{- end }} {{- end }} - {{- if eq .Values.deploymentMode "dummy" }} - # Dummy validation specific environment variables + # Common validation environment variables - name: STATUS_REPORTER_IMAGE value: {{ .Values.validation.statusReporterImage | quote }} - - name: SIMULATE_RESULT - value: {{ .Values.validation.dummy.simulateResult | quote }} - name: RESULTS_PATH - value: {{ .Values.validation.dummy.resultsPath | quote }} + value: {{ .Values.validation.resultsPath | quote }} - name: MAX_WAIT_TIME_SECONDS - value: {{ .Values.validation.dummy.maxWaitTimeSeconds | quote }} - {{- else if eq .Values.deploymentMode "real" }} + value: {{ .Values.validation.maxWaitTimeSeconds | quote }} + {{- if .Values.validation.useDummy }} + # Dummy validation specific environment variables + - name: SIMULATE_RESULT + value: {{ .Values.validation.dummy.simulateResult | quote }} + {{- else }} # Real GCP validation specific environment variables - - name: STATUS_REPORTER_IMAGE - value: {{ .Values.validation.statusReporterImage | quote }} - name: GCP_VALIDATOR_IMAGE value: {{ .Values.validation.real.gcpValidatorImage | quote }} - name: DISABLED_VALIDATORS value: {{ .Values.validation.real.disabledValidators | quote }} - name: REQUIRED_APIS value: {{ .Values.validation.real.requiredApis | quote }} - - name: RESULTS_PATH - value: {{ .Values.validation.real.resultsPath | quote }} - - name: MAX_WAIT_TIME_SECONDS - value: {{ .Values.validation.real.maxWaitTimeSeconds | quote }} + - name: VALIDATOR_LOG_LEVEL + value: {{ .Values.validation.real.logLevel | default "info" | quote }} {{- end }} resources: limits: diff --git a/charts/values.yaml b/charts/values.yaml index 321b76f..ee09ede 100644 --- a/charts/values.yaml +++ b/charts/values.yaml @@ -3,11 +3,6 @@ # Only environment-specific settings are exposed here. # For advanced customization, modify the templates directly. -# Deployment mode: "dummy" for simulation, "real" for actual GCP validation -# - dummy: Uses Alpine shell script to simulate validation results -# - real: Uses Go-based validator to perform actual GCP API checks -deploymentMode: "real" # "dummy" or "real" - replicaCount: 1 image: @@ -75,19 +70,20 @@ hyperfleetApi: # Validation-specific configuration validation: - # Status reporter image (sidecar container) + # Mode selection: false = real validation (default), true = dummy/test mode + useDummy: false + + # Common configuration (used by both modes) statusReporterImage: "registry.ci.openshift.org/ci/status-reporter:latest" + resultsPath: "/results/adapter-result.json" + maxWaitTimeSeconds: "300" - # Dummy validation simulation settings (for deploymentMode: dummy) + # Dummy validation simulation settings (only when useDummy: true) dummy: # Simulated result: success, failure, hang, crash, invalid-json, missing-status simulateResult: "success" - # Path where validation results are written - resultsPath: "/results/adapter-result.json" - # Maximum time to wait for validation completion (seconds) - maxWaitTimeSeconds: "300" - # Real GCP validator settings (for deploymentMode: real) + # Real GCP validator settings (only when useDummy: false) real: # GCP validator container image gcpValidatorImage: "registry.ci.openshift.org/ci/gcp-validator:latest" @@ -97,10 +93,9 @@ validation: # Comma-separated list of required GCP APIs to validate (default: empty) # Example: "compute.googleapis.com,storage-api.googleapis.com" requiredApis: "compute.googleapis.com,iam.googleapis.com,cloudresourcemanager.googleapis.com" - # Path where validation results are written - resultsPath: "/results/adapter-result.json" - # Maximum time to wait for validation completion (seconds) - maxWaitTimeSeconds: "300" + # Log level for validation containers (sets VALIDATOR_LOG_LEVEL env var) + # Options: debug, info, warn, error + logLevel: "info" # Additional environment variables env: [] From 755abc534cf871fff391f2a7299c4226b2dfb500 Mon Sep 17 00:00:00 2001 From: dawang Date: Fri, 23 Jan 2026 11:30:32 +0800 Subject: [PATCH 08/14] Replace os.Setenv with GinkgoT().Setenv and use MAX_WAIT_TIME_SECONDS for validator --- validator/cmd/validator/main.go | 8 +- validator/pkg/config/config.go | 4 + validator/pkg/config/config_test.go | 77 ++++++++------------ validator/pkg/validator/executor_test.go | 5 +- validator/pkg/validators/api_enabled_test.go | 25 ++----- validator/pkg/validators/quota_check_test.go | 16 +--- 6 files changed, 52 insertions(+), 83 deletions(-) diff --git a/validator/cmd/validator/main.go b/validator/cmd/validator/main.go index 9e7b467..9df0a74 100644 --- a/validator/cmd/validator/main.go +++ b/validator/cmd/validator/main.go @@ -15,10 +15,6 @@ import ( _ "validator/pkg/validators" // Import to trigger init() registration ) -const ( - // Maximum time for all validators to complete - validationTimeout = 5 * time.Minute -) // main is the entry point for the GCP validator application. // It loads configuration, executes all enabled validators, aggregates results, @@ -42,7 +38,8 @@ func main() { logger.Info("Loaded configuration", "gcp_project", cfg.ProjectID, "results_path", cfg.ResultsPath, - "log_level", cfg.LogLevel) + "log_level", cfg.LogLevel, + "max_wait_time_seconds", cfg.MaxWaitTimeSeconds) // Validate disabled validators against registry if len(cfg.DisabledValidators) > 0 { @@ -63,6 +60,7 @@ func main() { } // Create context with timeout (max time for all validators) + validationTimeout := time.Duration(cfg.MaxWaitTimeSeconds) * time.Second ctx, cancel := context.WithTimeout(context.Background(), validationTimeout) defer cancel() diff --git a/validator/pkg/config/config.go b/validator/pkg/config/config.go index 0c9015c..12e590c 100644 --- a/validator/pkg/config/config.go +++ b/validator/pkg/config/config.go @@ -34,6 +34,9 @@ type Config struct { // Logging LogLevel string // debug, info, warn, error + + // Timeout + MaxWaitTimeSeconds int // Default: 300 (5 minutes), maximum time for all validators to complete } // LoadFromEnv loads configuration from environment variables @@ -49,6 +52,7 @@ func LoadFromEnv() (*Config, error) { RequiredIPAddresses: getEnvInt("REQUIRED_IP_ADDRESSES", 0), VPCName: getEnv("VPC_NAME", ""), SubnetName: getEnv("SUBNET_NAME", ""), + MaxWaitTimeSeconds: getEnvInt("MAX_WAIT_TIME_SECONDS", 300), } // Parse disabled validators diff --git a/validator/pkg/config/config_test.go b/validator/pkg/config/config_test.go index f268995..932596e 100644 --- a/validator/pkg/config/config_test.go +++ b/validator/pkg/config/config_test.go @@ -1,8 +1,6 @@ package config_test import ( - "os" - . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" @@ -10,39 +8,24 @@ import ( ) var _ = Describe("Config", func() { - var originalEnv map[string]string - BeforeEach(func() { - // Save original environment - originalEnv = make(map[string]string) + // Clear environment variables - GinkgoT().Setenv automatically restores them envVars := []string{ "RESULTS_PATH", "PROJECT_ID", "GCP_REGION", "DISABLED_VALIDATORS", "STOP_ON_FIRST_FAILURE", "REQUIRED_APIS", "LOG_LEVEL", "REQUIRED_VCPUS", "REQUIRED_DISK_GB", "REQUIRED_IP_ADDRESSES", - "VPC_NAME", "SUBNET_NAME", - } - for _, v := range envVars { - originalEnv[v] = os.Getenv(v) - Expect(os.Unsetenv(v)).To(Succeed()) + "VPC_NAME", "SUBNET_NAME", "MAX_WAIT_TIME_SECONDS", } - }) - - AfterEach(func() { - // Restore original environment - for k, v := range originalEnv { - if v != "" { - Expect(os.Setenv(k, v)).To(Succeed()) - } else { - Expect(os.Unsetenv(k)).To(Succeed()) - } + for _, key := range envVars { + GinkgoT().Setenv(key, "") } }) Describe("LoadFromEnv", func() { Context("with minimal required configuration", func() { BeforeEach(func() { - Expect(os.Setenv("PROJECT_ID", "test-project-123")).To(Succeed()) + GinkgoT().Setenv("PROJECT_ID", "test-project-123") }) It("should load config with defaults", func() { @@ -75,11 +58,11 @@ var _ = Describe("Config", func() { Context("with custom configuration", func() { BeforeEach(func() { - Expect(os.Setenv("PROJECT_ID", "custom-project")).To(Succeed()) - Expect(os.Setenv("RESULTS_PATH", "/custom/path/results.json")).To(Succeed()) - Expect(os.Setenv("GCP_REGION", "us-central1")).To(Succeed()) - Expect(os.Setenv("LOG_LEVEL", "debug")).To(Succeed()) - Expect(os.Setenv("STOP_ON_FIRST_FAILURE", "true")).To(Succeed()) + GinkgoT().Setenv("PROJECT_ID", "custom-project") + GinkgoT().Setenv("RESULTS_PATH", "/custom/path/results.json") + GinkgoT().Setenv("GCP_REGION", "us-central1") + GinkgoT().Setenv("LOG_LEVEL", "debug") + GinkgoT().Setenv("STOP_ON_FIRST_FAILURE", "true") }) It("should load all custom values", func() { @@ -95,8 +78,8 @@ var _ = Describe("Config", func() { Context("with disabled validators", func() { BeforeEach(func() { - Expect(os.Setenv("PROJECT_ID", "test-project")).To(Succeed()) - Expect(os.Setenv("DISABLED_VALIDATORS", "quota-check,network-check")).To(Succeed()) + GinkgoT().Setenv("PROJECT_ID", "test-project") + GinkgoT().Setenv("DISABLED_VALIDATORS", "quota-check,network-check") }) It("should parse the disabled validators list", func() { @@ -108,8 +91,8 @@ var _ = Describe("Config", func() { Context("with disabled validators containing whitespace", func() { BeforeEach(func() { - Expect(os.Setenv("PROJECT_ID", "test-project")).To(Succeed()) - Expect(os.Setenv("DISABLED_VALIDATORS", " quota-check , network-check ")).To(Succeed()) + GinkgoT().Setenv("PROJECT_ID", "test-project") + GinkgoT().Setenv("DISABLED_VALIDATORS", " quota-check , network-check ") }) It("should trim whitespace from validator names", func() { @@ -121,8 +104,8 @@ var _ = Describe("Config", func() { Context("with custom required APIs", func() { BeforeEach(func() { - Expect(os.Setenv("PROJECT_ID", "test-project")).To(Succeed()) - Expect(os.Setenv("REQUIRED_APIS", "compute.googleapis.com,storage.googleapis.com")).To(Succeed()) + GinkgoT().Setenv("PROJECT_ID", "test-project") + GinkgoT().Setenv("REQUIRED_APIS", "compute.googleapis.com,storage.googleapis.com") }) It("should parse the required APIs list", func() { @@ -134,10 +117,10 @@ var _ = Describe("Config", func() { Context("with integer configurations", func() { BeforeEach(func() { - Expect(os.Setenv("PROJECT_ID", "test-project")).To(Succeed()) - Expect(os.Setenv("REQUIRED_VCPUS", "100")).To(Succeed()) - Expect(os.Setenv("REQUIRED_DISK_GB", "500")).To(Succeed()) - Expect(os.Setenv("REQUIRED_IP_ADDRESSES", "10")).To(Succeed()) + GinkgoT().Setenv("PROJECT_ID", "test-project") + GinkgoT().Setenv("REQUIRED_VCPUS", "100") + GinkgoT().Setenv("REQUIRED_DISK_GB", "500") + GinkgoT().Setenv("REQUIRED_IP_ADDRESSES", "10") }) It("should parse integer values", func() { @@ -151,8 +134,8 @@ var _ = Describe("Config", func() { Context("with invalid integer values", func() { BeforeEach(func() { - Expect(os.Setenv("PROJECT_ID", "test-project")).To(Succeed()) - Expect(os.Setenv("REQUIRED_VCPUS", "not-a-number")).To(Succeed()) + GinkgoT().Setenv("PROJECT_ID", "test-project") + GinkgoT().Setenv("REQUIRED_VCPUS", "not-a-number") }) It("should use default value for invalid integers", func() { @@ -164,8 +147,8 @@ var _ = Describe("Config", func() { Context("with invalid boolean values", func() { BeforeEach(func() { - Expect(os.Setenv("PROJECT_ID", "test-project")).To(Succeed()) - Expect(os.Setenv("STOP_ON_FIRST_FAILURE", "not-a-bool")).To(Succeed()) + GinkgoT().Setenv("PROJECT_ID", "test-project") + GinkgoT().Setenv("STOP_ON_FIRST_FAILURE", "not-a-bool") }) It("should use default value for invalid booleans", func() { @@ -177,9 +160,9 @@ var _ = Describe("Config", func() { Context("with network validator config", func() { BeforeEach(func() { - Expect(os.Setenv("PROJECT_ID", "test-project")).To(Succeed()) - Expect(os.Setenv("VPC_NAME", "my-vpc")).To(Succeed()) - Expect(os.Setenv("SUBNET_NAME", "my-subnet")).To(Succeed()) + GinkgoT().Setenv("PROJECT_ID", "test-project") + GinkgoT().Setenv("VPC_NAME", "my-vpc") + GinkgoT().Setenv("SUBNET_NAME", "my-subnet") }) It("should load network configuration", func() { @@ -195,7 +178,7 @@ var _ = Describe("Config", func() { var cfg *config.Config BeforeEach(func() { - Expect(os.Setenv("PROJECT_ID", "test-project")).To(Succeed()) + GinkgoT().Setenv("PROJECT_ID", "test-project") }) Context("with no disabled list", func() { @@ -214,7 +197,7 @@ var _ = Describe("Config", func() { Context("with disabled validators list", func() { BeforeEach(func() { - Expect(os.Setenv("DISABLED_VALIDATORS", "quota-check")).To(Succeed()) + GinkgoT().Setenv("DISABLED_VALIDATORS", "quota-check") var err error cfg, err = config.LoadFromEnv() Expect(err).NotTo(HaveOccurred()) @@ -229,7 +212,7 @@ var _ = Describe("Config", func() { Context("with multiple disabled validators", func() { BeforeEach(func() { - Expect(os.Setenv("DISABLED_VALIDATORS", "quota-check,network-check")).To(Succeed()) + GinkgoT().Setenv("DISABLED_VALIDATORS", "quota-check,network-check") var err error cfg, err = config.LoadFromEnv() Expect(err).NotTo(HaveOccurred()) diff --git a/validator/pkg/validator/executor_test.go b/validator/pkg/validator/executor_test.go index f01a315..3fba50c 100644 --- a/validator/pkg/validator/executor_test.go +++ b/validator/pkg/validator/executor_test.go @@ -31,8 +31,9 @@ var _ = Describe("Executor", func() { // Clear the global registry before each test validator.ClearRegistry() - // Set up minimal config - Expect(os.Setenv("PROJECT_ID", "test-project")).To(Succeed()) + // Set up minimal config with automatic cleanup + GinkgoT().Setenv("PROJECT_ID", "test-project") + cfg, err := config.LoadFromEnv() Expect(err).NotTo(HaveOccurred()) diff --git a/validator/pkg/validators/api_enabled_test.go b/validator/pkg/validators/api_enabled_test.go index ac4e5db..05f8833 100644 --- a/validator/pkg/validators/api_enabled_test.go +++ b/validator/pkg/validators/api_enabled_test.go @@ -1,8 +1,6 @@ package validators_test import ( - "os" - . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" @@ -20,8 +18,10 @@ var _ = Describe("APIEnabledValidator", func() { BeforeEach(func() { v = &validators.APIEnabledValidator{} - // Set up minimal config - Expect(os.Setenv("PROJECT_ID", "test-project")).To(Succeed()) + // Set up minimal config with automatic cleanup + GinkgoT().Setenv("PROJECT_ID", "test-project") + GinkgoT().Setenv("REQUIRED_APIS", "") + cfg, err := config.LoadFromEnv() Expect(err).NotTo(HaveOccurred()) @@ -31,11 +31,6 @@ var _ = Describe("APIEnabledValidator", func() { } }) - AfterEach(func() { - Expect(os.Unsetenv("PROJECT_ID")).To(Succeed()) - Expect(os.Unsetenv("REQUIRED_APIS")).To(Succeed()) - }) - Describe("Metadata", func() { It("should return correct metadata", func() { meta := v.Metadata() @@ -62,16 +57,12 @@ var _ = Describe("APIEnabledValidator", func() { Context("when validator is explicitly disabled", func() { BeforeEach(func() { - Expect(os.Setenv("DISABLED_VALIDATORS", "api-enabled")).To(Succeed()) + GinkgoT().Setenv("DISABLED_VALIDATORS", "api-enabled") cfg, err := config.LoadFromEnv() Expect(err).NotTo(HaveOccurred()) vctx.Config = cfg }) - AfterEach(func() { - Expect(os.Unsetenv("DISABLED_VALIDATORS")).To(Succeed()) - }) - It("should be disabled", func() { enabled := v.Enabled(vctx) Expect(enabled).To(BeFalse()) @@ -91,7 +82,7 @@ var _ = Describe("APIEnabledValidator", func() { Context("with custom required APIs", func() { BeforeEach(func() { - Expect(os.Setenv("REQUIRED_APIS", "storage.googleapis.com,bigquery.googleapis.com")).To(Succeed()) + GinkgoT().Setenv("REQUIRED_APIS", "storage.googleapis.com,bigquery.googleapis.com") cfg, err := config.LoadFromEnv() Expect(err).NotTo(HaveOccurred()) vctx.Config = cfg @@ -107,7 +98,7 @@ var _ = Describe("APIEnabledValidator", func() { Context("with APIs containing whitespace", func() { BeforeEach(func() { - Expect(os.Setenv("REQUIRED_APIS", " storage.googleapis.com , bigquery.googleapis.com ")).To(Succeed()) + GinkgoT().Setenv("REQUIRED_APIS", " storage.googleapis.com , bigquery.googleapis.com ") cfg, err := config.LoadFromEnv() Expect(err).NotTo(HaveOccurred()) vctx.Config = cfg @@ -129,7 +120,7 @@ var _ = Describe("APIEnabledValidator", func() { Context("with different project ID", func() { BeforeEach(func() { - Expect(os.Setenv("PROJECT_ID", "production-project-456")).To(Succeed()) + GinkgoT().Setenv("PROJECT_ID", "production-project-456") cfg, err := config.LoadFromEnv() Expect(err).NotTo(HaveOccurred()) vctx.Config = cfg diff --git a/validator/pkg/validators/quota_check_test.go b/validator/pkg/validators/quota_check_test.go index 3700003..2f6198f 100644 --- a/validator/pkg/validators/quota_check_test.go +++ b/validator/pkg/validators/quota_check_test.go @@ -2,7 +2,6 @@ package validators_test import ( "context" - "os" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" @@ -21,8 +20,9 @@ var _ = Describe("QuotaCheckValidator", func() { BeforeEach(func() { v = &validators.QuotaCheckValidator{} - // Set up minimal config - Expect(os.Setenv("PROJECT_ID", "test-project")).To(Succeed()) + // Set up minimal config with automatic cleanup + GinkgoT().Setenv("PROJECT_ID", "test-project") + cfg, err := config.LoadFromEnv() Expect(err).NotTo(HaveOccurred()) @@ -32,10 +32,6 @@ var _ = Describe("QuotaCheckValidator", func() { } }) - AfterEach(func() { - Expect(os.Unsetenv("PROJECT_ID")).To(Succeed()) - }) - Describe("Metadata", func() { It("should return correct metadata", func() { meta := v.Metadata() @@ -64,16 +60,12 @@ var _ = Describe("QuotaCheckValidator", func() { Context("when validator is explicitly disabled", func() { BeforeEach(func() { - Expect(os.Setenv("DISABLED_VALIDATORS", "quota-check")).To(Succeed()) + GinkgoT().Setenv("DISABLED_VALIDATORS", "quota-check") cfg, err := config.LoadFromEnv() Expect(err).NotTo(HaveOccurred()) vctx.Config = cfg }) - AfterEach(func() { - Expect(os.Unsetenv("DISABLED_VALIDATORS")).To(Succeed()) - }) - It("should be disabled", func() { enabled := v.Enabled(vctx) Expect(enabled).To(BeFalse()) From 55d5c43d50c2572375a72eacbea3e9b90e36b2d2 Mon Sep 17 00:00:00 2001 From: dawang Date: Fri, 23 Jan 2026 16:29:07 +0800 Subject: [PATCH 09/14] Support lazy initialization for gcp client, add integration tests and support Mermaid flowchart showing dependency --- validator/Makefile | 27 ++ validator/cmd/validator/main.go | 8 +- validator/pkg/validator/context.go | 107 ++++++- validator/pkg/validator/context_test.go | 239 ++++++++++++++ validator/pkg/validator/executor.go | 5 + validator/pkg/validator/executor_test.go | 16 +- validator/pkg/validator/resolver.go | 72 +++++ validator/pkg/validator/resolver_test.go | 173 +++++++++++ validator/pkg/validators/api_enabled.go | 11 +- validator/pkg/validators/api_enabled_test.go | 12 +- validator/pkg/validators/quota_check.go | 6 +- validator/pkg/validators/quota_check_test.go | 11 +- validator/test/integration/README.md | 49 +++ .../integration/context_integration_test.go | 272 ++++++++++++++++ validator/test/integration/suite_test.go | 16 + .../integration/validator_integration_test.go | 292 ++++++++++++++++++ 16 files changed, 1275 insertions(+), 41 deletions(-) create mode 100644 validator/pkg/validator/context_test.go create mode 100644 validator/test/integration/README.md create mode 100644 validator/test/integration/context_integration_test.go create mode 100644 validator/test/integration/suite_test.go create mode 100644 validator/test/integration/validator_integration_test.go diff --git a/validator/Makefile b/validator/Makefile index b2a61ae..2c1d154 100644 --- a/validator/Makefile +++ b/validator/Makefile @@ -86,6 +86,33 @@ test-coverage-html: test-coverage ## Generate HTML coverage report $(GOCMD) tool cover -html=$(COVERAGE_OUT) -o $(COVERAGE_HTML) @echo "HTML coverage report generated: $(COVERAGE_HTML)" +.PHONY: test-integration +test-integration: ## Run integration tests (requires GCP credentials and PROJECT_ID) + @echo "Running integration tests..." + @if [ -z "$$PROJECT_ID" ]; then \ + echo "❌ ERROR: PROJECT_ID environment variable is not set"; \ + echo ""; \ + echo "Integration tests require a real GCP project."; \ + echo "Please set PROJECT_ID:"; \ + echo " export PROJECT_ID=your-gcp-project-id"; \ + echo ""; \ + echo "You also need valid GCP credentials:"; \ + echo " gcloud auth application-default login"; \ + echo " OR"; \ + echo " export GOOGLE_APPLICATION_CREDENTIALS=/path/to/key.json"; \ + echo ""; \ + exit 1; \ + fi + @if [ -d "test/integration" ] && [ -n "$$(find test/integration -name '*_test.go' 2>/dev/null)" ]; then \ + echo "PROJECT_ID: $$PROJECT_ID"; \ + echo "Running tests with -tags=integration..."; \ + $(GOTEST) -tags=integration -v $(RACE_FLAG) -timeout $(TEST_TIMEOUT) ./test/integration/...; \ + else \ + echo "No integration tests found in test/integration directory"; \ + echo "Please add integration tests to test/integration directory"; \ + exit 0; \ + fi + .PHONY: lint lint: ## Run golangci-lint @echo "Running golangci-lint..." diff --git a/validator/cmd/validator/main.go b/validator/cmd/validator/main.go index 9df0a74..03e8ff1 100644 --- a/validator/cmd/validator/main.go +++ b/validator/cmd/validator/main.go @@ -53,11 +53,9 @@ func main() { } } - // Create validation context - vctx := &validator.Context{ - Config: cfg, - Results: make(map[string]*validator.Result), - } + // Create validation context with lazy client initialization + // Services will only be created when validators actually need them (least privilege) + vctx := validator.NewContext(cfg, logger) // Create context with timeout (max time for all validators) validationTimeout := time.Duration(cfg.MaxWaitTimeSeconds) * time.Second diff --git a/validator/pkg/validator/context.go b/validator/pkg/validator/context.go index 56cffd7..59a9758 100644 --- a/validator/pkg/validator/context.go +++ b/validator/pkg/validator/context.go @@ -1,26 +1,39 @@ package validator import ( - compute "google.golang.org/api/compute/v1" - iam "google.golang.org/api/iam/v1" - cloudresourcemanager "google.golang.org/api/cloudresourcemanager/v1" - serviceusage "google.golang.org/api/serviceusage/v1" - monitoring "google.golang.org/api/monitoring/v3" + "context" + "fmt" + "log/slog" + + "google.golang.org/api/cloudresourcemanager/v1" + "google.golang.org/api/compute/v1" + "google.golang.org/api/iam/v1" + "google.golang.org/api/monitoring/v3" + "google.golang.org/api/serviceusage/v1" "validator/pkg/config" + "validator/pkg/gcp" ) // Context provides shared resources and configuration to all validators +// Implements least-privilege principle through lazy initialization: +// - Services are only created when first requested by validators +// - OAuth scopes are only requested for services that are actually used +// - Disabled validators never trigger authentication for their services type Context struct { // Configuration Config *config.Config + // Client factory for creating GCP service clients + clientFactory *gcp.ClientFactory + // GCP Clients (lazily initialized, shared across validators) - ComputeService *compute.Service - IAMService *iam.Service - CloudResourceManagerSvc *cloudresourcemanager.Service - ServiceUsageService *serviceusage.Service - MonitoringService *monitoring.Service + // These are private to enforce use of getter methods + computeService *compute.Service + iamService *iam.Service + cloudResourceManagerSvc *cloudresourcemanager.Service + serviceUsageService *serviceusage.Service + monitoringService *monitoring.Service // Shared state between validators ProjectNumber int64 @@ -28,3 +41,77 @@ type Context struct { // Results from previous validators (for dependency checking) Results map[string]*Result } + +// NewContext creates a new validation context with a client factory +func NewContext(cfg *config.Config, logger *slog.Logger) *Context { + return &Context{ + Config: cfg, + clientFactory: gcp.NewClientFactory(cfg.ProjectID, logger), + Results: make(map[string]*Result), + } +} + +// GetComputeService returns the Compute Engine service, creating it lazily on first use +// Only requests compute.readonly scope when a validator actually needs it +func (c *Context) GetComputeService(ctx context.Context) (*compute.Service, error) { + if c.computeService == nil { + svc, err := c.clientFactory.CreateComputeService(ctx) + if err != nil { + return nil, fmt.Errorf("failed to create compute service: %w", err) + } + c.computeService = svc + } + return c.computeService, nil +} + +// GetIAMService returns the IAM service, creating it lazily on first use +// Only requests cloud-platform.read-only scope when a validator actually needs it +func (c *Context) GetIAMService(ctx context.Context) (*iam.Service, error) { + if c.iamService == nil { + svc, err := c.clientFactory.CreateIAMService(ctx) + if err != nil { + return nil, fmt.Errorf("failed to create IAM service: %w", err) + } + c.iamService = svc + } + return c.iamService, nil +} + +// GetCloudResourceManagerService returns the Cloud Resource Manager service, creating it lazily on first use +// Only requests cloudresourcemanager.readonly scope when a validator actually needs it +func (c *Context) GetCloudResourceManagerService(ctx context.Context) (*cloudresourcemanager.Service, error) { + if c.cloudResourceManagerSvc == nil { + svc, err := c.clientFactory.CreateCloudResourceManagerService(ctx) + if err != nil { + return nil, fmt.Errorf("failed to create cloud resource manager service: %w", err) + } + c.cloudResourceManagerSvc = svc + } + return c.cloudResourceManagerSvc, nil +} + +// GetServiceUsageService returns the Service Usage service, creating it lazily on first use +// Only requests serviceusage.readonly scope when a validator actually needs it +func (c *Context) GetServiceUsageService(ctx context.Context) (*serviceusage.Service, error) { + if c.serviceUsageService == nil { + svc, err := c.clientFactory.CreateServiceUsageService(ctx) + if err != nil { + return nil, fmt.Errorf("failed to create service usage service: %w", err) + } + c.serviceUsageService = svc + } + return c.serviceUsageService, nil +} + +// GetMonitoringService returns the Monitoring service, creating it lazily on first use +// Only requests monitoring.read scope when a validator actually needs it +func (c *Context) GetMonitoringService(ctx context.Context) (*monitoring.Service, error) { + if c.monitoringService == nil { + svc, err := c.clientFactory.CreateMonitoringService(ctx) + if err != nil { + return nil, fmt.Errorf("failed to create monitoring service: %w", err) + } + c.monitoringService = svc + } + return c.monitoringService, nil +} diff --git a/validator/pkg/validator/context_test.go b/validator/pkg/validator/context_test.go new file mode 100644 index 0000000..d8fa4a6 --- /dev/null +++ b/validator/pkg/validator/context_test.go @@ -0,0 +1,239 @@ +package validator_test + +import ( + "context" + "log/slog" + "os" + "sync" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "validator/pkg/config" + "validator/pkg/validator" +) + +var _ = Describe("Context", func() { + var ( + cfg *config.Config + logger *slog.Logger + vctx *validator.Context + ) + + BeforeEach(func() { + // Set up minimal config with automatic cleanup + GinkgoT().Setenv("PROJECT_ID", "test-project-lazy-init") + + var err error + cfg, err = config.LoadFromEnv() + Expect(err).NotTo(HaveOccurred()) + + logger = slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{ + Level: slog.LevelWarn, + })) + }) + + Describe("NewContext", func() { + Context("with valid configuration", func() { + It("should create a new context with proper initialization", func() { + vctx = validator.NewContext(cfg, logger) + + Expect(vctx).NotTo(BeNil()) + Expect(vctx.Config).To(Equal(cfg)) + Expect(vctx.Results).NotTo(BeNil()) + Expect(vctx.Results).To(BeEmpty()) + }) + + It("should initialize with correct project ID", func() { + vctx = validator.NewContext(cfg, logger) + + Expect(vctx.Config.ProjectID).To(Equal("test-project-lazy-init")) + }) + + It("should create Results map ready for use", func() { + vctx = validator.NewContext(cfg, logger) + + // Should be able to add results without nil pointer panic + vctx.Results["test"] = &validator.Result{ + ValidatorName: "test", + Status: validator.StatusSuccess, + } + Expect(vctx.Results).To(HaveKey("test")) + }) + }) + + Context("with different configurations", func() { + It("should handle different project IDs", func() { + GinkgoT().Setenv("PROJECT_ID", "production-123") + cfg2, err := config.LoadFromEnv() + Expect(err).NotTo(HaveOccurred()) + + vctx = validator.NewContext(cfg2, logger) + Expect(vctx.Config.ProjectID).To(Equal("production-123")) + }) + }) + }) + + Describe("Lazy Initialization - Least Privilege Guarantee", func() { + BeforeEach(func() { + vctx = validator.NewContext(cfg, logger) + }) + + Context("GetServiceUsageService", func() { + It("should create service on first call", func() { + ctx := context.Background() + + // First call should create the service + svc1, err := vctx.GetServiceUsageService(ctx) + + // Note: This will fail without valid GCP credentials + // For unit tests, we expect an error but verify the method works + if err != nil { + // Expected in test environment without GCP credentials + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(Or( + ContainSubstring("could not find default credentials"), + ContainSubstring("ADC"), + ContainSubstring("GOOGLE_APPLICATION_CREDENTIALS"), + )) + } else { + // If credentials exist (e.g., in CI with WIF), verify service is created + Expect(svc1).NotTo(BeNil()) + } + }) + + }) + + Context("GetComputeService", func() { + It("should handle missing credentials gracefully", func() { + ctx := context.Background() + + svc, err := vctx.GetComputeService(ctx) + + if err != nil { + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("failed to create compute service")) + } else { + Expect(svc).NotTo(BeNil()) + } + }) + }) + + Context("GetIAMService", func() { + It("should handle missing credentials gracefully", func() { + ctx := context.Background() + + svc, err := vctx.GetIAMService(ctx) + + if err != nil { + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("failed to create IAM service")) + } else { + Expect(svc).NotTo(BeNil()) + } + }) + }) + + Context("GetCloudResourceManagerService", func() { + It("should handle missing credentials gracefully", func() { + ctx := context.Background() + + svc, err := vctx.GetCloudResourceManagerService(ctx) + + if err != nil { + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("failed to create cloud resource manager service")) + } else { + Expect(svc).NotTo(BeNil()) + } + }) + }) + + Context("GetMonitoringService", func() { + It("should handle missing credentials gracefully", func() { + ctx := context.Background() + + svc, err := vctx.GetMonitoringService(ctx) + + if err != nil { + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("failed to create monitoring service")) + } else { + Expect(svc).NotTo(BeNil()) + } + }) + }) + }) + + Describe("Context Cancellation", func() { + BeforeEach(func() { + vctx = validator.NewContext(cfg, logger) + }) + + + It("should not panic with cancelled context", func() { + ctx, cancel := context.WithCancel(context.Background()) + cancel() // Cancel immediately + + // Should not panic, even if it doesn't check context + Expect(func() { + _, _ = vctx.GetServiceUsageService(ctx) + }).NotTo(Panic()) + }) + }) + + Describe("Thread Safety", func() { + BeforeEach(func() { + vctx = validator.NewContext(cfg, logger) + }) + + + It("should handle concurrent access to different getters safely", func() { + ctx := context.Background() + var wg sync.WaitGroup + + // Launch multiple goroutines calling different getters + getters := []func(context.Context) (interface{}, error){ + func(ctx context.Context) (interface{}, error) { return vctx.GetComputeService(ctx) }, + func(ctx context.Context) (interface{}, error) { return vctx.GetIAMService(ctx) }, + func(ctx context.Context) (interface{}, error) { return vctx.GetServiceUsageService(ctx) }, + func(ctx context.Context) (interface{}, error) { return vctx.GetMonitoringService(ctx) }, + } + + for _, getter := range getters { + wg.Add(1) + go func(g func(context.Context) (interface{}, error)) { + defer GinkgoRecover() + defer wg.Done() + _, _ = g(ctx) + // Don't check error - just verify no race conditions/panics + }(getter) + } + + // Should complete without race conditions or panics + wg.Wait() + }) + }) + + Describe("Shared State", func() { + BeforeEach(func() { + vctx = validator.NewContext(cfg, logger) + }) + + It("should maintain ProjectNumber across operations", func() { + vctx.ProjectNumber = 12345678 + + Expect(vctx.ProjectNumber).To(Equal(int64(12345678))) + }) + + It("should maintain Results map across operations", func() { + vctx.Results["validator-1"] = &validator.Result{ + ValidatorName: "validator-1", + Status: validator.StatusSuccess, + } + + Expect(vctx.Results).To(HaveLen(1)) + Expect(vctx.Results["validator-1"].Status).To(Equal(validator.StatusSuccess)) + }) + }) +}) diff --git a/validator/pkg/validator/executor.go b/validator/pkg/validator/executor.go index 2427505..c346566 100644 --- a/validator/pkg/validator/executor.go +++ b/validator/pkg/validator/executor.go @@ -54,6 +54,11 @@ func (e *Executor) ExecuteAll(ctx context.Context) ([]*Result, error) { } e.logger.Info("Execution plan created", "groups", len(groups)) + + // Log dependency graphs + e.logger.Debug("Validator dependency graph (raw dependencies):\n" + resolver.ToMermaid()) + e.logger.Info("Validator execution plan (with levels):\n" + resolver.ToMermaidWithLevels(groups)) + for _, group := range groups { e.logger.Debug("Execution group", "level", group.Level, diff --git a/validator/pkg/validator/executor_test.go b/validator/pkg/validator/executor_test.go index 3fba50c..ed6ad67 100644 --- a/validator/pkg/validator/executor_test.go +++ b/validator/pkg/validator/executor_test.go @@ -37,10 +37,8 @@ var _ = Describe("Executor", func() { cfg, err := config.LoadFromEnv() Expect(err).NotTo(HaveOccurred()) - vctx = &validator.Context{ - Config: cfg, - Results: make(map[string]*validator.Result), - } + // Use NewContext constructor for proper initialization + vctx = validator.NewContext(cfg, logger) }) Describe("ExecuteAll", func() { @@ -138,16 +136,16 @@ var _ = Describe("Executor", func() { } }) - It("should execute all validators in parallel", func() { + It("should execute all independent validators successfully", func() { executor = validator.NewExecutor(vctx, logger) - start := time.Now() results, err := executor.ExecuteAll(ctx) - duration := time.Since(start) Expect(err).NotTo(HaveOccurred()) Expect(results).To(HaveLen(3)) - // Parallel execution should take ~10ms, not ~30ms (sequential) - Expect(duration).To(BeNumerically("<", 100*time.Millisecond)) + // All validators should complete successfully + for _, result := range results { + Expect(result.Status).To(Equal(validator.StatusSuccess)) + } }) It("should store all results in context", func() { diff --git a/validator/pkg/validator/resolver.go b/validator/pkg/validator/resolver.go index 6e66c10..6cbaeeb 100644 --- a/validator/pkg/validator/resolver.go +++ b/validator/pkg/validator/resolver.go @@ -146,3 +146,75 @@ func (r *DependencyResolver) detectCycles() error { return nil } + +// ToMermaid generates a Mermaid flowchart showing raw dependency relationships +// This visualization shows which validators depend on others based on their RunAfter declarations +func (r *DependencyResolver) ToMermaid() string { + var result string + result += "flowchart TD\n" + + // Collect all validators to ensure orphans are shown + allValidators := make(map[string]bool) + for name := range r.validators { + allValidators[name] = true + } + + // Track which validators have dependencies + hasDependencies := make(map[string]bool) + + // Add edges for all dependencies + for name, v := range r.validators { + meta := v.Metadata() + if len(meta.RunAfter) > 0 { + hasDependencies[name] = true + for _, dep := range meta.RunAfter { + // Only show edge if dependency exists in our validator set + if _, exists := r.validators[dep]; exists { + result += fmt.Sprintf(" %s --> %s\n", name, dep) + } + } + } + } + + // Add standalone nodes (validators with no dependencies) + for name := range allValidators { + if !hasDependencies[name] { + result += fmt.Sprintf(" %s\n", name) + } + } + + return result +} + +// ToMermaidWithLevels generates a Mermaid flowchart showing the execution plan with levels +// Each level is rendered as a subgraph showing which validators run in parallel +func (r *DependencyResolver) ToMermaidWithLevels(groups []ExecutionGroup) string { + var result string + result += "flowchart TD\n" + + // Create subgraphs for each level + for _, group := range groups { + parallelInfo := "" + if len(group.Validators) > 1 { + parallelInfo = fmt.Sprintf(" - %d Validators in Parallel", len(group.Validators)) + } + result += fmt.Sprintf(" subgraph \"Level %d%s\"\n", group.Level, parallelInfo) + for _, v := range group.Validators { + meta := v.Metadata() + result += fmt.Sprintf(" %s\n", meta.Name) + } + result += " end\n\n" + } + + // Add dependency edges + for _, v := range r.validators { + meta := v.Metadata() + for _, dep := range meta.RunAfter { + if _, exists := r.validators[dep]; exists { + result += fmt.Sprintf(" %s --> %s\n", meta.Name, dep) + } + } + } + + return result +} diff --git a/validator/pkg/validator/resolver_test.go b/validator/pkg/validator/resolver_test.go index 2f72c0b..3ec7bf6 100644 --- a/validator/pkg/validator/resolver_test.go +++ b/validator/pkg/validator/resolver_test.go @@ -272,4 +272,177 @@ var _ = Describe("DependencyResolver", func() { }) }) }) + + Describe("ToMermaid", func() { + Context("with validators that have no dependencies", func() { + BeforeEach(func() { + validators = []validator.Validator{ + &MockValidator{name: "validator-a", runAfter: []string{}, enabled: true}, + &MockValidator{name: "validator-b", runAfter: []string{}, enabled: true}, + } + resolver = validator.NewDependencyResolver(validators) + }) + + It("should render standalone nodes", func() { + mermaid := resolver.ToMermaid() + Expect(mermaid).To(ContainSubstring("flowchart TD")) + Expect(mermaid).To(ContainSubstring("validator-a")) + Expect(mermaid).To(ContainSubstring("validator-b")) + Expect(mermaid).NotTo(ContainSubstring("-->")) + }) + }) + + Context("with linear dependencies", func() { + BeforeEach(func() { + validators = []validator.Validator{ + &MockValidator{name: "validator-a", runAfter: []string{}, enabled: true}, + &MockValidator{name: "validator-b", runAfter: []string{"validator-a"}, enabled: true}, + &MockValidator{name: "validator-c", runAfter: []string{"validator-b"}, enabled: true}, + } + resolver = validator.NewDependencyResolver(validators) + }) + + It("should render dependency arrows", func() { + mermaid := resolver.ToMermaid() + Expect(mermaid).To(ContainSubstring("flowchart TD")) + Expect(mermaid).To(ContainSubstring("validator-b --> validator-a")) + Expect(mermaid).To(ContainSubstring("validator-c --> validator-b")) + }) + }) + + Context("with complex dependencies", func() { + BeforeEach(func() { + validators = []validator.Validator{ + &MockValidator{name: "wif-check", runAfter: []string{}, enabled: true}, + &MockValidator{name: "api-enabled", runAfter: []string{"wif-check"}, enabled: true}, + &MockValidator{name: "quota-check", runAfter: []string{"wif-check"}, enabled: true}, + &MockValidator{name: "network-check", runAfter: []string{"api-enabled", "quota-check"}, enabled: true}, + } + resolver = validator.NewDependencyResolver(validators) + }) + + It("should render all dependency relationships", func() { + mermaid := resolver.ToMermaid() + Expect(mermaid).To(ContainSubstring("flowchart TD")) + Expect(mermaid).To(ContainSubstring("api-enabled --> wif-check")) + Expect(mermaid).To(ContainSubstring("quota-check --> wif-check")) + Expect(mermaid).To(ContainSubstring("network-check --> api-enabled")) + Expect(mermaid).To(ContainSubstring("network-check --> quota-check")) + }) + }) + + Context("with missing dependency", func() { + BeforeEach(func() { + validators = []validator.Validator{ + &MockValidator{name: "validator-a", runAfter: []string{"non-existent"}, enabled: true}, + } + resolver = validator.NewDependencyResolver(validators) + }) + + It("should not render edges for missing dependencies", func() { + mermaid := resolver.ToMermaid() + Expect(mermaid).To(ContainSubstring("flowchart TD")) + Expect(mermaid).NotTo(ContainSubstring("-->")) + Expect(mermaid).NotTo(ContainSubstring("non-existent")) + }) + }) + }) + + Describe("ToMermaidWithLevels", func() { + Context("with validators that have no dependencies", func() { + BeforeEach(func() { + validators = []validator.Validator{ + &MockValidator{name: "validator-a", runAfter: []string{}, enabled: true}, + &MockValidator{name: "validator-b", runAfter: []string{}, enabled: true}, + } + resolver = validator.NewDependencyResolver(validators) + }) + + It("should render all validators in Level 0 subgraph", func() { + groups, _ := resolver.ResolveExecutionGroups() + mermaid := resolver.ToMermaidWithLevels(groups) + + Expect(mermaid).To(ContainSubstring("flowchart TD")) + Expect(mermaid).To(ContainSubstring("subgraph \"Level 0 - 2 Validators in Parallel\"")) + Expect(mermaid).To(ContainSubstring("validator-a")) + Expect(mermaid).To(ContainSubstring("validator-b")) + }) + }) + + Context("with linear dependencies", func() { + BeforeEach(func() { + validators = []validator.Validator{ + &MockValidator{name: "validator-a", runAfter: []string{}, enabled: true}, + &MockValidator{name: "validator-b", runAfter: []string{"validator-a"}, enabled: true}, + &MockValidator{name: "validator-c", runAfter: []string{"validator-b"}, enabled: true}, + } + resolver = validator.NewDependencyResolver(validators) + }) + + It("should render separate levels with dependency arrows", func() { + groups, _ := resolver.ResolveExecutionGroups() + mermaid := resolver.ToMermaidWithLevels(groups) + + Expect(mermaid).To(ContainSubstring("flowchart TD")) + Expect(mermaid).To(ContainSubstring("subgraph \"Level 0\"")) + Expect(mermaid).To(ContainSubstring("subgraph \"Level 1\"")) + Expect(mermaid).To(ContainSubstring("subgraph \"Level 2\"")) + Expect(mermaid).To(ContainSubstring("validator-b --> validator-a")) + Expect(mermaid).To(ContainSubstring("validator-c --> validator-b")) + }) + }) + + Context("with parallel dependencies", func() { + BeforeEach(func() { + validators = []validator.Validator{ + &MockValidator{name: "wif-check", runAfter: []string{}, enabled: true}, + &MockValidator{name: "api-enabled", runAfter: []string{"wif-check"}, enabled: true}, + &MockValidator{name: "quota-check", runAfter: []string{"wif-check"}, enabled: true}, + &MockValidator{name: "network-check", runAfter: []string{"wif-check"}, enabled: true}, + } + resolver = validator.NewDependencyResolver(validators) + }) + + It("should show parallel validators in the same level", func() { + groups, _ := resolver.ResolveExecutionGroups() + mermaid := resolver.ToMermaidWithLevels(groups) + + Expect(mermaid).To(ContainSubstring("flowchart TD")) + Expect(mermaid).To(ContainSubstring("subgraph \"Level 0\"")) + Expect(mermaid).To(ContainSubstring("subgraph \"Level 1 - 3 Validators in Parallel\"")) + Expect(mermaid).To(ContainSubstring("wif-check")) + Expect(mermaid).To(ContainSubstring("api-enabled")) + Expect(mermaid).To(ContainSubstring("quota-check")) + Expect(mermaid).To(ContainSubstring("network-check")) + }) + }) + + Context("with complex dependency graph", func() { + BeforeEach(func() { + validators = []validator.Validator{ + &MockValidator{name: "wif-check", runAfter: []string{}, enabled: true}, + &MockValidator{name: "api-enabled", runAfter: []string{"wif-check"}, enabled: true}, + &MockValidator{name: "quota-check", runAfter: []string{"wif-check"}, enabled: true}, + &MockValidator{name: "iam-check", runAfter: []string{"api-enabled"}, enabled: true}, + &MockValidator{name: "network-check", runAfter: []string{"api-enabled", "quota-check"}, enabled: true}, + } + resolver = validator.NewDependencyResolver(validators) + }) + + It("should render correct levels and all dependency edges", func() { + groups, _ := resolver.ResolveExecutionGroups() + mermaid := resolver.ToMermaidWithLevels(groups) + + Expect(mermaid).To(ContainSubstring("flowchart TD")) + Expect(mermaid).To(ContainSubstring("subgraph \"Level 0\"")) + Expect(mermaid).To(ContainSubstring("subgraph \"Level 1 - 2 Validators in Parallel\"")) + Expect(mermaid).To(ContainSubstring("subgraph \"Level 2 - 2 Validators in Parallel\"")) + Expect(mermaid).To(ContainSubstring("api-enabled --> wif-check")) + Expect(mermaid).To(ContainSubstring("quota-check --> wif-check")) + Expect(mermaid).To(ContainSubstring("iam-check --> api-enabled")) + Expect(mermaid).To(ContainSubstring("network-check --> api-enabled")) + Expect(mermaid).To(ContainSubstring("network-check --> quota-check")) + }) + }) + }) }) diff --git a/validator/pkg/validators/api_enabled.go b/validator/pkg/validators/api_enabled.go index 248ad0f..2850c83 100644 --- a/validator/pkg/validators/api_enabled.go +++ b/validator/pkg/validators/api_enabled.go @@ -8,7 +8,6 @@ import ( "time" "google.golang.org/api/googleapi" - "validator/pkg/gcp" "validator/pkg/validator" ) @@ -72,12 +71,12 @@ func (v *APIEnabledValidator) Validate(ctx context.Context, vctx *validator.Cont ctx, cancel := context.WithTimeout(ctx, apiValidationTimeout) defer cancel() - // Create Service Usage client (uses WIF implicitly) - factory := gcp.NewClientFactory(vctx.Config.ProjectID, slog.Default()) - svc, err := factory.CreateServiceUsageService(ctx) + // Get Service Usage client from context (lazy initialization with least privilege) + // Only requests serviceusage.readonly scope when this validator actually runs + svc, err := vctx.GetServiceUsageService(ctx) if err != nil { // Log full error for debugging - slog.Error("Failed to create Service Usage client", + slog.Error("Failed to get Service Usage client", "error", err.Error(), "project_id", vctx.Config.ProjectID) @@ -87,7 +86,7 @@ func (v *APIEnabledValidator) Validate(ctx context.Context, vctx *validator.Cont return &validator.Result{ Status: validator.StatusFailure, Reason: reason, - Message: fmt.Sprintf("Failed to create Service Usage client (check WIF configuration): %v", err), + Message: fmt.Sprintf("Failed to get Service Usage client (check WIF configuration): %v", err), Details: map[string]interface{}{ //"error": err.Error(), "error_type": fmt.Sprintf("%T", err), diff --git a/validator/pkg/validators/api_enabled_test.go b/validator/pkg/validators/api_enabled_test.go index 05f8833..15d96c5 100644 --- a/validator/pkg/validators/api_enabled_test.go +++ b/validator/pkg/validators/api_enabled_test.go @@ -1,6 +1,9 @@ package validators_test import ( + "log/slog" + "os" + . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" @@ -25,10 +28,11 @@ var _ = Describe("APIEnabledValidator", func() { cfg, err := config.LoadFromEnv() Expect(err).NotTo(HaveOccurred()) - vctx = &validator.Context{ - Config: cfg, - Results: make(map[string]*validator.Result), - } + // Use NewContext constructor for proper initialization + logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{ + Level: slog.LevelWarn, + })) + vctx = validator.NewContext(cfg, logger) }) Describe("Metadata", func() { diff --git a/validator/pkg/validators/quota_check.go b/validator/pkg/validators/quota_check.go index d6d4655..d265add 100644 --- a/validator/pkg/validators/quota_check.go +++ b/validator/pkg/validators/quota_check.go @@ -43,13 +43,13 @@ func (v *QuotaCheckValidator) Validate(ctx context.Context, vctx *validator.Cont // // Example implementation structure: // - // factory := gcp.NewClientFactory(vctx.Config.ProjectID, slog.Default()) - // computeSvc, err := factory.CreateComputeService(ctx) + // // Get Compute service from context (lazy initialization with least privilege) + // computeSvc, err := vctx.GetComputeService(ctx) // if err != nil { // return &validator.Result{ // Status: validator.StatusFailure, // Reason: "ComputeClientError", - // Message: fmt.Sprintf("Failed to create Compute client: %v", err), + // Message: fmt.Sprintf("Failed to get Compute client: %v", err), // } // } // diff --git a/validator/pkg/validators/quota_check_test.go b/validator/pkg/validators/quota_check_test.go index 2f6198f..92418ef 100644 --- a/validator/pkg/validators/quota_check_test.go +++ b/validator/pkg/validators/quota_check_test.go @@ -2,6 +2,8 @@ package validators_test import ( "context" + "log/slog" + "os" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" @@ -26,10 +28,11 @@ var _ = Describe("QuotaCheckValidator", func() { cfg, err := config.LoadFromEnv() Expect(err).NotTo(HaveOccurred()) - vctx = &validator.Context{ - Config: cfg, - Results: make(map[string]*validator.Result), - } + // Use NewContext constructor for proper initialization + logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{ + Level: slog.LevelWarn, + })) + vctx = validator.NewContext(cfg, logger) }) Describe("Metadata", func() { diff --git a/validator/test/integration/README.md b/validator/test/integration/README.md new file mode 100644 index 0000000..4a3b9bb --- /dev/null +++ b/validator/test/integration/README.md @@ -0,0 +1,49 @@ +# Integration Tests + +This directory contains **real integration tests** that interact with actual GCP APIs to validate the validator implementation. + +## ⚠️ Requirements + +These tests **require**: + +1. **Real GCP Project** - with a valid PROJECT_ID +2. **Valid GCP Authentication** - one of: + - Workload Identity Federation (WIF) in Kubernetes + - Service Account key file + - Application Default Credentials (ADC) via `gcloud auth` +3. **Network Access** - to GCP APIs (*.googleapis.com) +4. **IAM Permissions** - on the target GCP project: + - `serviceusage.services.get` (Service Usage Viewer) + - `resourcemanager.projects.get` (Project Viewer) + - `compute.projects.get` (Compute Viewer) + - `iam.roles.get` (IAM Role Viewer) + +## 🚀 Running Tests Locally + +### Step 1: Authenticate with GCP + +```bash +# Option A: Use your user credentials (recommended for local dev) +gcloud auth application-default login + +# Option B: Use a service account key file +export GOOGLE_APPLICATION_CREDENTIALS="/path/to/service-account-key.json" +``` + +### Step 2: Set Required Environment Variables + +```bash +export PROJECT_ID="your-gcp-project-id" + +# Optional: Customize API list (defaults are provided) +export REQUIRED_APIS="compute.googleapis.com,iam.googleapis.com,cloudresourcemanager.googleapis.com" + +# Optional: Set log level +export LOG_LEVEL="info" +``` + +### Step 3: Run Integration Tests + +```bash +make test-integration +``` diff --git a/validator/test/integration/context_integration_test.go b/validator/test/integration/context_integration_test.go new file mode 100644 index 0000000..9b238e4 --- /dev/null +++ b/validator/test/integration/context_integration_test.go @@ -0,0 +1,272 @@ +//go:build integration +// +build integration + +package integration_test + +import ( + "context" + "log/slog" + "os" + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "validator/pkg/config" + "validator/pkg/validator" +) + +var _ = Describe("Context Integration Tests", func() { + var ( + ctx context.Context + cancel context.CancelFunc + vctx *validator.Context + cfg *config.Config + logger *slog.Logger + ) + + BeforeEach(func() { + // Create context with reasonable timeout for integration tests + ctx, cancel = context.WithTimeout(context.Background(), 30*time.Second) + + // Set up logger + logger = slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{ + Level: slog.LevelInfo, + })) + + // Load configuration from environment + // Requires: PROJECT_ID environment variable + var err error + cfg, err = config.LoadFromEnv() + Expect(err).NotTo(HaveOccurred(), "Failed to load config - ensure PROJECT_ID is set") + Expect(cfg.ProjectID).NotTo(BeEmpty(), "PROJECT_ID must be set for integration tests") + + // Create new context with client factory + vctx = validator.NewContext(cfg, logger) + }) + + AfterEach(func() { + cancel() + }) + + Describe("Lazy Initialization with Real GCP Services", func() { + Context("GetServiceUsageService", func() { + It("should successfully create service with valid credentials", func() { + svc, err := vctx.GetServiceUsageService(ctx) + + Expect(err).NotTo(HaveOccurred(), "Should create service with valid GCP credentials") + Expect(svc).NotTo(BeNil(), "Service should not be nil") + }) + + It("should return cached service on subsequent calls", func() { + // First call - creates the service + svc1, err := vctx.GetServiceUsageService(ctx) + Expect(err).NotTo(HaveOccurred()) + Expect(svc1).NotTo(BeNil()) + + // Second call - should return cached instance + svc2, err := vctx.GetServiceUsageService(ctx) + Expect(err).NotTo(HaveOccurred()) + Expect(svc2).NotTo(BeNil()) + + // Verify it's the exact same instance (pointer equality) + Expect(svc2).To(BeIdenticalTo(svc1), "Should return cached service instance") + }) + + It("should successfully make API calls with created service", func() { + svc, err := vctx.GetServiceUsageService(ctx) + Expect(err).NotTo(HaveOccurred()) + + // Make a real API call to verify the service works + serviceName := "projects/" + cfg.ProjectID + "/services/compute.googleapis.com" + service, err := svc.Services.Get(serviceName).Context(ctx).Do() + + // This may fail if compute API is not enabled, but shouldn't fail on auth + if err != nil { + // Log the error but don't fail - API might not be enabled + logger.Info("API check failed (might not be enabled)", "error", err.Error()) + } else { + Expect(service).NotTo(BeNil()) + logger.Info("Successfully called Service Usage API", "state", service.State) + } + }) + }) + + Context("GetComputeService", func() { + It("should successfully create service with valid credentials", func() { + svc, err := vctx.GetComputeService(ctx) + + Expect(err).NotTo(HaveOccurred(), "Should create service with valid GCP credentials") + Expect(svc).NotTo(BeNil(), "Service should not be nil") + }) + + It("should return cached service on subsequent calls", func() { + svc1, err := vctx.GetComputeService(ctx) + Expect(err).NotTo(HaveOccurred()) + + svc2, err := vctx.GetComputeService(ctx) + Expect(err).NotTo(HaveOccurred()) + + Expect(svc2).To(BeIdenticalTo(svc1), "Should return cached service instance") + }) + }) + + Context("GetIAMService", func() { + It("should successfully create service with valid credentials", func() { + svc, err := vctx.GetIAMService(ctx) + + Expect(err).NotTo(HaveOccurred(), "Should create service with valid GCP credentials") + Expect(svc).NotTo(BeNil(), "Service should not be nil") + }) + + It("should return cached service on subsequent calls", func() { + svc1, err := vctx.GetIAMService(ctx) + Expect(err).NotTo(HaveOccurred()) + + svc2, err := vctx.GetIAMService(ctx) + Expect(err).NotTo(HaveOccurred()) + + Expect(svc2).To(BeIdenticalTo(svc1), "Should return cached service instance") + }) + }) + + Context("GetCloudResourceManagerService", func() { + It("should successfully create service with valid credentials", func() { + svc, err := vctx.GetCloudResourceManagerService(ctx) + + Expect(err).NotTo(HaveOccurred(), "Should create service with valid GCP credentials") + Expect(svc).NotTo(BeNil(), "Service should not be nil") + }) + + It("should return cached service on subsequent calls", func() { + svc1, err := vctx.GetCloudResourceManagerService(ctx) + Expect(err).NotTo(HaveOccurred()) + + svc2, err := vctx.GetCloudResourceManagerService(ctx) + Expect(err).NotTo(HaveOccurred()) + + Expect(svc2).To(BeIdenticalTo(svc1), "Should return cached service instance") + }) + + It("should successfully make API calls with created service", func() { + svc, err := vctx.GetCloudResourceManagerService(ctx) + Expect(err).NotTo(HaveOccurred()) + + // Make a real API call to get project details + project, err := svc.Projects.Get(cfg.ProjectID).Context(ctx).Do() + + Expect(err).NotTo(HaveOccurred(), "Should successfully get project details") + Expect(project).NotTo(BeNil()) + Expect(project.ProjectId).To(Equal(cfg.ProjectID)) + logger.Info("Successfully retrieved project", + "projectId", project.ProjectId, + "projectNumber", project.ProjectNumber, + "state", project.LifecycleState) + }) + }) + + Context("GetMonitoringService", func() { + It("should successfully create service with valid credentials", func() { + svc, err := vctx.GetMonitoringService(ctx) + + Expect(err).NotTo(HaveOccurred(), "Should create service with valid GCP credentials") + Expect(svc).NotTo(BeNil(), "Service should not be nil") + }) + + It("should return cached service on subsequent calls", func() { + svc1, err := vctx.GetMonitoringService(ctx) + Expect(err).NotTo(HaveOccurred()) + + svc2, err := vctx.GetMonitoringService(ctx) + Expect(err).NotTo(HaveOccurred()) + + Expect(svc2).To(BeIdenticalTo(svc1), "Should return cached service instance") + }) + }) + }) + + + Describe("Context Cancellation with Real Services", func() { + It("should respect context timeout during service creation", func() { + // Create a context with very short timeout + shortCtx, shortCancel := context.WithTimeout(context.Background(), 1*time.Nanosecond) + defer shortCancel() + + // Wait for context to expire + time.Sleep(10 * time.Millisecond) + + // Try to create service with expired context + _, err := vctx.GetServiceUsageService(shortCtx) + + // Should fail due to context timeout + // Note: Might still succeed if service was already cached + if err != nil { + Expect(err.Error()).To(Or( + ContainSubstring("context"), + ContainSubstring("deadline"), + ContainSubstring("timeout"), + ), "Error should be context-related") + } + }) + + It("should handle context cancellation gracefully", func() { + cancelCtx, cancelFunc := context.WithCancel(context.Background()) + cancelFunc() // Cancel immediately + + // Create new context (not cached yet) with cancelled context + freshVctx := validator.NewContext(cfg, logger) + + _, err := freshVctx.GetServiceUsageService(cancelCtx) + + // Should fail gracefully (no panic) + if err != nil { + logger.Info("Context cancellation handled", "error", err.Error()) + } + }) + }) + + Describe("Least Privilege Verification", func() { + It("should only create services when getters are called", func() { + // Create a fresh context + freshVctx := validator.NewContext(cfg, logger) + + // At this point, NO services should be created + // We can't directly verify this without exposing internals, + // but we can verify that calling different getters succeeds + + // Call only ServiceUsageService + svc, err := freshVctx.GetServiceUsageService(ctx) + Expect(err).NotTo(HaveOccurred()) + Expect(svc).NotTo(BeNil()) + + // Other services should be lazily created only when needed + // This verifies the lazy initialization pattern + + logger.Info("Verified lazy initialization - service created only when requested") + }) + + It("should create all services when all getters are called", func() { + // Call all getters + computeSvc, err1 := vctx.GetComputeService(ctx) + iamSvc, err2 := vctx.GetIAMService(ctx) + crmSvc, err3 := vctx.GetCloudResourceManagerService(ctx) + suSvc, err4 := vctx.GetServiceUsageService(ctx) + monSvc, err5 := vctx.GetMonitoringService(ctx) + + // All should succeed + Expect(err1).NotTo(HaveOccurred()) + Expect(err2).NotTo(HaveOccurred()) + Expect(err3).NotTo(HaveOccurred()) + Expect(err4).NotTo(HaveOccurred()) + Expect(err5).NotTo(HaveOccurred()) + + Expect(computeSvc).NotTo(BeNil()) + Expect(iamSvc).NotTo(BeNil()) + Expect(crmSvc).NotTo(BeNil()) + Expect(suSvc).NotTo(BeNil()) + Expect(monSvc).NotTo(BeNil()) + + logger.Info("Successfully created all 5 GCP service clients") + }) + }) +}) diff --git a/validator/test/integration/suite_test.go b/validator/test/integration/suite_test.go new file mode 100644 index 0000000..9424e2f --- /dev/null +++ b/validator/test/integration/suite_test.go @@ -0,0 +1,16 @@ +//go:build integration +// +build integration + +package integration_test + +import ( + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestIntegration(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Integration Suite") +} diff --git a/validator/test/integration/validator_integration_test.go b/validator/test/integration/validator_integration_test.go new file mode 100644 index 0000000..0ad79b6 --- /dev/null +++ b/validator/test/integration/validator_integration_test.go @@ -0,0 +1,292 @@ +//go:build integration +// +build integration + +package integration_test + +import ( + "context" + "log/slog" + "os" + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "validator/pkg/config" + "validator/pkg/validator" + _ "validator/pkg/validators" // Import to trigger validator registration +) + +var _ = Describe("Validator Integration Tests", func() { + var ( + ctx context.Context + cancel context.CancelFunc + vctx *validator.Context + cfg *config.Config + logger *slog.Logger + ) + + BeforeEach(func() { + // Create context with reasonable timeout + ctx, cancel = context.WithTimeout(context.Background(), 60*time.Second) + + // Set up logger + logger = slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{ + Level: slog.LevelInfo, + })) + + // Load configuration from environment + var err error + cfg, err = config.LoadFromEnv() + Expect(err).NotTo(HaveOccurred()) + Expect(cfg.ProjectID).NotTo(BeEmpty(), "PROJECT_ID must be set") + + // Create validation context + vctx = validator.NewContext(cfg, logger) + }) + + AfterEach(func() { + cancel() + }) + + Describe("End-to-End Validator Execution", func() { + Context("with all validators enabled", func() { + It("should execute all enabled validators successfully", func() { + executor := validator.NewExecutor(vctx, logger) + + results, err := executor.ExecuteAll(ctx) + + Expect(err).NotTo(HaveOccurred(), "Executor should complete without error") + Expect(results).NotTo(BeEmpty(), "Should have at least one validator result") + + logger.Info("Validator execution completed", + "total_validators", len(results), + "project_id", cfg.ProjectID) + + // Log each result + for _, result := range results { + logger.Info("Validator result", + "name", result.ValidatorName, + "status", result.Status, + "reason", result.Reason, + "message", result.Message, + "duration", result.Duration) + } + }) + }) + + Context("api-enabled validator", func() { + It("should successfully check if required APIs are enabled", func() { + // Get the api-enabled validator + v, exists := validator.Get("api-enabled") + Expect(exists).To(BeTrue(), "api-enabled validator should be registered") + + // Check if it's enabled + enabled := v.Enabled(vctx) + if !enabled { + Skip("api-enabled validator is disabled in configuration") + } + + // Execute the validator + result := v.Validate(ctx, vctx) + + Expect(result).NotTo(BeNil()) + // Note: ValidatorName is set by Executor, not by Validate method directly + + // Log the result + logger.Info("API enabled check result", + "status", result.Status, + "reason", result.Reason, + "message", result.Message, + "details", result.Details) + + // Verify result structure + // Note: Timestamp, Duration, and ValidatorName are set by Executor, not by Validate directly + Expect(result.Status).To(BeElementOf( + validator.StatusSuccess, + validator.StatusFailure, + ), "Status should be success or failure") + Expect(result.Reason).NotTo(BeEmpty(), "Reason should not be empty") + Expect(result.Message).NotTo(BeEmpty(), "Message should not be empty") + }) + }) + + Context("quota-check validator", func() { + It("should run quota-check validator (stub)", func() { + v, exists := validator.Get("quota-check") + Expect(exists).To(BeTrue(), "quota-check validator should be registered") + + enabled := v.Enabled(vctx) + if !enabled { + Skip("quota-check validator is disabled in configuration") + } + + result := v.Validate(ctx, vctx) + + Expect(result).NotTo(BeNil()) + // Note: ValidatorName is set by Executor, not by Validate method directly + + logger.Info("Quota check result", + "status", result.Status, + "reason", result.Reason, + "message", result.Message) + + // Currently a stub, so should succeed + Expect(result.Status).To(Equal(validator.StatusSuccess)) + }) + }) + }) + + Describe("Validator Aggregation", func() { + It("should aggregate multiple validator results correctly", func() { + executor := validator.NewExecutor(vctx, logger) + + results, err := executor.ExecuteAll(ctx) + Expect(err).NotTo(HaveOccurred()) + + // Aggregate results + aggregated := validator.Aggregate(results) + + Expect(aggregated).NotTo(BeNil()) + Expect(aggregated.Status).To(BeElementOf( + validator.StatusSuccess, + validator.StatusFailure, + )) + Expect(aggregated.Message).NotTo(BeEmpty()) + Expect(aggregated.Details).NotTo(BeEmpty()) + + // Extract counts from Details map + checksRun, ok := aggregated.Details["checks_run"].(int) + Expect(ok).To(BeTrue(), "checks_run should be an int") + Expect(checksRun).To(Equal(len(results))) + + checksPassed, ok := aggregated.Details["checks_passed"].(int) + Expect(ok).To(BeTrue(), "checks_passed should be an int") + + successCount := 0 + failureCount := 0 + for _, r := range results { + if r.Status == validator.StatusSuccess { + successCount++ + } else { + failureCount++ + } + } + + Expect(checksPassed).To(Equal(successCount)) + Expect(checksRun - checksPassed).To(Equal(failureCount)) + + logger.Info("Aggregated results", + "status", aggregated.Status, + "checks_run", checksRun, + "checks_passed", checksPassed, + "checks_failed", checksRun-checksPassed, + "message", aggregated.Message) + }) + }) + + Describe("Shared State Between Validators", func() { + It("should maintain shared state in context across validators", func() { + executor := validator.NewExecutor(vctx, logger) + + results, err := executor.ExecuteAll(ctx) + Expect(err).NotTo(HaveOccurred()) + + // Verify results are stored in context + Expect(vctx.Results).To(HaveLen(len(results))) + + for _, result := range results { + Expect(vctx.Results).To(HaveKey(result.ValidatorName)) + Expect(vctx.Results[result.ValidatorName]).To(Equal(result)) + } + + logger.Info("Verified shared state", + "validators_in_context", len(vctx.Results)) + }) + }) + + Describe("Real GCP API Integration", func() { + Context("when checking actual GCP project state", func() { + It("should successfully interact with GCP APIs", func() { + // Get Cloud Resource Manager service + svc, err := vctx.GetCloudResourceManagerService(ctx) + Expect(err).NotTo(HaveOccurred()) + + // Make real API call + project, err := svc.Projects.Get(cfg.ProjectID).Context(ctx).Do() + Expect(err).NotTo(HaveOccurred()) + + Expect(project.ProjectId).To(Equal(cfg.ProjectID)) + Expect(project.ProjectNumber).To(BeNumerically(">", 0)) + Expect(project.LifecycleState).To(Equal("ACTIVE")) + + // Store project number in context (validators might use this) + vctx.ProjectNumber = project.ProjectNumber + + logger.Info("Successfully retrieved real project details", + "projectId", project.ProjectId, + "projectNumber", project.ProjectNumber, + "name", project.Name, + "state", project.LifecycleState) + }) + + It("should successfully check if Compute API is accessible", func() { + svc, err := vctx.GetServiceUsageService(ctx) + Expect(err).NotTo(HaveOccurred()) + + serviceName := "projects/" + cfg.ProjectID + "/services/compute.googleapis.com" + service, err := svc.Services.Get(serviceName).Context(ctx).Do() + + if err != nil { + logger.Warn("Failed to get Compute API status", "error", err.Error()) + // Don't fail test - API might not be enabled + return + } + + Expect(service).NotTo(BeNil()) + logger.Info("Compute API status", + "name", service.Name, + "state", service.State) + }) + }) + }) + + Describe("Performance and Timeout", func() { + It("should complete all validators within reasonable time", func() { + start := time.Now() + + executor := validator.NewExecutor(vctx, logger) + _, err := executor.ExecuteAll(ctx) + + duration := time.Since(start) + + Expect(err).NotTo(HaveOccurred()) + Expect(duration).To(BeNumerically("<", 30*time.Second), + "All validators should complete within 30 seconds") + + logger.Info("Performance test completed", + "total_duration", duration.String()) + }) + + It("should respect global timeout from configuration", func() { + // Create short timeout config + shortTimeout := 5 * time.Second + cfg.MaxWaitTimeSeconds = int(shortTimeout.Seconds()) + + shortCtx, shortCancel := context.WithTimeout(context.Background(), shortTimeout) + defer shortCancel() + + executor := validator.NewExecutor(vctx, logger) + results, err := executor.ExecuteAll(shortCtx) + + // Should either complete or respect timeout + if err != nil { + Expect(err.Error()).To(ContainSubstring("context")) + } else { + Expect(results).NotTo(BeNil()) + } + + logger.Info("Timeout test completed") + }) + }) +}) From b91bd45d91794a9af8f06b9b5fdc4ecc032ff3e8 Mon Sep 17 00:00:00 2001 From: dawang Date: Fri, 23 Jan 2026 18:01:10 +0800 Subject: [PATCH 10/14] Add more test cases for dependency logic and support helm variable for STOP_ON_FIRST_FAILURE --- README.md | 3 + charts/configs/validation-adapter.yaml | 5 + .../configs/validation-job-adapter-task.yaml | 2 +- charts/templates/deployment.yaml | 2 + charts/values.yaml | 4 + validator/pkg/validator/executor_test.go | 51 + validator/pkg/validator/resolver_test.go | 962 ++++++++++-------- 7 files changed, 588 insertions(+), 441 deletions(-) diff --git a/README.md b/README.md index bf045b9..d9d9a55 100644 --- a/README.md +++ b/README.md @@ -283,6 +283,7 @@ When `rbac.create=true`, the adapter gets **minimal permissions** needed for val | `validation.real.disabledValidators` | Comma-separated list of validators to disable | `"quota-check"` | | `validation.real.requiredApis` | Comma-separated list of required GCP APIs to validate | `"compute.googleapis.com,iam.googleapis.com,cloudresourcemanager.googleapis.com"` | | `validation.real.logLevel` | Log level for validation containers (debug, info, warn, error) | `"info"` | +| `validation.real.stopOnFirstFailure` | Stop execution on first failure. `true` = fail-fast, `false` = collect all results | `false` | ### Environment Variables @@ -439,6 +440,7 @@ validation: disabledValidators: "quota-check" requiredApis: "compute.googleapis.com,iam.googleapis.com,cloudresourcemanager.googleapis.com" logLevel: "info" + stopOnFirstFailure: false # true = fail-fast, false = collect all results ``` @@ -470,6 +472,7 @@ The deployment sets these environment variables automatically: | `DISABLED_VALIDATORS` | From `validation.real.disabledValidators` | When `validation.useDummy=false` | | `REQUIRED_APIS` | From `validation.real.requiredApis` | When `validation.useDummy=false` | | `VALIDATOR_LOG_LEVEL` | From `validation.real.logLevel` | When `validation.useDummy=false` | +| `STOP_ON_FIRST_FAILURE` | From `validation.real.stopOnFirstFailure` | When `validation.useDummy=false` | ## License diff --git a/charts/configs/validation-adapter.yaml b/charts/configs/validation-adapter.yaml index 0294712..2f2b803 100644 --- a/charts/configs/validation-adapter.yaml +++ b/charts/configs/validation-adapter.yaml @@ -80,6 +80,11 @@ spec: type: "string" default: "info" description: "Log level for validation containers (debug, info, warn, error)" + - name: "stopOnFirstFailure" + source: "env.STOP_ON_FIRST_FAILURE" + type: "string" + default: "false" + description: "Stop validation execution on first failure (true/false)" - name: "adapterTaskServiceAccount" source: "env.ADAPTER_TASK_SERVICE_ACCOUNT" type: "string" diff --git a/charts/configs/validation-job-adapter-task.yaml b/charts/configs/validation-job-adapter-task.yaml index 0a1c9cf..0569847 100644 --- a/charts/configs/validation-job-adapter-task.yaml +++ b/charts/configs/validation-job-adapter-task.yaml @@ -42,7 +42,7 @@ spec: - name: DISABLED_VALIDATORS value: "{{ .disabledValidators }}" - name: STOP_ON_FIRST_FAILURE - value: "false" + value: "{{ .stopOnFirstFailure }}" # API Validator config - name: REQUIRED_APIS diff --git a/charts/templates/deployment.yaml b/charts/templates/deployment.yaml index 879b0ae..7d9884a 100644 --- a/charts/templates/deployment.yaml +++ b/charts/templates/deployment.yaml @@ -101,6 +101,8 @@ spec: value: {{ .Values.validation.real.requiredApis | quote }} - name: VALIDATOR_LOG_LEVEL value: {{ .Values.validation.real.logLevel | default "info" | quote }} + - name: STOP_ON_FIRST_FAILURE + value: {{ .Values.validation.real.stopOnFirstFailure | quote }} {{- end }} resources: limits: diff --git a/charts/values.yaml b/charts/values.yaml index ee09ede..5fc1a83 100644 --- a/charts/values.yaml +++ b/charts/values.yaml @@ -96,6 +96,10 @@ validation: # Log level for validation containers (sets VALIDATOR_LOG_LEVEL env var) # Options: debug, info, warn, error logLevel: "info" + # Stop validation execution on first failure (default: false) + # When true: stops immediately after any validator fails (fail-fast) + # When false: continues executing all validators even after failures (collect all results) + stopOnFirstFailure: false # Additional environment variables env: [] diff --git a/validator/pkg/validator/executor_test.go b/validator/pkg/validator/executor_test.go index ed6ad67..a3af0c9 100644 --- a/validator/pkg/validator/executor_test.go +++ b/validator/pkg/validator/executor_test.go @@ -209,6 +209,57 @@ var _ = Describe("Executor", func() { Expect(executionOrder[0]).To(Equal("validator-a")) Expect(executionOrder[1:]).To(ConsistOf("validator-b", "validator-c")) }) + + It("should handle out-of-order registration (dependencies registered before dependents)", func() { + // Clear previous validators and reset execution order + validator.ClearRegistry() + executionOrder = []string{} + + // Register in reverse order: dependents (b, c) before dependency (a) + // This tests that the resolver can handle forward references + for _, name := range []string{"validator-b", "validator-c"} { + n := name + validator.Register(&MockValidator{ + name: n, + runAfter: []string{"validator-a"}, // depends on validator-a which isn't registered yet + enabled: true, + validateFunc: func(ctx context.Context, vctx *validator.Context) *validator.Result { + mu.Lock() + executionOrder = append(executionOrder, n) + mu.Unlock() + return &validator.Result{ + ValidatorName: n, + Status: validator.StatusSuccess, + } + }, + }) + } + + // Now register validator-a (after its dependents) + validator.Register(&MockValidator{ + name: "validator-a", + runAfter: []string{}, + enabled: true, + validateFunc: func(ctx context.Context, vctx *validator.Context) *validator.Result { + mu.Lock() + executionOrder = append(executionOrder, "validator-a") + mu.Unlock() + return &validator.Result{ + ValidatorName: "validator-a", + Status: validator.StatusSuccess, + } + }, + }) + + executor = validator.NewExecutor(vctx, logger) + results, err := executor.ExecuteAll(ctx) + Expect(err).NotTo(HaveOccurred()) + Expect(results).To(HaveLen(3)) + + // Regardless of registration order, validator-a should execute before b and c + Expect(executionOrder[0]).To(Equal("validator-a")) + Expect(executionOrder[1:]).To(ConsistOf("validator-b", "validator-c")) + }) }) Context("with StopOnFirstFailure enabled", func() { diff --git a/validator/pkg/validator/resolver_test.go b/validator/pkg/validator/resolver_test.go index 3ec7bf6..1c3edb6 100644 --- a/validator/pkg/validator/resolver_test.go +++ b/validator/pkg/validator/resolver_test.go @@ -1,448 +1,530 @@ package validator_test import ( - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" - "validator/pkg/validator" + "validator/pkg/validator" ) var _ = Describe("DependencyResolver", func() { - var ( - resolver *validator.DependencyResolver - validators []validator.Validator - ) - - Describe("ResolveExecutionGroups", func() { - Context("with validators that have no dependencies", func() { - BeforeEach(func() { - validators = []validator.Validator{ - &MockValidator{ - name: "validator-a", - runAfter: []string{}, - enabled: true, - }, - &MockValidator{ - name: "validator-b", - runAfter: []string{}, - enabled: true, - }, - &MockValidator{ - name: "validator-c", - runAfter: []string{}, - enabled: true, - }, - } - resolver = validator.NewDependencyResolver(validators) - }) - - It("should place all validators in level 0", func() { - groups, err := resolver.ResolveExecutionGroups() - Expect(err).NotTo(HaveOccurred()) - Expect(groups).To(HaveLen(1)) - Expect(groups[0].Level).To(Equal(0)) - Expect(groups[0].Validators).To(HaveLen(3)) - }) - - It("should sort validators alphabetically within the same level", func() { - groups, err := resolver.ResolveExecutionGroups() - Expect(err).NotTo(HaveOccurred()) - names := make([]string, len(groups[0].Validators)) - for i, v := range groups[0].Validators { - names[i] = v.Metadata().Name - } - Expect(names).To(Equal([]string{"validator-a", "validator-b", "validator-c"})) - }) - }) - - Context("with linear dependencies", func() { - BeforeEach(func() { - validators = []validator.Validator{ - &MockValidator{ - name: "validator-a", - runAfter: []string{}, - enabled: true, - }, - &MockValidator{ - name: "validator-b", - runAfter: []string{"validator-a"}, - enabled: true, - }, - &MockValidator{ - name: "validator-c", - runAfter: []string{"validator-b"}, - enabled: true, - }, - } - resolver = validator.NewDependencyResolver(validators) - }) - - It("should create separate levels for each validator", func() { - groups, err := resolver.ResolveExecutionGroups() - Expect(err).NotTo(HaveOccurred()) - Expect(groups).To(HaveLen(3)) - - Expect(groups[0].Level).To(Equal(0)) - Expect(groups[0].Validators).To(HaveLen(1)) - Expect(groups[0].Validators[0].Metadata().Name).To(Equal("validator-a")) - - Expect(groups[1].Level).To(Equal(1)) - Expect(groups[1].Validators).To(HaveLen(1)) - Expect(groups[1].Validators[0].Metadata().Name).To(Equal("validator-b")) - - Expect(groups[2].Level).To(Equal(2)) - Expect(groups[2].Validators).To(HaveLen(1)) - Expect(groups[2].Validators[0].Metadata().Name).To(Equal("validator-c")) - }) - }) - - Context("with parallel dependencies", func() { - BeforeEach(func() { - validators = []validator.Validator{ - &MockValidator{ - name: "wif-check", - runAfter: []string{}, - enabled: true, - }, - &MockValidator{ - name: "api-enabled", - runAfter: []string{"wif-check"}, - enabled: true, - }, - &MockValidator{ - name: "quota-check", - runAfter: []string{"wif-check"}, - enabled: true, - }, - &MockValidator{ - name: "network-check", - runAfter: []string{"wif-check"}, - enabled: true, - }, - } - resolver = validator.NewDependencyResolver(validators) - }) - - It("should group validators with same dependencies at the same level", func() { - groups, err := resolver.ResolveExecutionGroups() - Expect(err).NotTo(HaveOccurred()) - Expect(groups).To(HaveLen(2)) - - // Level 0: wif-check - Expect(groups[0].Level).To(Equal(0)) - Expect(groups[0].Validators).To(HaveLen(1)) - Expect(groups[0].Validators[0].Metadata().Name).To(Equal("wif-check")) - - // Level 1: api-enabled, quota-check, network-check (parallel) - Expect(groups[1].Level).To(Equal(1)) - Expect(groups[1].Validators).To(HaveLen(3)) - names := make([]string, 3) - for i, v := range groups[1].Validators { - names[i] = v.Metadata().Name - } - Expect(names).To(ConsistOf("api-enabled", "quota-check", "network-check")) - }) - }) - - Context("with complex dependency graph", func() { - BeforeEach(func() { - validators = []validator.Validator{ - &MockValidator{ - name: "wif-check", - runAfter: []string{}, - enabled: true, - }, - &MockValidator{ - name: "api-enabled", - runAfter: []string{"wif-check"}, - enabled: true, - }, - &MockValidator{ - name: "quota-check", - runAfter: []string{"wif-check"}, - enabled: true, - }, - &MockValidator{ - name: "iam-check", - runAfter: []string{"api-enabled"}, - enabled: true, - }, - &MockValidator{ - name: "network-check", - runAfter: []string{"api-enabled", "quota-check"}, - enabled: true, - }, - } - resolver = validator.NewDependencyResolver(validators) - }) - - It("should create correct levels based on dependencies", func() { - groups, err := resolver.ResolveExecutionGroups() - Expect(err).NotTo(HaveOccurred()) - Expect(groups).To(HaveLen(3)) - - // Level 0: wif-check - Expect(groups[0].Level).To(Equal(0)) - Expect(groups[0].Validators[0].Metadata().Name).To(Equal("wif-check")) - - // Level 1: api-enabled, quota-check - Expect(groups[1].Level).To(Equal(1)) - Expect(groups[1].Validators).To(HaveLen(2)) - - // Level 2: iam-check, network-check - Expect(groups[2].Level).To(Equal(2)) - Expect(groups[2].Validators).To(HaveLen(2)) - }) - }) - - Context("with circular dependencies", func() { - BeforeEach(func() { - validators = []validator.Validator{ - &MockValidator{ - name: "validator-a", - runAfter: []string{"validator-b"}, - enabled: true, - }, - &MockValidator{ - name: "validator-b", - runAfter: []string{"validator-a"}, - enabled: true, - }, - } - resolver = validator.NewDependencyResolver(validators) - }) - - It("should detect the circular dependency and return an error", func() { - _, err := resolver.ResolveExecutionGroups() - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("circular dependency")) - }) - }) - - Context("with self-referencing dependency", func() { - BeforeEach(func() { - validators = []validator.Validator{ - &MockValidator{ - name: "validator-a", - runAfter: []string{"validator-a"}, - enabled: true, - }, - } - resolver = validator.NewDependencyResolver(validators) - }) - - It("should detect the circular dependency and return an error", func() { - _, err := resolver.ResolveExecutionGroups() - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("circular dependency")) - }) - }) - - Context("with missing dependency", func() { - BeforeEach(func() { - validators = []validator.Validator{ - &MockValidator{ - name: "validator-a", - runAfter: []string{"non-existent"}, - enabled: true, - }, - } - resolver = validator.NewDependencyResolver(validators) - }) - - It("should handle missing dependencies gracefully", func() { - groups, err := resolver.ResolveExecutionGroups() - Expect(err).NotTo(HaveOccurred()) - // Missing dependencies are ignored, validator runs at level 0 - Expect(groups).To(HaveLen(1)) - Expect(groups[0].Level).To(Equal(0)) - }) - }) - - Context("with empty validator list", func() { - BeforeEach(func() { - validators = []validator.Validator{} - resolver = validator.NewDependencyResolver(validators) - }) - - It("should return empty groups", func() { - groups, err := resolver.ResolveExecutionGroups() - Expect(err).NotTo(HaveOccurred()) - Expect(groups).To(BeEmpty()) - }) - }) - }) - - Describe("ToMermaid", func() { - Context("with validators that have no dependencies", func() { - BeforeEach(func() { - validators = []validator.Validator{ - &MockValidator{name: "validator-a", runAfter: []string{}, enabled: true}, - &MockValidator{name: "validator-b", runAfter: []string{}, enabled: true}, - } - resolver = validator.NewDependencyResolver(validators) - }) - - It("should render standalone nodes", func() { - mermaid := resolver.ToMermaid() - Expect(mermaid).To(ContainSubstring("flowchart TD")) - Expect(mermaid).To(ContainSubstring("validator-a")) - Expect(mermaid).To(ContainSubstring("validator-b")) - Expect(mermaid).NotTo(ContainSubstring("-->")) - }) - }) - - Context("with linear dependencies", func() { - BeforeEach(func() { - validators = []validator.Validator{ - &MockValidator{name: "validator-a", runAfter: []string{}, enabled: true}, - &MockValidator{name: "validator-b", runAfter: []string{"validator-a"}, enabled: true}, - &MockValidator{name: "validator-c", runAfter: []string{"validator-b"}, enabled: true}, - } - resolver = validator.NewDependencyResolver(validators) - }) - - It("should render dependency arrows", func() { - mermaid := resolver.ToMermaid() - Expect(mermaid).To(ContainSubstring("flowchart TD")) - Expect(mermaid).To(ContainSubstring("validator-b --> validator-a")) - Expect(mermaid).To(ContainSubstring("validator-c --> validator-b")) - }) - }) - - Context("with complex dependencies", func() { - BeforeEach(func() { - validators = []validator.Validator{ - &MockValidator{name: "wif-check", runAfter: []string{}, enabled: true}, - &MockValidator{name: "api-enabled", runAfter: []string{"wif-check"}, enabled: true}, - &MockValidator{name: "quota-check", runAfter: []string{"wif-check"}, enabled: true}, - &MockValidator{name: "network-check", runAfter: []string{"api-enabled", "quota-check"}, enabled: true}, - } - resolver = validator.NewDependencyResolver(validators) - }) - - It("should render all dependency relationships", func() { - mermaid := resolver.ToMermaid() - Expect(mermaid).To(ContainSubstring("flowchart TD")) - Expect(mermaid).To(ContainSubstring("api-enabled --> wif-check")) - Expect(mermaid).To(ContainSubstring("quota-check --> wif-check")) - Expect(mermaid).To(ContainSubstring("network-check --> api-enabled")) - Expect(mermaid).To(ContainSubstring("network-check --> quota-check")) - }) - }) - - Context("with missing dependency", func() { - BeforeEach(func() { - validators = []validator.Validator{ - &MockValidator{name: "validator-a", runAfter: []string{"non-existent"}, enabled: true}, - } - resolver = validator.NewDependencyResolver(validators) - }) - - It("should not render edges for missing dependencies", func() { - mermaid := resolver.ToMermaid() - Expect(mermaid).To(ContainSubstring("flowchart TD")) - Expect(mermaid).NotTo(ContainSubstring("-->")) - Expect(mermaid).NotTo(ContainSubstring("non-existent")) - }) - }) - }) - - Describe("ToMermaidWithLevels", func() { - Context("with validators that have no dependencies", func() { - BeforeEach(func() { - validators = []validator.Validator{ - &MockValidator{name: "validator-a", runAfter: []string{}, enabled: true}, - &MockValidator{name: "validator-b", runAfter: []string{}, enabled: true}, - } - resolver = validator.NewDependencyResolver(validators) - }) - - It("should render all validators in Level 0 subgraph", func() { - groups, _ := resolver.ResolveExecutionGroups() - mermaid := resolver.ToMermaidWithLevels(groups) - - Expect(mermaid).To(ContainSubstring("flowchart TD")) - Expect(mermaid).To(ContainSubstring("subgraph \"Level 0 - 2 Validators in Parallel\"")) - Expect(mermaid).To(ContainSubstring("validator-a")) - Expect(mermaid).To(ContainSubstring("validator-b")) - }) - }) - - Context("with linear dependencies", func() { - BeforeEach(func() { - validators = []validator.Validator{ - &MockValidator{name: "validator-a", runAfter: []string{}, enabled: true}, - &MockValidator{name: "validator-b", runAfter: []string{"validator-a"}, enabled: true}, - &MockValidator{name: "validator-c", runAfter: []string{"validator-b"}, enabled: true}, - } - resolver = validator.NewDependencyResolver(validators) - }) - - It("should render separate levels with dependency arrows", func() { - groups, _ := resolver.ResolveExecutionGroups() - mermaid := resolver.ToMermaidWithLevels(groups) - - Expect(mermaid).To(ContainSubstring("flowchart TD")) - Expect(mermaid).To(ContainSubstring("subgraph \"Level 0\"")) - Expect(mermaid).To(ContainSubstring("subgraph \"Level 1\"")) - Expect(mermaid).To(ContainSubstring("subgraph \"Level 2\"")) - Expect(mermaid).To(ContainSubstring("validator-b --> validator-a")) - Expect(mermaid).To(ContainSubstring("validator-c --> validator-b")) - }) - }) - - Context("with parallel dependencies", func() { - BeforeEach(func() { - validators = []validator.Validator{ - &MockValidator{name: "wif-check", runAfter: []string{}, enabled: true}, - &MockValidator{name: "api-enabled", runAfter: []string{"wif-check"}, enabled: true}, - &MockValidator{name: "quota-check", runAfter: []string{"wif-check"}, enabled: true}, - &MockValidator{name: "network-check", runAfter: []string{"wif-check"}, enabled: true}, - } - resolver = validator.NewDependencyResolver(validators) - }) - - It("should show parallel validators in the same level", func() { - groups, _ := resolver.ResolveExecutionGroups() - mermaid := resolver.ToMermaidWithLevels(groups) - - Expect(mermaid).To(ContainSubstring("flowchart TD")) - Expect(mermaid).To(ContainSubstring("subgraph \"Level 0\"")) - Expect(mermaid).To(ContainSubstring("subgraph \"Level 1 - 3 Validators in Parallel\"")) - Expect(mermaid).To(ContainSubstring("wif-check")) - Expect(mermaid).To(ContainSubstring("api-enabled")) - Expect(mermaid).To(ContainSubstring("quota-check")) - Expect(mermaid).To(ContainSubstring("network-check")) - }) - }) - - Context("with complex dependency graph", func() { - BeforeEach(func() { - validators = []validator.Validator{ - &MockValidator{name: "wif-check", runAfter: []string{}, enabled: true}, - &MockValidator{name: "api-enabled", runAfter: []string{"wif-check"}, enabled: true}, - &MockValidator{name: "quota-check", runAfter: []string{"wif-check"}, enabled: true}, - &MockValidator{name: "iam-check", runAfter: []string{"api-enabled"}, enabled: true}, - &MockValidator{name: "network-check", runAfter: []string{"api-enabled", "quota-check"}, enabled: true}, - } - resolver = validator.NewDependencyResolver(validators) - }) - - It("should render correct levels and all dependency edges", func() { - groups, _ := resolver.ResolveExecutionGroups() - mermaid := resolver.ToMermaidWithLevels(groups) - - Expect(mermaid).To(ContainSubstring("flowchart TD")) - Expect(mermaid).To(ContainSubstring("subgraph \"Level 0\"")) - Expect(mermaid).To(ContainSubstring("subgraph \"Level 1 - 2 Validators in Parallel\"")) - Expect(mermaid).To(ContainSubstring("subgraph \"Level 2 - 2 Validators in Parallel\"")) - Expect(mermaid).To(ContainSubstring("api-enabled --> wif-check")) - Expect(mermaid).To(ContainSubstring("quota-check --> wif-check")) - Expect(mermaid).To(ContainSubstring("iam-check --> api-enabled")) - Expect(mermaid).To(ContainSubstring("network-check --> api-enabled")) - Expect(mermaid).To(ContainSubstring("network-check --> quota-check")) - }) - }) - }) + var ( + resolver *validator.DependencyResolver + validators []validator.Validator + ) + + Describe("ResolveExecutionGroups", func() { + Context("with validators that have no dependencies", func() { + BeforeEach(func() { + validators = []validator.Validator{ + &MockValidator{ + name: "validator-a", + runAfter: []string{}, + enabled: true, + }, + &MockValidator{ + name: "validator-b", + runAfter: []string{}, + enabled: true, + }, + &MockValidator{ + name: "validator-c", + runAfter: []string{}, + enabled: true, + }, + } + resolver = validator.NewDependencyResolver(validators) + }) + + It("should place all validators in level 0", func() { + groups, err := resolver.ResolveExecutionGroups() + Expect(err).NotTo(HaveOccurred()) + Expect(groups).To(HaveLen(1)) + Expect(groups[0].Level).To(Equal(0)) + Expect(groups[0].Validators).To(HaveLen(3)) + }) + + It("should sort validators alphabetically within the same level", func() { + groups, err := resolver.ResolveExecutionGroups() + Expect(err).NotTo(HaveOccurred()) + names := make([]string, len(groups[0].Validators)) + for i, v := range groups[0].Validators { + names[i] = v.Metadata().Name + } + Expect(names).To(Equal([]string{"validator-a", "validator-b", "validator-c"})) + }) + }) + + Context("with linear dependencies", func() { + BeforeEach(func() { + validators = []validator.Validator{ + &MockValidator{ + name: "validator-a", + runAfter: []string{}, + enabled: true, + }, + &MockValidator{ + name: "validator-b", + runAfter: []string{"validator-a"}, + enabled: true, + }, + &MockValidator{ + name: "validator-c", + runAfter: []string{"validator-b"}, + enabled: true, + }, + } + resolver = validator.NewDependencyResolver(validators) + }) + + It("should create separate levels for each validator", func() { + groups, err := resolver.ResolveExecutionGroups() + Expect(err).NotTo(HaveOccurred()) + Expect(groups).To(HaveLen(3)) + + Expect(groups[0].Level).To(Equal(0)) + Expect(groups[0].Validators).To(HaveLen(1)) + Expect(groups[0].Validators[0].Metadata().Name).To(Equal("validator-a")) + + Expect(groups[1].Level).To(Equal(1)) + Expect(groups[1].Validators).To(HaveLen(1)) + Expect(groups[1].Validators[0].Metadata().Name).To(Equal("validator-b")) + + Expect(groups[2].Level).To(Equal(2)) + Expect(groups[2].Validators).To(HaveLen(1)) + Expect(groups[2].Validators[0].Metadata().Name).To(Equal("validator-c")) + }) + }) + + Context("with parallel dependencies", func() { + BeforeEach(func() { + validators = []validator.Validator{ + &MockValidator{ + name: "wif-check", + runAfter: []string{}, + enabled: true, + }, + &MockValidator{ + name: "api-enabled", + runAfter: []string{"wif-check"}, + enabled: true, + }, + &MockValidator{ + name: "quota-check", + runAfter: []string{"wif-check"}, + enabled: true, + }, + &MockValidator{ + name: "network-check", + runAfter: []string{"wif-check"}, + enabled: true, + }, + } + resolver = validator.NewDependencyResolver(validators) + }) + + It("should group validators with same dependencies at the same level", func() { + groups, err := resolver.ResolveExecutionGroups() + Expect(err).NotTo(HaveOccurred()) + Expect(groups).To(HaveLen(2)) + + // Level 0: wif-check + Expect(groups[0].Level).To(Equal(0)) + Expect(groups[0].Validators).To(HaveLen(1)) + Expect(groups[0].Validators[0].Metadata().Name).To(Equal("wif-check")) + + // Level 1: api-enabled, quota-check, network-check (parallel) + Expect(groups[1].Level).To(Equal(1)) + Expect(groups[1].Validators).To(HaveLen(3)) + names := make([]string, 3) + for i, v := range groups[1].Validators { + names[i] = v.Metadata().Name + } + Expect(names).To(ConsistOf("api-enabled", "quota-check", "network-check")) + }) + }) + + Context("with complex dependency graph", func() { + BeforeEach(func() { + validators = []validator.Validator{ + &MockValidator{ + name: "wif-check", + runAfter: []string{}, + enabled: true, + }, + &MockValidator{ + name: "api-enabled", + runAfter: []string{"wif-check"}, + enabled: true, + }, + &MockValidator{ + name: "quota-check", + runAfter: []string{"wif-check"}, + enabled: true, + }, + &MockValidator{ + name: "iam-check", + runAfter: []string{"api-enabled"}, + enabled: true, + }, + &MockValidator{ + name: "network-check", + runAfter: []string{"api-enabled", "quota-check"}, + enabled: true, + }, + } + resolver = validator.NewDependencyResolver(validators) + }) + + It("should create correct levels based on dependencies", func() { + groups, err := resolver.ResolveExecutionGroups() + Expect(err).NotTo(HaveOccurred()) + Expect(groups).To(HaveLen(3)) + + // Level 0: wif-check + Expect(groups[0].Level).To(Equal(0)) + Expect(groups[0].Validators[0].Metadata().Name).To(Equal("wif-check")) + + // Level 1: api-enabled, quota-check + Expect(groups[1].Level).To(Equal(1)) + Expect(groups[1].Validators).To(HaveLen(2)) + + // Level 2: iam-check, network-check + Expect(groups[2].Level).To(Equal(2)) + Expect(groups[2].Validators).To(HaveLen(2)) + }) + }) + + Context("with dependencies across multiple levels", func() { + BeforeEach(func() { + validators = []validator.Validator{ + &MockValidator{ + name: "wif-check", + runAfter: []string{}, + enabled: true, + }, + &MockValidator{ + name: "api-enabled", + runAfter: []string{"wif-check"}, + enabled: true, + }, + &MockValidator{ + name: "quota-check", + runAfter: []string{"wif-check"}, + enabled: true, + }, + &MockValidator{ + name: "network-check", + runAfter: []string{"wif-check", "api-enabled"}, + enabled: true, + }, + } + resolver = validator.NewDependencyResolver(validators) + }) + + It("should place validator at correct level when depending on multiple levels", func() { + groups, err := resolver.ResolveExecutionGroups() + Expect(err).NotTo(HaveOccurred()) + Expect(groups).To(HaveLen(3)) + + // Level 0: wif-check + Expect(groups[0].Level).To(Equal(0)) + Expect(groups[0].Validators).To(HaveLen(1)) + Expect(groups[0].Validators[0].Metadata().Name).To(Equal("wif-check")) + + // Level 1: api-enabled, quota-check + Expect(groups[1].Level).To(Equal(1)) + Expect(groups[1].Validators).To(HaveLen(2)) + names := make([]string, 2) + for i, v := range groups[1].Validators { + names[i] = v.Metadata().Name + } + Expect(names).To(ConsistOf("api-enabled", "quota-check")) + + // Level 2: network-check (depends on both level 0 and level 1) + Expect(groups[2].Level).To(Equal(2)) + Expect(groups[2].Validators).To(HaveLen(1)) + Expect(groups[2].Validators[0].Metadata().Name).To(Equal("network-check")) + }) + }) + + Context("with circular dependencies", func() { + BeforeEach(func() { + validators = []validator.Validator{ + &MockValidator{ + name: "validator-a", + runAfter: []string{"validator-b"}, + enabled: true, + }, + &MockValidator{ + name: "validator-b", + runAfter: []string{"validator-a"}, + enabled: true, + }, + } + resolver = validator.NewDependencyResolver(validators) + }) + + It("should detect the circular dependency and return an error", func() { + _, err := resolver.ResolveExecutionGroups() + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("circular dependency")) + }) + }) + + Context("with self-referencing dependency", func() { + BeforeEach(func() { + validators = []validator.Validator{ + &MockValidator{ + name: "validator-a", + runAfter: []string{"validator-a"}, + enabled: true, + }, + } + resolver = validator.NewDependencyResolver(validators) + }) + + It("should detect the circular dependency and return an error", func() { + _, err := resolver.ResolveExecutionGroups() + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("circular dependency")) + }) + }) + + Context("with multi-level circular dependencies", func() { + BeforeEach(func() { + validators = []validator.Validator{ + &MockValidator{ + name: "validator-a", + runAfter: []string{"validator-c"}, + enabled: true, + }, + &MockValidator{ + name: "validator-b", + runAfter: []string{"validator-a"}, + enabled: true, + }, + &MockValidator{ + name: "validator-c", + runAfter: []string{"validator-b"}, + enabled: true, + }, + } + resolver = validator.NewDependencyResolver(validators) + }) + + It("should detect the circular dependency chain and return an error", func() { + _, err := resolver.ResolveExecutionGroups() + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("circular dependency")) + }) + }) + + Context("with missing dependency", func() { + BeforeEach(func() { + validators = []validator.Validator{ + &MockValidator{ + name: "validator-a", + runAfter: []string{"non-existent"}, + enabled: true, + }, + } + resolver = validator.NewDependencyResolver(validators) + }) + + It("should handle missing dependencies gracefully", func() { + groups, err := resolver.ResolveExecutionGroups() + Expect(err).NotTo(HaveOccurred()) + // Missing dependencies are ignored, validator runs at level 0 + Expect(groups).To(HaveLen(1)) + Expect(groups[0].Level).To(Equal(0)) + }) + }) + + Context("with empty validator list", func() { + BeforeEach(func() { + validators = []validator.Validator{} + resolver = validator.NewDependencyResolver(validators) + }) + + It("should return empty groups", func() { + groups, err := resolver.ResolveExecutionGroups() + Expect(err).NotTo(HaveOccurred()) + Expect(groups).To(BeEmpty()) + }) + }) + }) + + Describe("ToMermaid", func() { + Context("with validators that have no dependencies", func() { + BeforeEach(func() { + validators = []validator.Validator{ + &MockValidator{name: "validator-a", runAfter: []string{}, enabled: true}, + &MockValidator{name: "validator-b", runAfter: []string{}, enabled: true}, + } + resolver = validator.NewDependencyResolver(validators) + }) + + It("should render standalone nodes", func() { + mermaid := resolver.ToMermaid() + Expect(mermaid).To(ContainSubstring("flowchart TD")) + Expect(mermaid).To(ContainSubstring("validator-a")) + Expect(mermaid).To(ContainSubstring("validator-b")) + Expect(mermaid).NotTo(ContainSubstring("-->")) + }) + }) + + Context("with linear dependencies", func() { + BeforeEach(func() { + validators = []validator.Validator{ + &MockValidator{name: "validator-a", runAfter: []string{}, enabled: true}, + &MockValidator{name: "validator-b", runAfter: []string{"validator-a"}, enabled: true}, + &MockValidator{name: "validator-c", runAfter: []string{"validator-b"}, enabled: true}, + } + resolver = validator.NewDependencyResolver(validators) + }) + + It("should render dependency arrows", func() { + mermaid := resolver.ToMermaid() + Expect(mermaid).To(ContainSubstring("flowchart TD")) + Expect(mermaid).To(ContainSubstring("validator-b --> validator-a")) + Expect(mermaid).To(ContainSubstring("validator-c --> validator-b")) + }) + }) + + Context("with complex dependencies", func() { + BeforeEach(func() { + validators = []validator.Validator{ + &MockValidator{name: "wif-check", runAfter: []string{}, enabled: true}, + &MockValidator{name: "api-enabled", runAfter: []string{"wif-check"}, enabled: true}, + &MockValidator{name: "quota-check", runAfter: []string{"wif-check"}, enabled: true}, + &MockValidator{name: "network-check", runAfter: []string{"api-enabled", "quota-check"}, enabled: true}, + } + resolver = validator.NewDependencyResolver(validators) + }) + + It("should render all dependency relationships", func() { + mermaid := resolver.ToMermaid() + Expect(mermaid).To(ContainSubstring("flowchart TD")) + Expect(mermaid).To(ContainSubstring("api-enabled --> wif-check")) + Expect(mermaid).To(ContainSubstring("quota-check --> wif-check")) + Expect(mermaid).To(ContainSubstring("network-check --> api-enabled")) + Expect(mermaid).To(ContainSubstring("network-check --> quota-check")) + }) + }) + + Context("with missing dependency", func() { + BeforeEach(func() { + validators = []validator.Validator{ + &MockValidator{name: "validator-a", runAfter: []string{"non-existent"}, enabled: true}, + } + resolver = validator.NewDependencyResolver(validators) + }) + + It("should not render edges for missing dependencies", func() { + mermaid := resolver.ToMermaid() + Expect(mermaid).To(ContainSubstring("flowchart TD")) + Expect(mermaid).NotTo(ContainSubstring("-->")) + Expect(mermaid).NotTo(ContainSubstring("non-existent")) + }) + }) + }) + + Describe("ToMermaidWithLevels", func() { + Context("with validators that have no dependencies", func() { + BeforeEach(func() { + validators = []validator.Validator{ + &MockValidator{name: "validator-a", runAfter: []string{}, enabled: true}, + &MockValidator{name: "validator-b", runAfter: []string{}, enabled: true}, + } + resolver = validator.NewDependencyResolver(validators) + }) + + It("should render all validators in Level 0 subgraph", func() { + groups, _ := resolver.ResolveExecutionGroups() + mermaid := resolver.ToMermaidWithLevels(groups) + + Expect(mermaid).To(ContainSubstring("flowchart TD")) + Expect(mermaid).To(ContainSubstring("subgraph \"Level 0 - 2 Validators in Parallel\"")) + Expect(mermaid).To(ContainSubstring("validator-a")) + Expect(mermaid).To(ContainSubstring("validator-b")) + }) + }) + + Context("with linear dependencies", func() { + BeforeEach(func() { + validators = []validator.Validator{ + &MockValidator{name: "validator-a", runAfter: []string{}, enabled: true}, + &MockValidator{name: "validator-b", runAfter: []string{"validator-a"}, enabled: true}, + &MockValidator{name: "validator-c", runAfter: []string{"validator-b"}, enabled: true}, + } + resolver = validator.NewDependencyResolver(validators) + }) + + It("should render separate levels with dependency arrows", func() { + groups, _ := resolver.ResolveExecutionGroups() + mermaid := resolver.ToMermaidWithLevels(groups) + + Expect(mermaid).To(ContainSubstring("flowchart TD")) + Expect(mermaid).To(ContainSubstring("subgraph \"Level 0\"")) + Expect(mermaid).To(ContainSubstring("subgraph \"Level 1\"")) + Expect(mermaid).To(ContainSubstring("subgraph \"Level 2\"")) + Expect(mermaid).To(ContainSubstring("validator-b --> validator-a")) + Expect(mermaid).To(ContainSubstring("validator-c --> validator-b")) + }) + }) + + Context("with parallel dependencies", func() { + BeforeEach(func() { + validators = []validator.Validator{ + &MockValidator{name: "wif-check", runAfter: []string{}, enabled: true}, + &MockValidator{name: "api-enabled", runAfter: []string{"wif-check"}, enabled: true}, + &MockValidator{name: "quota-check", runAfter: []string{"wif-check"}, enabled: true}, + &MockValidator{name: "network-check", runAfter: []string{"wif-check"}, enabled: true}, + } + resolver = validator.NewDependencyResolver(validators) + }) + + It("should show parallel validators in the same level", func() { + groups, _ := resolver.ResolveExecutionGroups() + mermaid := resolver.ToMermaidWithLevels(groups) + + Expect(mermaid).To(ContainSubstring("flowchart TD")) + Expect(mermaid).To(ContainSubstring("subgraph \"Level 0\"")) + Expect(mermaid).To(ContainSubstring("subgraph \"Level 1 - 3 Validators in Parallel\"")) + Expect(mermaid).To(ContainSubstring("wif-check")) + Expect(mermaid).To(ContainSubstring("api-enabled")) + Expect(mermaid).To(ContainSubstring("quota-check")) + Expect(mermaid).To(ContainSubstring("network-check")) + }) + }) + + Context("with complex dependency graph", func() { + BeforeEach(func() { + validators = []validator.Validator{ + &MockValidator{name: "wif-check", runAfter: []string{}, enabled: true}, + &MockValidator{name: "api-enabled", runAfter: []string{"wif-check"}, enabled: true}, + &MockValidator{name: "quota-check", runAfter: []string{"wif-check"}, enabled: true}, + &MockValidator{name: "iam-check", runAfter: []string{"api-enabled"}, enabled: true}, + &MockValidator{name: "network-check", runAfter: []string{"api-enabled", "quota-check"}, enabled: true}, + } + resolver = validator.NewDependencyResolver(validators) + }) + + It("should render correct levels and all dependency edges", func() { + groups, _ := resolver.ResolveExecutionGroups() + mermaid := resolver.ToMermaidWithLevels(groups) + + Expect(mermaid).To(ContainSubstring("flowchart TD")) + Expect(mermaid).To(ContainSubstring("subgraph \"Level 0\"")) + Expect(mermaid).To(ContainSubstring("subgraph \"Level 1 - 2 Validators in Parallel\"")) + Expect(mermaid).To(ContainSubstring("subgraph \"Level 2 - 2 Validators in Parallel\"")) + Expect(mermaid).To(ContainSubstring("api-enabled --> wif-check")) + Expect(mermaid).To(ContainSubstring("quota-check --> wif-check")) + Expect(mermaid).To(ContainSubstring("iam-check --> api-enabled")) + Expect(mermaid).To(ContainSubstring("network-check --> api-enabled")) + Expect(mermaid).To(ContainSubstring("network-check --> quota-check")) + }) + }) + }) }) From 29c7d6b9b00af2999909b0bcfbe7f2d21f65464a Mon Sep 17 00:00:00 2001 From: dawang Date: Fri, 23 Jan 2026 18:12:43 +0800 Subject: [PATCH 11/14] Adjust format - replace tab with four spaces --- validator/cmd/validator/main.go | 240 +++---- validator/go.mod | 70 +- validator/pkg/config/config.go | 192 +++--- validator/pkg/config/config_suite_test.go | 10 +- validator/pkg/config/config_test.go | 440 ++++++------ validator/pkg/gcp/client.go | 344 +++++----- validator/pkg/gcp/client_test.go | 360 +++++----- validator/pkg/gcp/gcp_suite_test.go | 10 +- validator/pkg/validator/context.go | 140 ++-- validator/pkg/validator/context_test.go | 460 ++++++------- validator/pkg/validator/executor.go | 346 +++++----- validator/pkg/validator/executor_test.go | 648 +++++++++--------- validator/pkg/validator/registry.go | 70 +- validator/pkg/validator/registry_test.go | 244 +++---- validator/pkg/validator/resolver.go | 362 +++++----- validator/pkg/validator/validator.go | 150 ++-- .../pkg/validator/validator_suite_test.go | 10 +- validator/pkg/validators/api_enabled.go | 296 ++++---- validator/pkg/validators/api_enabled_test.go | 268 ++++---- validator/pkg/validators/quota_check.go | 126 ++-- validator/pkg/validators/quota_check_test.go | 180 ++--- .../pkg/validators/validators_suite_test.go | 10 +- .../integration/context_integration_test.go | 520 +++++++------- validator/test/integration/suite_test.go | 10 +- .../integration/validator_integration_test.go | 560 +++++++-------- 25 files changed, 3033 insertions(+), 3033 deletions(-) diff --git a/validator/cmd/validator/main.go b/validator/cmd/validator/main.go index 03e8ff1..7687e67 100644 --- a/validator/cmd/validator/main.go +++ b/validator/cmd/validator/main.go @@ -1,18 +1,18 @@ package main import ( - "context" - "encoding/json" - "log/slog" - "os" - "os/signal" - "strings" - "syscall" - "time" - - "validator/pkg/config" - "validator/pkg/validator" - _ "validator/pkg/validators" // Import to trigger init() registration + "context" + "encoding/json" + "log/slog" + "os" + "os/signal" + "strings" + "syscall" + "time" + + "validator/pkg/config" + "validator/pkg/validator" + _ "validator/pkg/validators" // Import to trigger init() registration ) @@ -20,116 +20,116 @@ import ( // It loads configuration, executes all enabled validators, aggregates results, // and writes the output to a JSON file. func main() { - // Load configuration first to get log level - cfg, err := config.LoadFromEnv() - if err != nil { - slog.Error("Configuration error", "error", err) - os.Exit(1) - } - - // Set up structured logger based on log level - logLevel := parseLogLevel(cfg.LogLevel) - logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{ - Level: logLevel, - })) - slog.SetDefault(logger) - - logger.Info("Starting GCP Validator") - logger.Info("Loaded configuration", - "gcp_project", cfg.ProjectID, - "results_path", cfg.ResultsPath, - "log_level", cfg.LogLevel, - "max_wait_time_seconds", cfg.MaxWaitTimeSeconds) - - // Validate disabled validators against registry - if len(cfg.DisabledValidators) > 0 { - logger.Info("Disabled validators", "validators", cfg.DisabledValidators) - for _, name := range cfg.DisabledValidators { - if _, exists := validator.Get(name); !exists { - logger.Warn("Unknown validator in DISABLED_VALIDATORS - will be ignored", - "validator", name, - "hint", "Check for typos. Run without DISABLED_VALIDATORS to see available validators.") - } - } - } - - // Create validation context with lazy client initialization - // Services will only be created when validators actually need them (least privilege) - vctx := validator.NewContext(cfg, logger) - - // Create context with timeout (max time for all validators) - validationTimeout := time.Duration(cfg.MaxWaitTimeSeconds) * time.Second - ctx, cancel := context.WithTimeout(context.Background(), validationTimeout) - defer cancel() - - // Set up signal handling for graceful shutdown - sigCh := make(chan os.Signal, 1) - signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM) - go func() { - sig := <-sigCh - logger.Warn("Received shutdown signal, cancelling validation", "signal", sig) - cancel() - }() - - // Execute all validators - executor := validator.NewExecutor(vctx, logger) - - results, err := executor.ExecuteAll(ctx) - if err != nil { - logger.Error("Validator execution failed", "error", err) - os.Exit(1) - } - - // Aggregate results - aggregated := validator.Aggregate(results) - - // Write to output file - outputFile := cfg.ResultsPath - logger.Info("Writing results", "path", outputFile) - - data, err := json.MarshalIndent(aggregated, "", " ") - if err != nil { - logger.Error("Failed to marshal results", "error", err) - os.Exit(1) - } - - // Ensure output directory exists - // Note: In Kubernetes, the /results directory should be pre-created via volumeMounts - if err := os.WriteFile(outputFile, data, 0644); err != nil { - logger.Error("Failed to write results", "error", err, "path", outputFile) - os.Exit(1) - } - - // Log the results content for easy access via logs (useful in containerized environments) - logger.Info("Results written successfully", - "path", outputFile, - "content", string(data)) - - logger.Info("Validation completed", - "status", aggregated.Status, - "message", aggregated.Message) - - // Exit with appropriate code - if aggregated.Status == validator.StatusFailure { - logger.Warn("Validation FAILED - exiting with code 1") - os.Exit(1) - } - - logger.Info("Validation PASSED - exiting with code 0") + // Load configuration first to get log level + cfg, err := config.LoadFromEnv() + if err != nil { + slog.Error("Configuration error", "error", err) + os.Exit(1) + } + + // Set up structured logger based on log level + logLevel := parseLogLevel(cfg.LogLevel) + logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{ + Level: logLevel, + })) + slog.SetDefault(logger) + + logger.Info("Starting GCP Validator") + logger.Info("Loaded configuration", + "gcp_project", cfg.ProjectID, + "results_path", cfg.ResultsPath, + "log_level", cfg.LogLevel, + "max_wait_time_seconds", cfg.MaxWaitTimeSeconds) + + // Validate disabled validators against registry + if len(cfg.DisabledValidators) > 0 { + logger.Info("Disabled validators", "validators", cfg.DisabledValidators) + for _, name := range cfg.DisabledValidators { + if _, exists := validator.Get(name); !exists { + logger.Warn("Unknown validator in DISABLED_VALIDATORS - will be ignored", + "validator", name, + "hint", "Check for typos. Run without DISABLED_VALIDATORS to see available validators.") + } + } + } + + // Create validation context with lazy client initialization + // Services will only be created when validators actually need them (least privilege) + vctx := validator.NewContext(cfg, logger) + + // Create context with timeout (max time for all validators) + validationTimeout := time.Duration(cfg.MaxWaitTimeSeconds) * time.Second + ctx, cancel := context.WithTimeout(context.Background(), validationTimeout) + defer cancel() + + // Set up signal handling for graceful shutdown + sigCh := make(chan os.Signal, 1) + signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM) + go func() { + sig := <-sigCh + logger.Warn("Received shutdown signal, cancelling validation", "signal", sig) + cancel() + }() + + // Execute all validators + executor := validator.NewExecutor(vctx, logger) + + results, err := executor.ExecuteAll(ctx) + if err != nil { + logger.Error("Validator execution failed", "error", err) + os.Exit(1) + } + + // Aggregate results + aggregated := validator.Aggregate(results) + + // Write to output file + outputFile := cfg.ResultsPath + logger.Info("Writing results", "path", outputFile) + + data, err := json.MarshalIndent(aggregated, "", " ") + if err != nil { + logger.Error("Failed to marshal results", "error", err) + os.Exit(1) + } + + // Ensure output directory exists + // Note: In Kubernetes, the /results directory should be pre-created via volumeMounts + if err := os.WriteFile(outputFile, data, 0644); err != nil { + logger.Error("Failed to write results", "error", err, "path", outputFile) + os.Exit(1) + } + + // Log the results content for easy access via logs (useful in containerized environments) + logger.Info("Results written successfully", + "path", outputFile, + "content", string(data)) + + logger.Info("Validation completed", + "status", aggregated.Status, + "message", aggregated.Message) + + // Exit with appropriate code + if aggregated.Status == validator.StatusFailure { + logger.Warn("Validation FAILED - exiting with code 1") + os.Exit(1) + } + + logger.Info("Validation PASSED - exiting with code 0") } // parseLogLevel converts string log level to slog.Level func parseLogLevel(level string) slog.Level { - switch strings.ToLower(level) { - case "debug": - return slog.LevelDebug - case "info": - return slog.LevelInfo - case "warn", "warning": - return slog.LevelWarn - case "error": - return slog.LevelError - default: - return slog.LevelInfo - } + switch strings.ToLower(level) { + case "debug": + return slog.LevelDebug + case "info": + return slog.LevelInfo + case "warn", "warning": + return slog.LevelWarn + case "error": + return slog.LevelError + default: + return slog.LevelInfo + } } diff --git a/validator/go.mod b/validator/go.mod index 706f420..42b7690 100644 --- a/validator/go.mod +++ b/validator/go.mod @@ -3,42 +3,42 @@ module validator go 1.25.0 require ( - github.com/onsi/ginkgo/v2 v2.27.5 - github.com/onsi/gomega v1.39.0 - golang.org/x/oauth2 v0.34.0 - google.golang.org/api v0.260.0 + github.com/onsi/ginkgo/v2 v2.27.5 + github.com/onsi/gomega v1.39.0 + golang.org/x/oauth2 v0.34.0 + google.golang.org/api v0.260.0 ) require ( - cloud.google.com/go/auth v0.18.0 // indirect - cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect - cloud.google.com/go/compute/metadata v0.9.0 // indirect - github.com/Masterminds/semver/v3 v3.4.0 // indirect - github.com/cespare/xxhash/v2 v2.3.0 // indirect - github.com/felixge/httpsnoop v1.0.4 // indirect - github.com/go-logr/logr v1.4.3 // indirect - github.com/go-logr/stdr v1.2.2 // indirect - github.com/go-task/slim-sprig/v3 v3.0.0 // indirect - github.com/google/go-cmp v0.7.0 // indirect - github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 // indirect - github.com/google/s2a-go v0.1.9 // indirect - github.com/google/uuid v1.6.0 // indirect - github.com/googleapis/enterprise-certificate-proxy v0.3.11 // indirect - github.com/googleapis/gax-go/v2 v2.16.0 // indirect - go.opentelemetry.io/auto/sdk v1.2.1 // indirect - go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.64.0 // indirect - go.opentelemetry.io/otel v1.39.0 // indirect - go.opentelemetry.io/otel/metric v1.39.0 // indirect - go.opentelemetry.io/otel/trace v1.39.0 // indirect - go.yaml.in/yaml/v3 v3.0.4 // indirect - golang.org/x/crypto v0.47.0 // indirect - golang.org/x/mod v0.31.0 // indirect - golang.org/x/net v0.49.0 // indirect - golang.org/x/sync v0.19.0 // indirect - golang.org/x/sys v0.40.0 // indirect - golang.org/x/text v0.33.0 // indirect - golang.org/x/tools v0.40.0 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20260114163908-3f89685c29c3 // indirect - google.golang.org/grpc v1.78.0 // indirect - google.golang.org/protobuf v1.36.11 // indirect + cloud.google.com/go/auth v0.18.0 // indirect + cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect + cloud.google.com/go/compute/metadata v0.9.0 // indirect + github.com/Masterminds/semver/v3 v3.4.0 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/felixge/httpsnoop v1.0.4 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/go-task/slim-sprig/v3 v3.0.0 // indirect + github.com/google/go-cmp v0.7.0 // indirect + github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 // indirect + github.com/google/s2a-go v0.1.9 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/googleapis/enterprise-certificate-proxy v0.3.11 // indirect + github.com/googleapis/gax-go/v2 v2.16.0 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.64.0 // indirect + go.opentelemetry.io/otel v1.39.0 // indirect + go.opentelemetry.io/otel/metric v1.39.0 // indirect + go.opentelemetry.io/otel/trace v1.39.0 // indirect + go.yaml.in/yaml/v3 v3.0.4 // indirect + golang.org/x/crypto v0.47.0 // indirect + golang.org/x/mod v0.31.0 // indirect + golang.org/x/net v0.49.0 // indirect + golang.org/x/sync v0.19.0 // indirect + golang.org/x/sys v0.40.0 // indirect + golang.org/x/text v0.33.0 // indirect + golang.org/x/tools v0.40.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260114163908-3f89685c29c3 // indirect + google.golang.org/grpc v1.78.0 // indirect + google.golang.org/protobuf v1.36.11 // indirect ) diff --git a/validator/pkg/config/config.go b/validator/pkg/config/config.go index 12e590c..68e0105 100644 --- a/validator/pkg/config/config.go +++ b/validator/pkg/config/config.go @@ -1,132 +1,132 @@ package config import ( - "fmt" - "os" - "strconv" - "strings" + "fmt" + "os" + "strconv" + "strings" ) // Config holds all configuration from environment variables type Config struct { - // Output - ResultsPath string // Default: /results/adapter-result.json + // Output + ResultsPath string // Default: /results/adapter-result.json - // GCP Configuration - ProjectID string // Required - GCPRegion string // Optional, for regional checks + // GCP Configuration + ProjectID string // Required + GCPRegion string // Optional, for regional checks - // Validator Control - DisabledValidators []string // Comma-separated list of validators to disable - StopOnFirstFailure bool // Default: false + // Validator Control + DisabledValidators []string // Comma-separated list of validators to disable + StopOnFirstFailure bool // Default: false - // API Validator Config - RequiredAPIs []string // Default: compute.googleapis.com, iam.googleapis.com, etc. + // API Validator Config + RequiredAPIs []string // Default: compute.googleapis.com, iam.googleapis.com, etc. - // Quota Validator Config (Post-MVP) - RequiredVCPUs int // Default: 0 (skip quota check) - RequiredDiskGB int - RequiredIPAddresses int + // Quota Validator Config (Post-MVP) + RequiredVCPUs int // Default: 0 (skip quota check) + RequiredDiskGB int + RequiredIPAddresses int - // Network Validator Config (Post-MVP) - VPCName string - SubnetName string + // Network Validator Config (Post-MVP) + VPCName string + SubnetName string - // Logging - LogLevel string // debug, info, warn, error + // Logging + LogLevel string // debug, info, warn, error - // Timeout - MaxWaitTimeSeconds int // Default: 300 (5 minutes), maximum time for all validators to complete + // Timeout + MaxWaitTimeSeconds int // Default: 300 (5 minutes), maximum time for all validators to complete } // LoadFromEnv loads configuration from environment variables func LoadFromEnv() (*Config, error) { - cfg := &Config{ - ResultsPath: getEnv("RESULTS_PATH", "/results/adapter-result.json"), - ProjectID: os.Getenv("PROJECT_ID"), - GCPRegion: getEnv("GCP_REGION", ""), - StopOnFirstFailure: getEnvBool("STOP_ON_FIRST_FAILURE", false), - LogLevel: getEnv("LOG_LEVEL", "info"), - RequiredVCPUs: getEnvInt("REQUIRED_VCPUS", 0), - RequiredDiskGB: getEnvInt("REQUIRED_DISK_GB", 0), - RequiredIPAddresses: getEnvInt("REQUIRED_IP_ADDRESSES", 0), - VPCName: getEnv("VPC_NAME", ""), - SubnetName: getEnv("SUBNET_NAME", ""), - MaxWaitTimeSeconds: getEnvInt("MAX_WAIT_TIME_SECONDS", 300), - } - - // Parse disabled validators - if disabled := os.Getenv("DISABLED_VALIDATORS"); disabled != "" { - cfg.DisabledValidators = strings.Split(disabled, ",") - // Trim whitespace - for i, v := range cfg.DisabledValidators { - cfg.DisabledValidators[i] = strings.TrimSpace(v) - } - } - - // Parse required APIs - defaultAPIs := []string{ - "compute.googleapis.com", - "iam.googleapis.com", - "cloudresourcemanager.googleapis.com", - } - if apis := os.Getenv("REQUIRED_APIS"); apis != "" { - cfg.RequiredAPIs = strings.Split(apis, ",") - // Trim whitespace - for i, v := range cfg.RequiredAPIs { - cfg.RequiredAPIs[i] = strings.TrimSpace(v) - } - } else { - cfg.RequiredAPIs = defaultAPIs - } - - // Validation - if cfg.ProjectID == "" { - return nil, fmt.Errorf("PROJECT_ID is required") - } - - return cfg, nil + cfg := &Config{ + ResultsPath: getEnv("RESULTS_PATH", "/results/adapter-result.json"), + ProjectID: os.Getenv("PROJECT_ID"), + GCPRegion: getEnv("GCP_REGION", ""), + StopOnFirstFailure: getEnvBool("STOP_ON_FIRST_FAILURE", false), + LogLevel: getEnv("LOG_LEVEL", "info"), + RequiredVCPUs: getEnvInt("REQUIRED_VCPUS", 0), + RequiredDiskGB: getEnvInt("REQUIRED_DISK_GB", 0), + RequiredIPAddresses: getEnvInt("REQUIRED_IP_ADDRESSES", 0), + VPCName: getEnv("VPC_NAME", ""), + SubnetName: getEnv("SUBNET_NAME", ""), + MaxWaitTimeSeconds: getEnvInt("MAX_WAIT_TIME_SECONDS", 300), + } + + // Parse disabled validators + if disabled := os.Getenv("DISABLED_VALIDATORS"); disabled != "" { + cfg.DisabledValidators = strings.Split(disabled, ",") + // Trim whitespace + for i, v := range cfg.DisabledValidators { + cfg.DisabledValidators[i] = strings.TrimSpace(v) + } + } + + // Parse required APIs + defaultAPIs := []string{ + "compute.googleapis.com", + "iam.googleapis.com", + "cloudresourcemanager.googleapis.com", + } + if apis := os.Getenv("REQUIRED_APIS"); apis != "" { + cfg.RequiredAPIs = strings.Split(apis, ",") + // Trim whitespace + for i, v := range cfg.RequiredAPIs { + cfg.RequiredAPIs[i] = strings.TrimSpace(v) + } + } else { + cfg.RequiredAPIs = defaultAPIs + } + + // Validation + if cfg.ProjectID == "" { + return nil, fmt.Errorf("PROJECT_ID is required") + } + + return cfg, nil } // getEnv retrieves an environment variable or returns a default value if not set func getEnv(key, defaultValue string) string { - if value := os.Getenv(key); value != "" { - return value - } - return defaultValue + if value := os.Getenv(key); value != "" { + return value + } + return defaultValue } // getEnvBool retrieves a boolean environment variable or returns a default value if not set or invalid func getEnvBool(key string, defaultValue bool) bool { - if value := os.Getenv(key); value != "" { - b, err := strconv.ParseBool(value) - if err == nil { - return b - } - } - return defaultValue + if value := os.Getenv(key); value != "" { + b, err := strconv.ParseBool(value) + if err == nil { + return b + } + } + return defaultValue } // getEnvInt retrieves an integer environment variable or returns a default value if not set or invalid func getEnvInt(key string, defaultValue int) int { - if value := os.Getenv(key); value != "" { - i, err := strconv.Atoi(value) - if err == nil { - return i - } - } - return defaultValue + if value := os.Getenv(key); value != "" { + i, err := strconv.Atoi(value) + if err == nil { + return i + } + } + return defaultValue } // IsValidatorEnabled checks if a validator should run // All validators are enabled by default unless explicitly disabled func (c *Config) IsValidatorEnabled(name string) bool { - // Check if explicitly disabled - for _, disabled := range c.DisabledValidators { - if disabled == name { - return false - } - } - // Not disabled = enabled - return true + // Check if explicitly disabled + for _, disabled := range c.DisabledValidators { + if disabled == name { + return false + } + } + // Not disabled = enabled + return true } diff --git a/validator/pkg/config/config_suite_test.go b/validator/pkg/config/config_suite_test.go index c6e29ba..91c7adc 100644 --- a/validator/pkg/config/config_suite_test.go +++ b/validator/pkg/config/config_suite_test.go @@ -1,13 +1,13 @@ package config_test import ( - "testing" + "testing" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" ) func TestConfig(t *testing.T) { - RegisterFailHandler(Fail) - RunSpecs(t, "Config Suite") + RegisterFailHandler(Fail) + RunSpecs(t, "Config Suite") } diff --git a/validator/pkg/config/config_test.go b/validator/pkg/config/config_test.go index 932596e..62e81aa 100644 --- a/validator/pkg/config/config_test.go +++ b/validator/pkg/config/config_test.go @@ -1,228 +1,228 @@ package config_test import ( - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" - "validator/pkg/config" + "validator/pkg/config" ) var _ = Describe("Config", func() { - BeforeEach(func() { - // Clear environment variables - GinkgoT().Setenv automatically restores them - envVars := []string{ - "RESULTS_PATH", "PROJECT_ID", "GCP_REGION", - "DISABLED_VALIDATORS", "STOP_ON_FIRST_FAILURE", - "REQUIRED_APIS", "LOG_LEVEL", - "REQUIRED_VCPUS", "REQUIRED_DISK_GB", "REQUIRED_IP_ADDRESSES", - "VPC_NAME", "SUBNET_NAME", "MAX_WAIT_TIME_SECONDS", - } - for _, key := range envVars { - GinkgoT().Setenv(key, "") - } - }) - - Describe("LoadFromEnv", func() { - Context("with minimal required configuration", func() { - BeforeEach(func() { - GinkgoT().Setenv("PROJECT_ID", "test-project-123") - }) - - It("should load config with defaults", func() { - cfg, err := config.LoadFromEnv() - Expect(err).NotTo(HaveOccurred()) - Expect(cfg.ProjectID).To(Equal("test-project-123")) - Expect(cfg.ResultsPath).To(Equal("/results/adapter-result.json")) - Expect(cfg.LogLevel).To(Equal("info")) - Expect(cfg.StopOnFirstFailure).To(BeFalse()) - }) - - It("should set default required APIs", func() { - cfg, err := config.LoadFromEnv() - Expect(err).NotTo(HaveOccurred()) - Expect(cfg.RequiredAPIs).To(ConsistOf( - "compute.googleapis.com", - "iam.googleapis.com", - "cloudresourcemanager.googleapis.com", - )) - }) - }) - - Context("without required PROJECT_ID", func() { - It("should return an error", func() { - _, err := config.LoadFromEnv() - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("PROJECT_ID is required")) - }) - }) - - Context("with custom configuration", func() { - BeforeEach(func() { - GinkgoT().Setenv("PROJECT_ID", "custom-project") - GinkgoT().Setenv("RESULTS_PATH", "/custom/path/results.json") - GinkgoT().Setenv("GCP_REGION", "us-central1") - GinkgoT().Setenv("LOG_LEVEL", "debug") - GinkgoT().Setenv("STOP_ON_FIRST_FAILURE", "true") - }) - - It("should load all custom values", func() { - cfg, err := config.LoadFromEnv() - Expect(err).NotTo(HaveOccurred()) - Expect(cfg.ProjectID).To(Equal("custom-project")) - Expect(cfg.ResultsPath).To(Equal("/custom/path/results.json")) - Expect(cfg.GCPRegion).To(Equal("us-central1")) - Expect(cfg.LogLevel).To(Equal("debug")) - Expect(cfg.StopOnFirstFailure).To(BeTrue()) - }) - }) - - Context("with disabled validators", func() { - BeforeEach(func() { - GinkgoT().Setenv("PROJECT_ID", "test-project") - GinkgoT().Setenv("DISABLED_VALIDATORS", "quota-check,network-check") - }) - - It("should parse the disabled validators list", func() { - cfg, err := config.LoadFromEnv() - Expect(err).NotTo(HaveOccurred()) - Expect(cfg.DisabledValidators).To(ConsistOf("quota-check", "network-check")) - }) - }) - - Context("with disabled validators containing whitespace", func() { - BeforeEach(func() { - GinkgoT().Setenv("PROJECT_ID", "test-project") - GinkgoT().Setenv("DISABLED_VALIDATORS", " quota-check , network-check ") - }) - - It("should trim whitespace from validator names", func() { - cfg, err := config.LoadFromEnv() - Expect(err).NotTo(HaveOccurred()) - Expect(cfg.DisabledValidators).To(ConsistOf("quota-check", "network-check")) - }) - }) - - Context("with custom required APIs", func() { - BeforeEach(func() { - GinkgoT().Setenv("PROJECT_ID", "test-project") - GinkgoT().Setenv("REQUIRED_APIS", "compute.googleapis.com,storage.googleapis.com") - }) - - It("should parse the required APIs list", func() { - cfg, err := config.LoadFromEnv() - Expect(err).NotTo(HaveOccurred()) - Expect(cfg.RequiredAPIs).To(ConsistOf("compute.googleapis.com", "storage.googleapis.com")) - }) - }) - - Context("with integer configurations", func() { - BeforeEach(func() { - GinkgoT().Setenv("PROJECT_ID", "test-project") - GinkgoT().Setenv("REQUIRED_VCPUS", "100") - GinkgoT().Setenv("REQUIRED_DISK_GB", "500") - GinkgoT().Setenv("REQUIRED_IP_ADDRESSES", "10") - }) - - It("should parse integer values", func() { - cfg, err := config.LoadFromEnv() - Expect(err).NotTo(HaveOccurred()) - Expect(cfg.RequiredVCPUs).To(Equal(100)) - Expect(cfg.RequiredDiskGB).To(Equal(500)) - Expect(cfg.RequiredIPAddresses).To(Equal(10)) - }) - }) - - Context("with invalid integer values", func() { - BeforeEach(func() { - GinkgoT().Setenv("PROJECT_ID", "test-project") - GinkgoT().Setenv("REQUIRED_VCPUS", "not-a-number") - }) - - It("should use default value for invalid integers", func() { - cfg, err := config.LoadFromEnv() - Expect(err).NotTo(HaveOccurred()) - Expect(cfg.RequiredVCPUs).To(Equal(0)) - }) - }) - - Context("with invalid boolean values", func() { - BeforeEach(func() { - GinkgoT().Setenv("PROJECT_ID", "test-project") - GinkgoT().Setenv("STOP_ON_FIRST_FAILURE", "not-a-bool") - }) - - It("should use default value for invalid booleans", func() { - cfg, err := config.LoadFromEnv() - Expect(err).NotTo(HaveOccurred()) - Expect(cfg.StopOnFirstFailure).To(BeFalse()) - }) - }) - - Context("with network validator config", func() { - BeforeEach(func() { - GinkgoT().Setenv("PROJECT_ID", "test-project") - GinkgoT().Setenv("VPC_NAME", "my-vpc") - GinkgoT().Setenv("SUBNET_NAME", "my-subnet") - }) - - It("should load network configuration", func() { - cfg, err := config.LoadFromEnv() - Expect(err).NotTo(HaveOccurred()) - Expect(cfg.VPCName).To(Equal("my-vpc")) - Expect(cfg.SubnetName).To(Equal("my-subnet")) - }) - }) - }) - - Describe("IsValidatorEnabled", func() { - var cfg *config.Config - - BeforeEach(func() { - GinkgoT().Setenv("PROJECT_ID", "test-project") - }) - - Context("with no disabled list", func() { - BeforeEach(func() { - var err error - cfg, err = config.LoadFromEnv() - Expect(err).NotTo(HaveOccurred()) - }) - - It("should enable all validators by default", func() { - Expect(cfg.IsValidatorEnabled("api-enabled")).To(BeTrue()) - Expect(cfg.IsValidatorEnabled("quota-check")).To(BeTrue()) - Expect(cfg.IsValidatorEnabled("any-validator")).To(BeTrue()) - }) - }) - - Context("with disabled validators list", func() { - BeforeEach(func() { - GinkgoT().Setenv("DISABLED_VALIDATORS", "quota-check") - var err error - cfg, err = config.LoadFromEnv() - Expect(err).NotTo(HaveOccurred()) - }) - - It("should disable validators in the list", func() { - Expect(cfg.IsValidatorEnabled("quota-check")).To(BeFalse()) - Expect(cfg.IsValidatorEnabled("api-enabled")).To(BeTrue()) - Expect(cfg.IsValidatorEnabled("network-check")).To(BeTrue()) - }) - }) - - Context("with multiple disabled validators", func() { - BeforeEach(func() { - GinkgoT().Setenv("DISABLED_VALIDATORS", "quota-check,network-check") - var err error - cfg, err = config.LoadFromEnv() - Expect(err).NotTo(HaveOccurred()) - }) - - It("should disable all validators in the list", func() { - Expect(cfg.IsValidatorEnabled("quota-check")).To(BeFalse()) - Expect(cfg.IsValidatorEnabled("network-check")).To(BeFalse()) - Expect(cfg.IsValidatorEnabled("api-enabled")).To(BeTrue()) - }) - }) - }) + BeforeEach(func() { + // Clear environment variables - GinkgoT().Setenv automatically restores them + envVars := []string{ + "RESULTS_PATH", "PROJECT_ID", "GCP_REGION", + "DISABLED_VALIDATORS", "STOP_ON_FIRST_FAILURE", + "REQUIRED_APIS", "LOG_LEVEL", + "REQUIRED_VCPUS", "REQUIRED_DISK_GB", "REQUIRED_IP_ADDRESSES", + "VPC_NAME", "SUBNET_NAME", "MAX_WAIT_TIME_SECONDS", + } + for _, key := range envVars { + GinkgoT().Setenv(key, "") + } + }) + + Describe("LoadFromEnv", func() { + Context("with minimal required configuration", func() { + BeforeEach(func() { + GinkgoT().Setenv("PROJECT_ID", "test-project-123") + }) + + It("should load config with defaults", func() { + cfg, err := config.LoadFromEnv() + Expect(err).NotTo(HaveOccurred()) + Expect(cfg.ProjectID).To(Equal("test-project-123")) + Expect(cfg.ResultsPath).To(Equal("/results/adapter-result.json")) + Expect(cfg.LogLevel).To(Equal("info")) + Expect(cfg.StopOnFirstFailure).To(BeFalse()) + }) + + It("should set default required APIs", func() { + cfg, err := config.LoadFromEnv() + Expect(err).NotTo(HaveOccurred()) + Expect(cfg.RequiredAPIs).To(ConsistOf( + "compute.googleapis.com", + "iam.googleapis.com", + "cloudresourcemanager.googleapis.com", + )) + }) + }) + + Context("without required PROJECT_ID", func() { + It("should return an error", func() { + _, err := config.LoadFromEnv() + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("PROJECT_ID is required")) + }) + }) + + Context("with custom configuration", func() { + BeforeEach(func() { + GinkgoT().Setenv("PROJECT_ID", "custom-project") + GinkgoT().Setenv("RESULTS_PATH", "/custom/path/results.json") + GinkgoT().Setenv("GCP_REGION", "us-central1") + GinkgoT().Setenv("LOG_LEVEL", "debug") + GinkgoT().Setenv("STOP_ON_FIRST_FAILURE", "true") + }) + + It("should load all custom values", func() { + cfg, err := config.LoadFromEnv() + Expect(err).NotTo(HaveOccurred()) + Expect(cfg.ProjectID).To(Equal("custom-project")) + Expect(cfg.ResultsPath).To(Equal("/custom/path/results.json")) + Expect(cfg.GCPRegion).To(Equal("us-central1")) + Expect(cfg.LogLevel).To(Equal("debug")) + Expect(cfg.StopOnFirstFailure).To(BeTrue()) + }) + }) + + Context("with disabled validators", func() { + BeforeEach(func() { + GinkgoT().Setenv("PROJECT_ID", "test-project") + GinkgoT().Setenv("DISABLED_VALIDATORS", "quota-check,network-check") + }) + + It("should parse the disabled validators list", func() { + cfg, err := config.LoadFromEnv() + Expect(err).NotTo(HaveOccurred()) + Expect(cfg.DisabledValidators).To(ConsistOf("quota-check", "network-check")) + }) + }) + + Context("with disabled validators containing whitespace", func() { + BeforeEach(func() { + GinkgoT().Setenv("PROJECT_ID", "test-project") + GinkgoT().Setenv("DISABLED_VALIDATORS", " quota-check , network-check ") + }) + + It("should trim whitespace from validator names", func() { + cfg, err := config.LoadFromEnv() + Expect(err).NotTo(HaveOccurred()) + Expect(cfg.DisabledValidators).To(ConsistOf("quota-check", "network-check")) + }) + }) + + Context("with custom required APIs", func() { + BeforeEach(func() { + GinkgoT().Setenv("PROJECT_ID", "test-project") + GinkgoT().Setenv("REQUIRED_APIS", "compute.googleapis.com,storage.googleapis.com") + }) + + It("should parse the required APIs list", func() { + cfg, err := config.LoadFromEnv() + Expect(err).NotTo(HaveOccurred()) + Expect(cfg.RequiredAPIs).To(ConsistOf("compute.googleapis.com", "storage.googleapis.com")) + }) + }) + + Context("with integer configurations", func() { + BeforeEach(func() { + GinkgoT().Setenv("PROJECT_ID", "test-project") + GinkgoT().Setenv("REQUIRED_VCPUS", "100") + GinkgoT().Setenv("REQUIRED_DISK_GB", "500") + GinkgoT().Setenv("REQUIRED_IP_ADDRESSES", "10") + }) + + It("should parse integer values", func() { + cfg, err := config.LoadFromEnv() + Expect(err).NotTo(HaveOccurred()) + Expect(cfg.RequiredVCPUs).To(Equal(100)) + Expect(cfg.RequiredDiskGB).To(Equal(500)) + Expect(cfg.RequiredIPAddresses).To(Equal(10)) + }) + }) + + Context("with invalid integer values", func() { + BeforeEach(func() { + GinkgoT().Setenv("PROJECT_ID", "test-project") + GinkgoT().Setenv("REQUIRED_VCPUS", "not-a-number") + }) + + It("should use default value for invalid integers", func() { + cfg, err := config.LoadFromEnv() + Expect(err).NotTo(HaveOccurred()) + Expect(cfg.RequiredVCPUs).To(Equal(0)) + }) + }) + + Context("with invalid boolean values", func() { + BeforeEach(func() { + GinkgoT().Setenv("PROJECT_ID", "test-project") + GinkgoT().Setenv("STOP_ON_FIRST_FAILURE", "not-a-bool") + }) + + It("should use default value for invalid booleans", func() { + cfg, err := config.LoadFromEnv() + Expect(err).NotTo(HaveOccurred()) + Expect(cfg.StopOnFirstFailure).To(BeFalse()) + }) + }) + + Context("with network validator config", func() { + BeforeEach(func() { + GinkgoT().Setenv("PROJECT_ID", "test-project") + GinkgoT().Setenv("VPC_NAME", "my-vpc") + GinkgoT().Setenv("SUBNET_NAME", "my-subnet") + }) + + It("should load network configuration", func() { + cfg, err := config.LoadFromEnv() + Expect(err).NotTo(HaveOccurred()) + Expect(cfg.VPCName).To(Equal("my-vpc")) + Expect(cfg.SubnetName).To(Equal("my-subnet")) + }) + }) + }) + + Describe("IsValidatorEnabled", func() { + var cfg *config.Config + + BeforeEach(func() { + GinkgoT().Setenv("PROJECT_ID", "test-project") + }) + + Context("with no disabled list", func() { + BeforeEach(func() { + var err error + cfg, err = config.LoadFromEnv() + Expect(err).NotTo(HaveOccurred()) + }) + + It("should enable all validators by default", func() { + Expect(cfg.IsValidatorEnabled("api-enabled")).To(BeTrue()) + Expect(cfg.IsValidatorEnabled("quota-check")).To(BeTrue()) + Expect(cfg.IsValidatorEnabled("any-validator")).To(BeTrue()) + }) + }) + + Context("with disabled validators list", func() { + BeforeEach(func() { + GinkgoT().Setenv("DISABLED_VALIDATORS", "quota-check") + var err error + cfg, err = config.LoadFromEnv() + Expect(err).NotTo(HaveOccurred()) + }) + + It("should disable validators in the list", func() { + Expect(cfg.IsValidatorEnabled("quota-check")).To(BeFalse()) + Expect(cfg.IsValidatorEnabled("api-enabled")).To(BeTrue()) + Expect(cfg.IsValidatorEnabled("network-check")).To(BeTrue()) + }) + }) + + Context("with multiple disabled validators", func() { + BeforeEach(func() { + GinkgoT().Setenv("DISABLED_VALIDATORS", "quota-check,network-check") + var err error + cfg, err = config.LoadFromEnv() + Expect(err).NotTo(HaveOccurred()) + }) + + It("should disable all validators in the list", func() { + Expect(cfg.IsValidatorEnabled("quota-check")).To(BeFalse()) + Expect(cfg.IsValidatorEnabled("network-check")).To(BeFalse()) + Expect(cfg.IsValidatorEnabled("api-enabled")).To(BeTrue()) + }) + }) + }) }) diff --git a/validator/pkg/gcp/client.go b/validator/pkg/gcp/client.go index 71b1407..5298758 100644 --- a/validator/pkg/gcp/client.go +++ b/validator/pkg/gcp/client.go @@ -1,227 +1,227 @@ package gcp import ( - "context" - "fmt" - "log/slog" - "net/http" - "time" - - "golang.org/x/oauth2/google" - "google.golang.org/api/cloudresourcemanager/v1" - "google.golang.org/api/compute/v1" - "google.golang.org/api/googleapi" - "google.golang.org/api/iam/v1" - "google.golang.org/api/monitoring/v3" - "google.golang.org/api/option" - "google.golang.org/api/serviceusage/v1" + "context" + "fmt" + "log/slog" + "net/http" + "time" + + "golang.org/x/oauth2/google" + "google.golang.org/api/cloudresourcemanager/v1" + "google.golang.org/api/compute/v1" + "google.golang.org/api/googleapi" + "google.golang.org/api/iam/v1" + "google.golang.org/api/monitoring/v3" + "google.golang.org/api/option" + "google.golang.org/api/serviceusage/v1" ) const ( - // Retry configuration - initialBackoff = 100 * time.Millisecond - maxBackoff = 30 * time.Second - maxRetries = 5 - - // Retryable HTTP status codes - statusRateLimited = 429 - statusServiceUnavail = 503 - statusInternalError = 500 + // Retry configuration + initialBackoff = 100 * time.Millisecond + maxBackoff = 30 * time.Second + maxRetries = 5 + + // Retryable HTTP status codes + statusRateLimited = 429 + statusServiceUnavail = 503 + statusInternalError = 500 ) // getDefaultClient creates an HTTP client with WIF authentication // Creates a new client for each call with the specified scopes // google.DefaultClient handles connection pooling and credential caching internally func getDefaultClient(ctx context.Context, scopes ...string) (*http.Client, error) { - return google.DefaultClient(ctx, scopes...) + return google.DefaultClient(ctx, scopes...) } // retryWithBackoff wraps GCP API calls with exponential backoff retry logic func retryWithBackoff(ctx context.Context, operation func() error) error { - var lastErr error - backoff := initialBackoff - - for attempt := 0; attempt < maxRetries; attempt++ { - if attempt > 0 { - // Calculate exponential backoff with jitter - if backoff < maxBackoff { - backoff = backoff * 2 - if backoff > maxBackoff { - backoff = maxBackoff - } - } - slog.Debug("Retrying GCP API call", "attempt", attempt, "backoff", backoff) - - select { - case <-time.After(backoff): - case <-ctx.Done(): - return fmt.Errorf("context cancelled during retry: %w", ctx.Err()) - } - } - - lastErr = operation() - if lastErr == nil { - return nil // Success - } - - // Check if error is retryable - if apiErr, ok := lastErr.(*googleapi.Error); ok { - // Retry on rate limit, service unavailable, and internal errors - if apiErr.Code == statusRateLimited || - apiErr.Code == statusServiceUnavail || - apiErr.Code == statusInternalError { - continue - } - // Don't retry on other errors (4xx client errors, etc.) - return lastErr - } - - // Retry on network/context errors - if ctx.Err() != nil { - return fmt.Errorf("context error: %w", ctx.Err()) - } - } - - return fmt.Errorf("max retries exceeded: %w", lastErr) + var lastErr error + backoff := initialBackoff + + for attempt := 0; attempt < maxRetries; attempt++ { + if attempt > 0 { + // Calculate exponential backoff with jitter + if backoff < maxBackoff { + backoff = backoff * 2 + if backoff > maxBackoff { + backoff = maxBackoff + } + } + slog.Debug("Retrying GCP API call", "attempt", attempt, "backoff", backoff) + + select { + case <-time.After(backoff): + case <-ctx.Done(): + return fmt.Errorf("context cancelled during retry: %w", ctx.Err()) + } + } + + lastErr = operation() + if lastErr == nil { + return nil // Success + } + + // Check if error is retryable + if apiErr, ok := lastErr.(*googleapi.Error); ok { + // Retry on rate limit, service unavailable, and internal errors + if apiErr.Code == statusRateLimited || + apiErr.Code == statusServiceUnavail || + apiErr.Code == statusInternalError { + continue + } + // Don't retry on other errors (4xx client errors, etc.) + return lastErr + } + + // Retry on network/context errors + if ctx.Err() != nil { + return fmt.Errorf("context error: %w", ctx.Err()) + } + } + + return fmt.Errorf("max retries exceeded: %w", lastErr) } // ClientFactory creates GCP service clients with WIF authentication type ClientFactory struct { - projectID string - logger *slog.Logger + projectID string + logger *slog.Logger } // NewClientFactory creates a new GCP client factory func NewClientFactory(projectID string, logger *slog.Logger) *ClientFactory { - return &ClientFactory{ - projectID: projectID, - logger: logger, - } + return &ClientFactory{ + projectID: projectID, + logger: logger, + } } // CreateComputeService creates a Compute Engine service client with minimal scopes func (f *ClientFactory) CreateComputeService(ctx context.Context) (*compute.Service, error) { - f.logger.Debug("Creating Compute Engine service client with WIF") - - // Use readonly scope for read-only operations (quota checks, list instances, etc.) - client, err := getDefaultClient(ctx, compute.ComputeReadonlyScope) - if err != nil { - return nil, fmt.Errorf("failed to create default client: %w", err) - } - - var svc *compute.Service - err = retryWithBackoff(ctx, func() error { - var createErr error - svc, createErr = compute.NewService(ctx, option.WithHTTPClient(client)) - return createErr - }) - if err != nil { - return nil, fmt.Errorf("failed to create compute service: %w", err) - } - - return svc, nil + f.logger.Debug("Creating Compute Engine service client with WIF") + + // Use readonly scope for read-only operations (quota checks, list instances, etc.) + client, err := getDefaultClient(ctx, compute.ComputeReadonlyScope) + if err != nil { + return nil, fmt.Errorf("failed to create default client: %w", err) + } + + var svc *compute.Service + err = retryWithBackoff(ctx, func() error { + var createErr error + svc, createErr = compute.NewService(ctx, option.WithHTTPClient(client)) + return createErr + }) + if err != nil { + return nil, fmt.Errorf("failed to create compute service: %w", err) + } + + return svc, nil } // CreateIAMService creates an IAM service client with minimal scopes func (f *ClientFactory) CreateIAMService(ctx context.Context) (*iam.Service, error) { - f.logger.Debug("Creating IAM service client with WIF") - - // Use readonly scope for validation (checking service accounts, roles, etc.) - client, err := getDefaultClient(ctx, "https://www.googleapis.com/auth/cloud-platform.read-only") - if err != nil { - return nil, fmt.Errorf("failed to create default client: %w", err) - } - - var svc *iam.Service - err = retryWithBackoff(ctx, func() error { - var createErr error - svc, createErr = iam.NewService(ctx, option.WithHTTPClient(client)) - return createErr - }) - if err != nil { - return nil, fmt.Errorf("failed to create IAM service: %w", err) - } - - return svc, nil + f.logger.Debug("Creating IAM service client with WIF") + + // Use readonly scope for validation (checking service accounts, roles, etc.) + client, err := getDefaultClient(ctx, "https://www.googleapis.com/auth/cloud-platform.read-only") + if err != nil { + return nil, fmt.Errorf("failed to create default client: %w", err) + } + + var svc *iam.Service + err = retryWithBackoff(ctx, func() error { + var createErr error + svc, createErr = iam.NewService(ctx, option.WithHTTPClient(client)) + return createErr + }) + if err != nil { + return nil, fmt.Errorf("failed to create IAM service: %w", err) + } + + return svc, nil } // CreateCloudResourceManagerService creates a Cloud Resource Manager service client with minimal scopes func (f *ClientFactory) CreateCloudResourceManagerService(ctx context.Context) (*cloudresourcemanager.Service, error) { - f.logger.Debug("Creating Cloud Resource Manager service client with WIF") - - // Use readonly scope for read-only project operations - client, err := getDefaultClient(ctx, cloudresourcemanager.CloudPlatformReadOnlyScope) - if err != nil { - return nil, fmt.Errorf("failed to create default client: %w", err) - } - - var svc *cloudresourcemanager.Service - err = retryWithBackoff(ctx, func() error { - var createErr error - svc, createErr = cloudresourcemanager.NewService(ctx, option.WithHTTPClient(client)) - return createErr - }) - if err != nil { - return nil, fmt.Errorf("failed to create cloud resource manager service: %w", err) - } - - return svc, nil + f.logger.Debug("Creating Cloud Resource Manager service client with WIF") + + // Use readonly scope for read-only project operations + client, err := getDefaultClient(ctx, cloudresourcemanager.CloudPlatformReadOnlyScope) + if err != nil { + return nil, fmt.Errorf("failed to create default client: %w", err) + } + + var svc *cloudresourcemanager.Service + err = retryWithBackoff(ctx, func() error { + var createErr error + svc, createErr = cloudresourcemanager.NewService(ctx, option.WithHTTPClient(client)) + return createErr + }) + if err != nil { + return nil, fmt.Errorf("failed to create cloud resource manager service: %w", err) + } + + return svc, nil } // CreateServiceUsageService creates a Service Usage service client with minimal scopes func (f *ClientFactory) CreateServiceUsageService(ctx context.Context) (*serviceusage.Service, error) { - f.logger.Debug("Creating Service Usage service client with WIF") - - // Use readonly scope for checking API enablement status - client, err := getDefaultClient(ctx, serviceusage.CloudPlatformReadOnlyScope) - if err != nil { - return nil, fmt.Errorf("failed to create default client: %w", err) - } - - var svc *serviceusage.Service - err = retryWithBackoff(ctx, func() error { - var createErr error - svc, createErr = serviceusage.NewService(ctx, option.WithHTTPClient(client)) - return createErr - }) - if err != nil { - return nil, fmt.Errorf("failed to create service usage service: %w", err) - } - - return svc, nil + f.logger.Debug("Creating Service Usage service client with WIF") + + // Use readonly scope for checking API enablement status + client, err := getDefaultClient(ctx, serviceusage.CloudPlatformReadOnlyScope) + if err != nil { + return nil, fmt.Errorf("failed to create default client: %w", err) + } + + var svc *serviceusage.Service + err = retryWithBackoff(ctx, func() error { + var createErr error + svc, createErr = serviceusage.NewService(ctx, option.WithHTTPClient(client)) + return createErr + }) + if err != nil { + return nil, fmt.Errorf("failed to create service usage service: %w", err) + } + + return svc, nil } // CreateMonitoringService creates a Monitoring service client with minimal scopes func (f *ClientFactory) CreateMonitoringService(ctx context.Context) (*monitoring.Service, error) { - f.logger.Debug("Creating Monitoring service client with WIF") - - // Use readonly scope for reading metrics/alerts - client, err := getDefaultClient(ctx, monitoring.MonitoringReadScope) - if err != nil { - return nil, fmt.Errorf("failed to create default client: %w", err) - } - - var svc *monitoring.Service - err = retryWithBackoff(ctx, func() error { - var createErr error - svc, createErr = monitoring.NewService(ctx, option.WithHTTPClient(client)) - return createErr - }) - if err != nil { - return nil, fmt.Errorf("failed to create monitoring service: %w", err) - } - - return svc, nil + f.logger.Debug("Creating Monitoring service client with WIF") + + // Use readonly scope for reading metrics/alerts + client, err := getDefaultClient(ctx, monitoring.MonitoringReadScope) + if err != nil { + return nil, fmt.Errorf("failed to create default client: %w", err) + } + + var svc *monitoring.Service + err = retryWithBackoff(ctx, func() error { + var createErr error + svc, createErr = monitoring.NewService(ctx, option.WithHTTPClient(client)) + return createErr + }) + if err != nil { + return nil, fmt.Errorf("failed to create monitoring service: %w", err) + } + + return svc, nil } // Test helpers - exported for testing purposes only // GetDefaultClientForTesting exposes getDefaultClient for testing func GetDefaultClientForTesting(ctx context.Context, scopes ...string) (*http.Client, error) { - return getDefaultClient(ctx, scopes...) + return getDefaultClient(ctx, scopes...) } // RetryWithBackoffForTesting exposes retryWithBackoff for testing func RetryWithBackoffForTesting(ctx context.Context, operation func() error) error { - return retryWithBackoff(ctx, operation) + return retryWithBackoff(ctx, operation) } diff --git a/validator/pkg/gcp/client_test.go b/validator/pkg/gcp/client_test.go index 7c424d6..bce2a8a 100644 --- a/validator/pkg/gcp/client_test.go +++ b/validator/pkg/gcp/client_test.go @@ -1,189 +1,189 @@ package gcp_test import ( - "context" - "errors" - "log/slog" - "time" + "context" + "errors" + "log/slog" + "time" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" - "google.golang.org/api/googleapi" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "google.golang.org/api/googleapi" - "validator/pkg/gcp" + "validator/pkg/gcp" ) var _ = Describe("GCP Client", func() { - Describe("getDefaultClient", func() { - Context("with different scopes", func() { - It("should create new clients for each scope", func() { - ctx := context.Background() - scopes1 := []string{"https://www.googleapis.com/auth/cloud-platform.read-only"} - scopes2 := []string{"https://www.googleapis.com/auth/compute.readonly"} - - // First call with scopes1 - client1, err1 := gcp.GetDefaultClientForTesting(ctx, scopes1...) - Expect(err1).NotTo(HaveOccurred()) - Expect(client1).NotTo(BeNil()) - - // Second call with scopes2 should return a different instance - client2, err2 := gcp.GetDefaultClientForTesting(ctx, scopes2...) - Expect(err2).NotTo(HaveOccurred()) - Expect(client2).NotTo(BeNil()) - Expect(client2).NotTo(BeIdenticalTo(client1), "Expected different client instances for different scopes") - }) - - It("should create valid clients", func() { - ctx := context.Background() - scopes := []string{"https://www.googleapis.com/auth/cloud-platform.read-only"} - - client, err := gcp.GetDefaultClientForTesting(ctx, scopes...) - Expect(err).NotTo(HaveOccurred()) - Expect(client).NotTo(BeNil()) - Expect(client.Transport).NotTo(BeNil()) - }) - }) - }) - - Describe("retryWithBackoff", func() { - var ctx context.Context - - BeforeEach(func() { - ctx = context.Background() - }) - - Context("when operation succeeds on first attempt", func() { - It("should return success without retrying", func() { - callCount := 0 - operation := func() error { - callCount++ - return nil - } - - err := gcp.RetryWithBackoffForTesting(ctx, operation) - Expect(err).NotTo(HaveOccurred()) - Expect(callCount).To(Equal(1), "Should only call once on success") - }) - }) - - Context("with retryable errors", func() { - DescribeTable("should retry based on error code", - func(errorCode int, shouldRetry bool, expectedAttempts int) { - callCount := 0 - operation := func() error { - callCount++ - return &googleapi.Error{Code: errorCode} - } - - err := gcp.RetryWithBackoffForTesting(ctx, operation) - Expect(err).To(HaveOccurred(), "Should return error") - Expect(callCount).To(Equal(expectedAttempts)) - }, - Entry("429 Rate Limit - should retry", 429, true, 5), - Entry("503 Service Unavailable - should retry", 503, true, 5), - Entry("500 Internal Error - should retry", 500, true, 5), - Entry("404 Not Found - should not retry", 404, false, 1), - Entry("403 Forbidden - should not retry", 403, false, 1), - ) - }) - - Context("when context is cancelled during retry", func() { - It("should stop retrying and return context error", func() { - ctx, cancel := context.WithCancel(context.Background()) - callCount := 0 - - operation := func() error { - callCount++ - if callCount == 2 { - cancel() // Cancel on second attempt - } - return &googleapi.Error{Code: 503} // Retryable error - } - - err := gcp.RetryWithBackoffForTesting(ctx, operation) - Expect(err).To(HaveOccurred()) - Expect(errors.Is(err, context.Canceled)).To(BeTrue(), "Should return context.Canceled error") - Expect(callCount).To(Equal(2), "Should have attempted twice before cancellation") - }) - }) - - Context("when context times out", func() { - It("should return deadline exceeded error", func() { - ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond) - defer cancel() - - operation := func() error { - return &googleapi.Error{Code: 503} // Keep retrying - } - - err := gcp.RetryWithBackoffForTesting(ctx, operation) - Expect(err).To(HaveOccurred()) - Expect(errors.Is(err, context.DeadlineExceeded)).To(BeTrue(), "Should return deadline exceeded error") - }) - }) - - Context("when max retries are exceeded", func() { - It("should return error after 5 attempts", func() { - callCount := 0 - operation := func() error { - callCount++ - return &googleapi.Error{Code: 503} // Always fail with retryable error - } - - err := gcp.RetryWithBackoffForTesting(ctx, operation) - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("max retries exceeded")) - Expect(callCount).To(Equal(5), "Should attempt 5 times (initial + 4 retries)") - }) - }) - - Context("with non-googleapi errors", func() { - It("should retry generic errors until max retries", func() { - callCount := 0 - operation := func() error { - callCount++ - return errors.New("generic error") - } - - err := gcp.RetryWithBackoffForTesting(ctx, operation) - Expect(err).To(HaveOccurred()) - Expect(callCount).To(Equal(5), "Should retry generic errors until max retries") - }) - }) - }) - - Describe("ClientFactory", func() { - var ( - projectID string - logger *slog.Logger - ) - - BeforeEach(func() { - projectID = "test-project" - logger = slog.Default() - }) - - Describe("NewClientFactory", func() { - It("should create a new factory with correct values", func() { - factory := gcp.NewClientFactory(projectID, logger) - Expect(factory).NotTo(BeNil()) - - // Note: We can't directly test private fields, but we can test behavior - // by using the factory to create services (which would fail if projectID is wrong) - }) - - It("should accept different project IDs", func() { - factory := gcp.NewClientFactory("my-test-project", logger) - Expect(factory).NotTo(BeNil()) - }) - }) - - // Note: Testing actual GCP service creation requires either: - // 1. Mocking google.DefaultClient (complex, requires dependency injection) - // 2. Integration tests with real GCP credentials - // 3. Using interfaces and dependency injection (architectural change) - // - // For now, we test the factory creation and leave service creation for integration tests. - // The CreateXXXService methods follow the same pattern, so testing one validates the pattern. - }) + Describe("getDefaultClient", func() { + Context("with different scopes", func() { + It("should create new clients for each scope", func() { + ctx := context.Background() + scopes1 := []string{"https://www.googleapis.com/auth/cloud-platform.read-only"} + scopes2 := []string{"https://www.googleapis.com/auth/compute.readonly"} + + // First call with scopes1 + client1, err1 := gcp.GetDefaultClientForTesting(ctx, scopes1...) + Expect(err1).NotTo(HaveOccurred()) + Expect(client1).NotTo(BeNil()) + + // Second call with scopes2 should return a different instance + client2, err2 := gcp.GetDefaultClientForTesting(ctx, scopes2...) + Expect(err2).NotTo(HaveOccurred()) + Expect(client2).NotTo(BeNil()) + Expect(client2).NotTo(BeIdenticalTo(client1), "Expected different client instances for different scopes") + }) + + It("should create valid clients", func() { + ctx := context.Background() + scopes := []string{"https://www.googleapis.com/auth/cloud-platform.read-only"} + + client, err := gcp.GetDefaultClientForTesting(ctx, scopes...) + Expect(err).NotTo(HaveOccurred()) + Expect(client).NotTo(BeNil()) + Expect(client.Transport).NotTo(BeNil()) + }) + }) + }) + + Describe("retryWithBackoff", func() { + var ctx context.Context + + BeforeEach(func() { + ctx = context.Background() + }) + + Context("when operation succeeds on first attempt", func() { + It("should return success without retrying", func() { + callCount := 0 + operation := func() error { + callCount++ + return nil + } + + err := gcp.RetryWithBackoffForTesting(ctx, operation) + Expect(err).NotTo(HaveOccurred()) + Expect(callCount).To(Equal(1), "Should only call once on success") + }) + }) + + Context("with retryable errors", func() { + DescribeTable("should retry based on error code", + func(errorCode int, shouldRetry bool, expectedAttempts int) { + callCount := 0 + operation := func() error { + callCount++ + return &googleapi.Error{Code: errorCode} + } + + err := gcp.RetryWithBackoffForTesting(ctx, operation) + Expect(err).To(HaveOccurred(), "Should return error") + Expect(callCount).To(Equal(expectedAttempts)) + }, + Entry("429 Rate Limit - should retry", 429, true, 5), + Entry("503 Service Unavailable - should retry", 503, true, 5), + Entry("500 Internal Error - should retry", 500, true, 5), + Entry("404 Not Found - should not retry", 404, false, 1), + Entry("403 Forbidden - should not retry", 403, false, 1), + ) + }) + + Context("when context is cancelled during retry", func() { + It("should stop retrying and return context error", func() { + ctx, cancel := context.WithCancel(context.Background()) + callCount := 0 + + operation := func() error { + callCount++ + if callCount == 2 { + cancel() // Cancel on second attempt + } + return &googleapi.Error{Code: 503} // Retryable error + } + + err := gcp.RetryWithBackoffForTesting(ctx, operation) + Expect(err).To(HaveOccurred()) + Expect(errors.Is(err, context.Canceled)).To(BeTrue(), "Should return context.Canceled error") + Expect(callCount).To(Equal(2), "Should have attempted twice before cancellation") + }) + }) + + Context("when context times out", func() { + It("should return deadline exceeded error", func() { + ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond) + defer cancel() + + operation := func() error { + return &googleapi.Error{Code: 503} // Keep retrying + } + + err := gcp.RetryWithBackoffForTesting(ctx, operation) + Expect(err).To(HaveOccurred()) + Expect(errors.Is(err, context.DeadlineExceeded)).To(BeTrue(), "Should return deadline exceeded error") + }) + }) + + Context("when max retries are exceeded", func() { + It("should return error after 5 attempts", func() { + callCount := 0 + operation := func() error { + callCount++ + return &googleapi.Error{Code: 503} // Always fail with retryable error + } + + err := gcp.RetryWithBackoffForTesting(ctx, operation) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("max retries exceeded")) + Expect(callCount).To(Equal(5), "Should attempt 5 times (initial + 4 retries)") + }) + }) + + Context("with non-googleapi errors", func() { + It("should retry generic errors until max retries", func() { + callCount := 0 + operation := func() error { + callCount++ + return errors.New("generic error") + } + + err := gcp.RetryWithBackoffForTesting(ctx, operation) + Expect(err).To(HaveOccurred()) + Expect(callCount).To(Equal(5), "Should retry generic errors until max retries") + }) + }) + }) + + Describe("ClientFactory", func() { + var ( + projectID string + logger *slog.Logger + ) + + BeforeEach(func() { + projectID = "test-project" + logger = slog.Default() + }) + + Describe("NewClientFactory", func() { + It("should create a new factory with correct values", func() { + factory := gcp.NewClientFactory(projectID, logger) + Expect(factory).NotTo(BeNil()) + + // Note: We can't directly test private fields, but we can test behavior + // by using the factory to create services (which would fail if projectID is wrong) + }) + + It("should accept different project IDs", func() { + factory := gcp.NewClientFactory("my-test-project", logger) + Expect(factory).NotTo(BeNil()) + }) + }) + + // Note: Testing actual GCP service creation requires either: + // 1. Mocking google.DefaultClient (complex, requires dependency injection) + // 2. Integration tests with real GCP credentials + // 3. Using interfaces and dependency injection (architectural change) + // + // For now, we test the factory creation and leave service creation for integration tests. + // The CreateXXXService methods follow the same pattern, so testing one validates the pattern. + }) }) diff --git a/validator/pkg/gcp/gcp_suite_test.go b/validator/pkg/gcp/gcp_suite_test.go index 50fc1ff..d414a44 100644 --- a/validator/pkg/gcp/gcp_suite_test.go +++ b/validator/pkg/gcp/gcp_suite_test.go @@ -1,13 +1,13 @@ package gcp_test import ( - "testing" + "testing" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" ) func TestGCP(t *testing.T) { - RegisterFailHandler(Fail) - RunSpecs(t, "GCP Suite") + RegisterFailHandler(Fail) + RunSpecs(t, "GCP Suite") } diff --git a/validator/pkg/validator/context.go b/validator/pkg/validator/context.go index 59a9758..9efdb85 100644 --- a/validator/pkg/validator/context.go +++ b/validator/pkg/validator/context.go @@ -1,18 +1,18 @@ package validator import ( - "context" - "fmt" - "log/slog" + "context" + "fmt" + "log/slog" - "google.golang.org/api/cloudresourcemanager/v1" - "google.golang.org/api/compute/v1" - "google.golang.org/api/iam/v1" - "google.golang.org/api/monitoring/v3" - "google.golang.org/api/serviceusage/v1" + "google.golang.org/api/cloudresourcemanager/v1" + "google.golang.org/api/compute/v1" + "google.golang.org/api/iam/v1" + "google.golang.org/api/monitoring/v3" + "google.golang.org/api/serviceusage/v1" - "validator/pkg/config" - "validator/pkg/gcp" + "validator/pkg/config" + "validator/pkg/gcp" ) // Context provides shared resources and configuration to all validators @@ -21,97 +21,97 @@ import ( // - OAuth scopes are only requested for services that are actually used // - Disabled validators never trigger authentication for their services type Context struct { - // Configuration - Config *config.Config + // Configuration + Config *config.Config - // Client factory for creating GCP service clients - clientFactory *gcp.ClientFactory + // Client factory for creating GCP service clients + clientFactory *gcp.ClientFactory - // GCP Clients (lazily initialized, shared across validators) - // These are private to enforce use of getter methods - computeService *compute.Service - iamService *iam.Service - cloudResourceManagerSvc *cloudresourcemanager.Service - serviceUsageService *serviceusage.Service - monitoringService *monitoring.Service + // GCP Clients (lazily initialized, shared across validators) + // These are private to enforce use of getter methods + computeService *compute.Service + iamService *iam.Service + cloudResourceManagerSvc *cloudresourcemanager.Service + serviceUsageService *serviceusage.Service + monitoringService *monitoring.Service - // Shared state between validators - ProjectNumber int64 + // Shared state between validators + ProjectNumber int64 - // Results from previous validators (for dependency checking) - Results map[string]*Result + // Results from previous validators (for dependency checking) + Results map[string]*Result } // NewContext creates a new validation context with a client factory func NewContext(cfg *config.Config, logger *slog.Logger) *Context { - return &Context{ - Config: cfg, - clientFactory: gcp.NewClientFactory(cfg.ProjectID, logger), - Results: make(map[string]*Result), - } + return &Context{ + Config: cfg, + clientFactory: gcp.NewClientFactory(cfg.ProjectID, logger), + Results: make(map[string]*Result), + } } // GetComputeService returns the Compute Engine service, creating it lazily on first use // Only requests compute.readonly scope when a validator actually needs it func (c *Context) GetComputeService(ctx context.Context) (*compute.Service, error) { - if c.computeService == nil { - svc, err := c.clientFactory.CreateComputeService(ctx) - if err != nil { - return nil, fmt.Errorf("failed to create compute service: %w", err) - } - c.computeService = svc - } - return c.computeService, nil + if c.computeService == nil { + svc, err := c.clientFactory.CreateComputeService(ctx) + if err != nil { + return nil, fmt.Errorf("failed to create compute service: %w", err) + } + c.computeService = svc + } + return c.computeService, nil } // GetIAMService returns the IAM service, creating it lazily on first use // Only requests cloud-platform.read-only scope when a validator actually needs it func (c *Context) GetIAMService(ctx context.Context) (*iam.Service, error) { - if c.iamService == nil { - svc, err := c.clientFactory.CreateIAMService(ctx) - if err != nil { - return nil, fmt.Errorf("failed to create IAM service: %w", err) - } - c.iamService = svc - } - return c.iamService, nil + if c.iamService == nil { + svc, err := c.clientFactory.CreateIAMService(ctx) + if err != nil { + return nil, fmt.Errorf("failed to create IAM service: %w", err) + } + c.iamService = svc + } + return c.iamService, nil } // GetCloudResourceManagerService returns the Cloud Resource Manager service, creating it lazily on first use // Only requests cloudresourcemanager.readonly scope when a validator actually needs it func (c *Context) GetCloudResourceManagerService(ctx context.Context) (*cloudresourcemanager.Service, error) { - if c.cloudResourceManagerSvc == nil { - svc, err := c.clientFactory.CreateCloudResourceManagerService(ctx) - if err != nil { - return nil, fmt.Errorf("failed to create cloud resource manager service: %w", err) - } - c.cloudResourceManagerSvc = svc - } - return c.cloudResourceManagerSvc, nil + if c.cloudResourceManagerSvc == nil { + svc, err := c.clientFactory.CreateCloudResourceManagerService(ctx) + if err != nil { + return nil, fmt.Errorf("failed to create cloud resource manager service: %w", err) + } + c.cloudResourceManagerSvc = svc + } + return c.cloudResourceManagerSvc, nil } // GetServiceUsageService returns the Service Usage service, creating it lazily on first use // Only requests serviceusage.readonly scope when a validator actually needs it func (c *Context) GetServiceUsageService(ctx context.Context) (*serviceusage.Service, error) { - if c.serviceUsageService == nil { - svc, err := c.clientFactory.CreateServiceUsageService(ctx) - if err != nil { - return nil, fmt.Errorf("failed to create service usage service: %w", err) - } - c.serviceUsageService = svc - } - return c.serviceUsageService, nil + if c.serviceUsageService == nil { + svc, err := c.clientFactory.CreateServiceUsageService(ctx) + if err != nil { + return nil, fmt.Errorf("failed to create service usage service: %w", err) + } + c.serviceUsageService = svc + } + return c.serviceUsageService, nil } // GetMonitoringService returns the Monitoring service, creating it lazily on first use // Only requests monitoring.read scope when a validator actually needs it func (c *Context) GetMonitoringService(ctx context.Context) (*monitoring.Service, error) { - if c.monitoringService == nil { - svc, err := c.clientFactory.CreateMonitoringService(ctx) - if err != nil { - return nil, fmt.Errorf("failed to create monitoring service: %w", err) - } - c.monitoringService = svc - } - return c.monitoringService, nil + if c.monitoringService == nil { + svc, err := c.clientFactory.CreateMonitoringService(ctx) + if err != nil { + return nil, fmt.Errorf("failed to create monitoring service: %w", err) + } + c.monitoringService = svc + } + return c.monitoringService, nil } diff --git a/validator/pkg/validator/context_test.go b/validator/pkg/validator/context_test.go index d8fa4a6..66385f8 100644 --- a/validator/pkg/validator/context_test.go +++ b/validator/pkg/validator/context_test.go @@ -1,239 +1,239 @@ package validator_test import ( - "context" - "log/slog" - "os" - "sync" + "context" + "log/slog" + "os" + "sync" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" - "validator/pkg/config" - "validator/pkg/validator" + "validator/pkg/config" + "validator/pkg/validator" ) var _ = Describe("Context", func() { - var ( - cfg *config.Config - logger *slog.Logger - vctx *validator.Context - ) - - BeforeEach(func() { - // Set up minimal config with automatic cleanup - GinkgoT().Setenv("PROJECT_ID", "test-project-lazy-init") - - var err error - cfg, err = config.LoadFromEnv() - Expect(err).NotTo(HaveOccurred()) - - logger = slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{ - Level: slog.LevelWarn, - })) - }) - - Describe("NewContext", func() { - Context("with valid configuration", func() { - It("should create a new context with proper initialization", func() { - vctx = validator.NewContext(cfg, logger) - - Expect(vctx).NotTo(BeNil()) - Expect(vctx.Config).To(Equal(cfg)) - Expect(vctx.Results).NotTo(BeNil()) - Expect(vctx.Results).To(BeEmpty()) - }) - - It("should initialize with correct project ID", func() { - vctx = validator.NewContext(cfg, logger) - - Expect(vctx.Config.ProjectID).To(Equal("test-project-lazy-init")) - }) - - It("should create Results map ready for use", func() { - vctx = validator.NewContext(cfg, logger) - - // Should be able to add results without nil pointer panic - vctx.Results["test"] = &validator.Result{ - ValidatorName: "test", - Status: validator.StatusSuccess, - } - Expect(vctx.Results).To(HaveKey("test")) - }) - }) - - Context("with different configurations", func() { - It("should handle different project IDs", func() { - GinkgoT().Setenv("PROJECT_ID", "production-123") - cfg2, err := config.LoadFromEnv() - Expect(err).NotTo(HaveOccurred()) - - vctx = validator.NewContext(cfg2, logger) - Expect(vctx.Config.ProjectID).To(Equal("production-123")) - }) - }) - }) - - Describe("Lazy Initialization - Least Privilege Guarantee", func() { - BeforeEach(func() { - vctx = validator.NewContext(cfg, logger) - }) - - Context("GetServiceUsageService", func() { - It("should create service on first call", func() { - ctx := context.Background() - - // First call should create the service - svc1, err := vctx.GetServiceUsageService(ctx) - - // Note: This will fail without valid GCP credentials - // For unit tests, we expect an error but verify the method works - if err != nil { - // Expected in test environment without GCP credentials - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(Or( - ContainSubstring("could not find default credentials"), - ContainSubstring("ADC"), - ContainSubstring("GOOGLE_APPLICATION_CREDENTIALS"), - )) - } else { - // If credentials exist (e.g., in CI with WIF), verify service is created - Expect(svc1).NotTo(BeNil()) - } - }) - - }) - - Context("GetComputeService", func() { - It("should handle missing credentials gracefully", func() { - ctx := context.Background() - - svc, err := vctx.GetComputeService(ctx) - - if err != nil { - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("failed to create compute service")) - } else { - Expect(svc).NotTo(BeNil()) - } - }) - }) - - Context("GetIAMService", func() { - It("should handle missing credentials gracefully", func() { - ctx := context.Background() - - svc, err := vctx.GetIAMService(ctx) - - if err != nil { - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("failed to create IAM service")) - } else { - Expect(svc).NotTo(BeNil()) - } - }) - }) - - Context("GetCloudResourceManagerService", func() { - It("should handle missing credentials gracefully", func() { - ctx := context.Background() - - svc, err := vctx.GetCloudResourceManagerService(ctx) - - if err != nil { - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("failed to create cloud resource manager service")) - } else { - Expect(svc).NotTo(BeNil()) - } - }) - }) - - Context("GetMonitoringService", func() { - It("should handle missing credentials gracefully", func() { - ctx := context.Background() - - svc, err := vctx.GetMonitoringService(ctx) - - if err != nil { - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("failed to create monitoring service")) - } else { - Expect(svc).NotTo(BeNil()) - } - }) - }) - }) - - Describe("Context Cancellation", func() { - BeforeEach(func() { - vctx = validator.NewContext(cfg, logger) - }) - - - It("should not panic with cancelled context", func() { - ctx, cancel := context.WithCancel(context.Background()) - cancel() // Cancel immediately - - // Should not panic, even if it doesn't check context - Expect(func() { - _, _ = vctx.GetServiceUsageService(ctx) - }).NotTo(Panic()) - }) - }) - - Describe("Thread Safety", func() { - BeforeEach(func() { - vctx = validator.NewContext(cfg, logger) - }) - - - It("should handle concurrent access to different getters safely", func() { - ctx := context.Background() - var wg sync.WaitGroup - - // Launch multiple goroutines calling different getters - getters := []func(context.Context) (interface{}, error){ - func(ctx context.Context) (interface{}, error) { return vctx.GetComputeService(ctx) }, - func(ctx context.Context) (interface{}, error) { return vctx.GetIAMService(ctx) }, - func(ctx context.Context) (interface{}, error) { return vctx.GetServiceUsageService(ctx) }, - func(ctx context.Context) (interface{}, error) { return vctx.GetMonitoringService(ctx) }, - } - - for _, getter := range getters { - wg.Add(1) - go func(g func(context.Context) (interface{}, error)) { - defer GinkgoRecover() - defer wg.Done() - _, _ = g(ctx) - // Don't check error - just verify no race conditions/panics - }(getter) - } - - // Should complete without race conditions or panics - wg.Wait() - }) - }) - - Describe("Shared State", func() { - BeforeEach(func() { - vctx = validator.NewContext(cfg, logger) - }) - - It("should maintain ProjectNumber across operations", func() { - vctx.ProjectNumber = 12345678 - - Expect(vctx.ProjectNumber).To(Equal(int64(12345678))) - }) - - It("should maintain Results map across operations", func() { - vctx.Results["validator-1"] = &validator.Result{ - ValidatorName: "validator-1", - Status: validator.StatusSuccess, - } - - Expect(vctx.Results).To(HaveLen(1)) - Expect(vctx.Results["validator-1"].Status).To(Equal(validator.StatusSuccess)) - }) - }) + var ( + cfg *config.Config + logger *slog.Logger + vctx *validator.Context + ) + + BeforeEach(func() { + // Set up minimal config with automatic cleanup + GinkgoT().Setenv("PROJECT_ID", "test-project-lazy-init") + + var err error + cfg, err = config.LoadFromEnv() + Expect(err).NotTo(HaveOccurred()) + + logger = slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{ + Level: slog.LevelWarn, + })) + }) + + Describe("NewContext", func() { + Context("with valid configuration", func() { + It("should create a new context with proper initialization", func() { + vctx = validator.NewContext(cfg, logger) + + Expect(vctx).NotTo(BeNil()) + Expect(vctx.Config).To(Equal(cfg)) + Expect(vctx.Results).NotTo(BeNil()) + Expect(vctx.Results).To(BeEmpty()) + }) + + It("should initialize with correct project ID", func() { + vctx = validator.NewContext(cfg, logger) + + Expect(vctx.Config.ProjectID).To(Equal("test-project-lazy-init")) + }) + + It("should create Results map ready for use", func() { + vctx = validator.NewContext(cfg, logger) + + // Should be able to add results without nil pointer panic + vctx.Results["test"] = &validator.Result{ + ValidatorName: "test", + Status: validator.StatusSuccess, + } + Expect(vctx.Results).To(HaveKey("test")) + }) + }) + + Context("with different configurations", func() { + It("should handle different project IDs", func() { + GinkgoT().Setenv("PROJECT_ID", "production-123") + cfg2, err := config.LoadFromEnv() + Expect(err).NotTo(HaveOccurred()) + + vctx = validator.NewContext(cfg2, logger) + Expect(vctx.Config.ProjectID).To(Equal("production-123")) + }) + }) + }) + + Describe("Lazy Initialization - Least Privilege Guarantee", func() { + BeforeEach(func() { + vctx = validator.NewContext(cfg, logger) + }) + + Context("GetServiceUsageService", func() { + It("should create service on first call", func() { + ctx := context.Background() + + // First call should create the service + svc1, err := vctx.GetServiceUsageService(ctx) + + // Note: This will fail without valid GCP credentials + // For unit tests, we expect an error but verify the method works + if err != nil { + // Expected in test environment without GCP credentials + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(Or( + ContainSubstring("could not find default credentials"), + ContainSubstring("ADC"), + ContainSubstring("GOOGLE_APPLICATION_CREDENTIALS"), + )) + } else { + // If credentials exist (e.g., in CI with WIF), verify service is created + Expect(svc1).NotTo(BeNil()) + } + }) + + }) + + Context("GetComputeService", func() { + It("should handle missing credentials gracefully", func() { + ctx := context.Background() + + svc, err := vctx.GetComputeService(ctx) + + if err != nil { + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("failed to create compute service")) + } else { + Expect(svc).NotTo(BeNil()) + } + }) + }) + + Context("GetIAMService", func() { + It("should handle missing credentials gracefully", func() { + ctx := context.Background() + + svc, err := vctx.GetIAMService(ctx) + + if err != nil { + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("failed to create IAM service")) + } else { + Expect(svc).NotTo(BeNil()) + } + }) + }) + + Context("GetCloudResourceManagerService", func() { + It("should handle missing credentials gracefully", func() { + ctx := context.Background() + + svc, err := vctx.GetCloudResourceManagerService(ctx) + + if err != nil { + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("failed to create cloud resource manager service")) + } else { + Expect(svc).NotTo(BeNil()) + } + }) + }) + + Context("GetMonitoringService", func() { + It("should handle missing credentials gracefully", func() { + ctx := context.Background() + + svc, err := vctx.GetMonitoringService(ctx) + + if err != nil { + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("failed to create monitoring service")) + } else { + Expect(svc).NotTo(BeNil()) + } + }) + }) + }) + + Describe("Context Cancellation", func() { + BeforeEach(func() { + vctx = validator.NewContext(cfg, logger) + }) + + + It("should not panic with cancelled context", func() { + ctx, cancel := context.WithCancel(context.Background()) + cancel() // Cancel immediately + + // Should not panic, even if it doesn't check context + Expect(func() { + _, _ = vctx.GetServiceUsageService(ctx) + }).NotTo(Panic()) + }) + }) + + Describe("Thread Safety", func() { + BeforeEach(func() { + vctx = validator.NewContext(cfg, logger) + }) + + + It("should handle concurrent access to different getters safely", func() { + ctx := context.Background() + var wg sync.WaitGroup + + // Launch multiple goroutines calling different getters + getters := []func(context.Context) (interface{}, error){ + func(ctx context.Context) (interface{}, error) { return vctx.GetComputeService(ctx) }, + func(ctx context.Context) (interface{}, error) { return vctx.GetIAMService(ctx) }, + func(ctx context.Context) (interface{}, error) { return vctx.GetServiceUsageService(ctx) }, + func(ctx context.Context) (interface{}, error) { return vctx.GetMonitoringService(ctx) }, + } + + for _, getter := range getters { + wg.Add(1) + go func(g func(context.Context) (interface{}, error)) { + defer GinkgoRecover() + defer wg.Done() + _, _ = g(ctx) + // Don't check error - just verify no race conditions/panics + }(getter) + } + + // Should complete without race conditions or panics + wg.Wait() + }) + }) + + Describe("Shared State", func() { + BeforeEach(func() { + vctx = validator.NewContext(cfg, logger) + }) + + It("should maintain ProjectNumber across operations", func() { + vctx.ProjectNumber = 12345678 + + Expect(vctx.ProjectNumber).To(Equal(int64(12345678))) + }) + + It("should maintain Results map across operations", func() { + vctx.Results["validator-1"] = &validator.Result{ + ValidatorName: "validator-1", + Status: validator.StatusSuccess, + } + + Expect(vctx.Results).To(HaveLen(1)) + Expect(vctx.Results["validator-1"].Status).To(Equal(validator.StatusSuccess)) + }) + }) }) diff --git a/validator/pkg/validator/executor.go b/validator/pkg/validator/executor.go index c346566..ce7c791 100644 --- a/validator/pkg/validator/executor.go +++ b/validator/pkg/validator/executor.go @@ -1,193 +1,193 @@ package validator import ( - "context" - "fmt" - "log/slog" - "runtime/debug" - "sync" - "time" + "context" + "fmt" + "log/slog" + "runtime/debug" + "sync" + "time" ) // Executor orchestrates validator execution type Executor struct { - ctx *Context - logger *slog.Logger - mu sync.Mutex // Protects results map during parallel execution + ctx *Context + logger *slog.Logger + mu sync.Mutex // Protects results map during parallel execution } // NewExecutor creates a new executor func NewExecutor(ctx *Context, logger *slog.Logger) *Executor { - return &Executor{ - ctx: ctx, - logger: logger, - } + return &Executor{ + ctx: ctx, + logger: logger, + } } // ExecuteAll runs validators with dependency resolution and parallel execution func (e *Executor) ExecuteAll(ctx context.Context) ([]*Result, error) { - // 1. Get all registered validators - allValidators := GetAll() - - // 2. Filter enabled validators - enabledValidators := []Validator{} - for _, v := range allValidators { - if v.Enabled(e.ctx) { - enabledValidators = append(enabledValidators, v) - } else { - meta := v.Metadata() - e.logger.Info("Validator disabled, skipping", "validator", meta.Name) - } - } - - if len(enabledValidators) == 0 { - return nil, fmt.Errorf("no validators enabled") - } - - e.logger.Info("Found enabled validators", "count", len(enabledValidators)) - - // 3. Resolve dependencies and build execution plan - resolver := NewDependencyResolver(enabledValidators) - groups, err := resolver.ResolveExecutionGroups() - if err != nil { - return nil, fmt.Errorf("dependency resolution failed: %w", err) - } - - e.logger.Info("Execution plan created", "groups", len(groups)) - - // Log dependency graphs - e.logger.Debug("Validator dependency graph (raw dependencies):\n" + resolver.ToMermaid()) - e.logger.Info("Validator execution plan (with levels):\n" + resolver.ToMermaidWithLevels(groups)) - - for _, group := range groups { - e.logger.Debug("Execution group", - "level", group.Level, - "validators", len(group.Validators), - "mode", "parallel") - } - - // 4. Execute validators group by group - allResults := []*Result{} - for _, group := range groups { - e.logger.Info("Executing level", - "level", group.Level, - "validators", len(group.Validators)) - - groupResults := e.executeGroup(ctx, group) - allResults = append(allResults, groupResults...) - - // Check stop on failure - if e.ctx.Config.StopOnFirstFailure { - for _, result := range groupResults { - if result.Status == StatusFailure { - e.logger.Warn("Stopping due to failure", "validator", result.ValidatorName) - return allResults, nil - } - } - } - } - - return allResults, nil + // 1. Get all registered validators + allValidators := GetAll() + + // 2. Filter enabled validators + enabledValidators := []Validator{} + for _, v := range allValidators { + if v.Enabled(e.ctx) { + enabledValidators = append(enabledValidators, v) + } else { + meta := v.Metadata() + e.logger.Info("Validator disabled, skipping", "validator", meta.Name) + } + } + + if len(enabledValidators) == 0 { + return nil, fmt.Errorf("no validators enabled") + } + + e.logger.Info("Found enabled validators", "count", len(enabledValidators)) + + // 3. Resolve dependencies and build execution plan + resolver := NewDependencyResolver(enabledValidators) + groups, err := resolver.ResolveExecutionGroups() + if err != nil { + return nil, fmt.Errorf("dependency resolution failed: %w", err) + } + + e.logger.Info("Execution plan created", "groups", len(groups)) + + // Log dependency graphs + e.logger.Debug("Validator dependency graph (raw dependencies):\n" + resolver.ToMermaid()) + e.logger.Info("Validator execution plan (with levels):\n" + resolver.ToMermaidWithLevels(groups)) + + for _, group := range groups { + e.logger.Debug("Execution group", + "level", group.Level, + "validators", len(group.Validators), + "mode", "parallel") + } + + // 4. Execute validators group by group + allResults := []*Result{} + for _, group := range groups { + e.logger.Info("Executing level", + "level", group.Level, + "validators", len(group.Validators)) + + groupResults := e.executeGroup(ctx, group) + allResults = append(allResults, groupResults...) + + // Check stop on failure + if e.ctx.Config.StopOnFirstFailure { + for _, result := range groupResults { + if result.Status == StatusFailure { + e.logger.Warn("Stopping due to failure", "validator", result.ValidatorName) + return allResults, nil + } + } + } + } + + return allResults, nil } // executeGroup runs all validators in a group in parallel func (e *Executor) executeGroup(ctx context.Context, group ExecutionGroup) []*Result { - var wg sync.WaitGroup - results := make([]*Result, len(group.Validators)) - - for i, v := range group.Validators { - wg.Add(1) - go func(index int, validator Validator) { - defer wg.Done() - - // Add panic recovery to prevent one validator from crashing all validators - defer func() { - if r := recover(); r != nil { - stack := string(debug.Stack()) - meta := validator.Metadata() - e.logger.Error("Validator panicked", - "validator", meta.Name, - "panic", r, - "stack", stack) - - // Create failure result for panicked validator - panicResult := &Result{ - ValidatorName: meta.Name, - Status: StatusFailure, - Reason: "ValidatorPanic", - Message: fmt.Sprintf("Validator crashed: %v", r), - Details: map[string]interface{}{ - "panic": fmt.Sprint(r), - "panic_type": fmt.Sprintf("%T", r), - "stack": stack, - }, - Duration: 0, - Timestamp: time.Now().UTC(), - } - - // Thread-safe result storage - e.mu.Lock() - e.ctx.Results[meta.Name] = panicResult - results[index] = panicResult - e.mu.Unlock() - } - }() - - meta := validator.Metadata() - e.logger.Info("Running validator", "validator", meta.Name) - - start := time.Now() - result := validator.Validate(ctx, e.ctx) - - // Defensive nil check - validator.Validate should never return nil, - // but handle it to prevent nil pointer panics - if result == nil { - e.logger.Error("Validator returned nil result", - "validator", meta.Name) - result = &Result{ - ValidatorName: meta.Name, - Status: StatusFailure, - Reason: "NilResult", - Message: "Validator returned nil result (this is a validator implementation bug)", - Duration: time.Since(start), - Timestamp: time.Now().UTC(), - } - } else { - result.Duration = time.Since(start) - result.Timestamp = time.Now().UTC() - result.ValidatorName = meta.Name - } - - // Thread-safe result storage - e.mu.Lock() - e.ctx.Results[meta.Name] = result - e.mu.Unlock() - - results[index] = result - - // Log based on result status - logAttrs := []any{ - "validator", meta.Name, - "status", result.Status, - "duration", result.Duration, - } - switch result.Status { - case StatusFailure: - // Add reason and message for failures to help with debugging - logAttrs = append(logAttrs, - "reason", result.Reason, - "message", result.Message) - e.logger.Warn("Validator completed with failure", logAttrs...) - case StatusSkipped: - // Add reason for skipped validators - logAttrs = append(logAttrs, "reason", result.Reason) - e.logger.Info("Validator skipped", logAttrs...) - default: - e.logger.Info("Validator completed", logAttrs...) - } - }(i, v) - } - - wg.Wait() // Wait for all validators in this group - return results + var wg sync.WaitGroup + results := make([]*Result, len(group.Validators)) + + for i, v := range group.Validators { + wg.Add(1) + go func(index int, validator Validator) { + defer wg.Done() + + // Add panic recovery to prevent one validator from crashing all validators + defer func() { + if r := recover(); r != nil { + stack := string(debug.Stack()) + meta := validator.Metadata() + e.logger.Error("Validator panicked", + "validator", meta.Name, + "panic", r, + "stack", stack) + + // Create failure result for panicked validator + panicResult := &Result{ + ValidatorName: meta.Name, + Status: StatusFailure, + Reason: "ValidatorPanic", + Message: fmt.Sprintf("Validator crashed: %v", r), + Details: map[string]interface{}{ + "panic": fmt.Sprint(r), + "panic_type": fmt.Sprintf("%T", r), + "stack": stack, + }, + Duration: 0, + Timestamp: time.Now().UTC(), + } + + // Thread-safe result storage + e.mu.Lock() + e.ctx.Results[meta.Name] = panicResult + results[index] = panicResult + e.mu.Unlock() + } + }() + + meta := validator.Metadata() + e.logger.Info("Running validator", "validator", meta.Name) + + start := time.Now() + result := validator.Validate(ctx, e.ctx) + + // Defensive nil check - validator.Validate should never return nil, + // but handle it to prevent nil pointer panics + if result == nil { + e.logger.Error("Validator returned nil result", + "validator", meta.Name) + result = &Result{ + ValidatorName: meta.Name, + Status: StatusFailure, + Reason: "NilResult", + Message: "Validator returned nil result (this is a validator implementation bug)", + Duration: time.Since(start), + Timestamp: time.Now().UTC(), + } + } else { + result.Duration = time.Since(start) + result.Timestamp = time.Now().UTC() + result.ValidatorName = meta.Name + } + + // Thread-safe result storage + e.mu.Lock() + e.ctx.Results[meta.Name] = result + e.mu.Unlock() + + results[index] = result + + // Log based on result status + logAttrs := []any{ + "validator", meta.Name, + "status", result.Status, + "duration", result.Duration, + } + switch result.Status { + case StatusFailure: + // Add reason and message for failures to help with debugging + logAttrs = append(logAttrs, + "reason", result.Reason, + "message", result.Message) + e.logger.Warn("Validator completed with failure", logAttrs...) + case StatusSkipped: + // Add reason for skipped validators + logAttrs = append(logAttrs, "reason", result.Reason) + e.logger.Info("Validator skipped", logAttrs...) + default: + e.logger.Info("Validator completed", logAttrs...) + } + }(i, v) + } + + wg.Wait() // Wait for all validators in this group + return results } diff --git a/validator/pkg/validator/executor_test.go b/validator/pkg/validator/executor_test.go index a3af0c9..13b23b0 100644 --- a/validator/pkg/validator/executor_test.go +++ b/validator/pkg/validator/executor_test.go @@ -1,333 +1,333 @@ package validator_test import ( - "context" - "log/slog" - "os" - "sync" - "time" + "context" + "log/slog" + "os" + "sync" + "time" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" - "validator/pkg/config" - "validator/pkg/validator" + "validator/pkg/config" + "validator/pkg/validator" ) var _ = Describe("Executor", func() { - var ( - ctx context.Context - vctx *validator.Context - executor *validator.Executor - logger *slog.Logger - ) - - BeforeEach(func() { - ctx = context.Background() - logger = slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{ - Level: slog.LevelWarn, // Reduce noise in test output - })) - - // Clear the global registry before each test - validator.ClearRegistry() - - // Set up minimal config with automatic cleanup - GinkgoT().Setenv("PROJECT_ID", "test-project") - - cfg, err := config.LoadFromEnv() - Expect(err).NotTo(HaveOccurred()) - - // Use NewContext constructor for proper initialization - vctx = validator.NewContext(cfg, logger) - }) - - Describe("ExecuteAll", func() { - Context("with no validators registered", func() { - It("should return error when no validators are enabled", func() { - executor = validator.NewExecutor(vctx, logger) - results, err := executor.ExecuteAll(ctx) - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("no validators enabled")) - Expect(results).To(BeNil()) - }) - }) - - Context("with single validator", func() { - var mockValidator *MockValidator - - BeforeEach(func() { - mockValidator = &MockValidator{ - name: "test-validator", - enabled: true, - validateFunc: func(ctx context.Context, vctx *validator.Context) *validator.Result { - return &validator.Result{ - ValidatorName: "test-validator", - Status: validator.StatusSuccess, - Reason: "TestPassed", - Message: "Test validation successful", - } - }, - } - validator.Register(mockValidator) - }) - - It("should execute the validator", func() { - executor = validator.NewExecutor(vctx, logger) - results, err := executor.ExecuteAll(ctx) - Expect(err).NotTo(HaveOccurred()) - Expect(results).To(HaveLen(1)) - Expect(results[0].ValidatorName).To(Equal("test-validator")) - Expect(results[0].Status).To(Equal(validator.StatusSuccess)) - }) - - It("should store result in context", func() { - executor = validator.NewExecutor(vctx, logger) - _, err := executor.ExecuteAll(ctx) - Expect(err).NotTo(HaveOccurred()) - Expect(vctx.Results).To(HaveKey("test-validator")) - }) - - It("should set timestamp and duration", func() { - executor = validator.NewExecutor(vctx, logger) - results, err := executor.ExecuteAll(ctx) - Expect(err).NotTo(HaveOccurred()) - Expect(results[0].Timestamp).NotTo(BeZero()) - Expect(results[0].Duration).To(BeNumerically(">", 0)) - }) - }) - - Context("with disabled validator", func() { - var mockValidator *MockValidator - - BeforeEach(func() { - mockValidator = &MockValidator{ - name: "disabled-validator", - enabled: false, - } - validator.Register(mockValidator) - }) - - It("should skip disabled validators", func() { - executor = validator.NewExecutor(vctx, logger) - _, err := executor.ExecuteAll(ctx) - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("no validators enabled")) - }) - }) - - Context("with multiple independent validators", func() { - BeforeEach(func() { - for i := 1; i <= 3; i++ { - name := "validator-" + string(rune('a'+i-1)) - n := name // Capture loop variable for closure - validator.Register(&MockValidator{ - name: n, - enabled: true, - validateFunc: func(ctx context.Context, vctx *validator.Context) *validator.Result { - time.Sleep(10 * time.Millisecond) // Simulate work - return &validator.Result{ - ValidatorName: n, - Status: validator.StatusSuccess, - Reason: "Success", - Message: "Passed", - } - }, - }) - } - }) - - It("should execute all independent validators successfully", func() { - executor = validator.NewExecutor(vctx, logger) - results, err := executor.ExecuteAll(ctx) - - Expect(err).NotTo(HaveOccurred()) - Expect(results).To(HaveLen(3)) - // All validators should complete successfully - for _, result := range results { - Expect(result.Status).To(Equal(validator.StatusSuccess)) - } - }) - - It("should store all results in context", func() { - executor = validator.NewExecutor(vctx, logger) - _, err := executor.ExecuteAll(ctx) - Expect(err).NotTo(HaveOccurred()) - Expect(vctx.Results).To(HaveLen(3)) - }) - }) - - Context("with dependent validators", func() { - var executionOrder []string - var mu sync.Mutex - - BeforeEach(func() { - executionOrder = []string{} - - // Level 0 validator - validator.Register(&MockValidator{ - name: "validator-a", - runAfter: []string{}, - enabled: true, - validateFunc: func(ctx context.Context, vctx *validator.Context) *validator.Result { - mu.Lock() - executionOrder = append(executionOrder, "validator-a") - mu.Unlock() - return &validator.Result{ - ValidatorName: "validator-a", - Status: validator.StatusSuccess, - } - }, - }) - - // Level 1 validators (depend on validator-a) - for _, name := range []string{"validator-b", "validator-c"} { - n := name - validator.Register(&MockValidator{ - name: n, - runAfter: []string{"validator-a"}, - enabled: true, - validateFunc: func(ctx context.Context, vctx *validator.Context) *validator.Result { - mu.Lock() - executionOrder = append(executionOrder, n) - mu.Unlock() - return &validator.Result{ - ValidatorName: n, - Status: validator.StatusSuccess, - } - }, - }) - } - }) - - It("should execute validators in dependency order", func() { - executor = validator.NewExecutor(vctx, logger) - results, err := executor.ExecuteAll(ctx) - Expect(err).NotTo(HaveOccurred()) - Expect(results).To(HaveLen(3)) - - // validator-a should execute before b and c - Expect(executionOrder[0]).To(Equal("validator-a")) - Expect(executionOrder[1:]).To(ConsistOf("validator-b", "validator-c")) - }) - - It("should handle out-of-order registration (dependencies registered before dependents)", func() { - // Clear previous validators and reset execution order - validator.ClearRegistry() - executionOrder = []string{} - - // Register in reverse order: dependents (b, c) before dependency (a) - // This tests that the resolver can handle forward references - for _, name := range []string{"validator-b", "validator-c"} { - n := name - validator.Register(&MockValidator{ - name: n, - runAfter: []string{"validator-a"}, // depends on validator-a which isn't registered yet - enabled: true, - validateFunc: func(ctx context.Context, vctx *validator.Context) *validator.Result { - mu.Lock() - executionOrder = append(executionOrder, n) - mu.Unlock() - return &validator.Result{ - ValidatorName: n, - Status: validator.StatusSuccess, - } - }, - }) - } - - // Now register validator-a (after its dependents) - validator.Register(&MockValidator{ - name: "validator-a", - runAfter: []string{}, - enabled: true, - validateFunc: func(ctx context.Context, vctx *validator.Context) *validator.Result { - mu.Lock() - executionOrder = append(executionOrder, "validator-a") - mu.Unlock() - return &validator.Result{ - ValidatorName: "validator-a", - Status: validator.StatusSuccess, - } - }, - }) - - executor = validator.NewExecutor(vctx, logger) - results, err := executor.ExecuteAll(ctx) - Expect(err).NotTo(HaveOccurred()) - Expect(results).To(HaveLen(3)) - - // Regardless of registration order, validator-a should execute before b and c - Expect(executionOrder[0]).To(Equal("validator-a")) - Expect(executionOrder[1:]).To(ConsistOf("validator-b", "validator-c")) - }) - }) - - Context("with StopOnFirstFailure enabled", func() { - BeforeEach(func() { - vctx.Config.StopOnFirstFailure = true - - // First validator fails - validator.Register(&MockValidator{ - name: "failing-validator", - enabled: true, - validateFunc: func(ctx context.Context, vctx *validator.Context) *validator.Result { - return &validator.Result{ - ValidatorName: "failing-validator", - Status: validator.StatusFailure, - Reason: "TestFailure", - Message: "Intentional failure", - } - }, - }) - - // Second validator should not run - validator.Register(&MockValidator{ - name: "should-not-run", - runAfter: []string{"failing-validator"}, - enabled: true, - validateFunc: func(ctx context.Context, vctx *validator.Context) *validator.Result { - Fail("This validator should not execute") - return nil - }, - }) - }) - - It("should stop execution after first failure", func() { - executor = validator.NewExecutor(vctx, logger) - results, err := executor.ExecuteAll(ctx) - Expect(err).NotTo(HaveOccurred()) - Expect(results).To(HaveLen(1)) - Expect(results[0].Status).To(Equal(validator.StatusFailure)) - }) - }) - - Context("with validator that returns failure", func() { - BeforeEach(func() { - validator.Register(&MockValidator{ - name: "failing-validator", - enabled: true, - validateFunc: func(ctx context.Context, vctx *validator.Context) *validator.Result { - return &validator.Result{ - ValidatorName: "failing-validator", - Status: validator.StatusFailure, - Reason: "ValidationFailed", - Message: "Validation check failed", - Details: map[string]interface{}{ - "error": "Test error", - }, - } - }, - }) - }) - - It("should return the failure result", func() { - executor = validator.NewExecutor(vctx, logger) - results, err := executor.ExecuteAll(ctx) - Expect(err).NotTo(HaveOccurred()) - Expect(results).To(HaveLen(1)) - Expect(results[0].Status).To(Equal(validator.StatusFailure)) - Expect(results[0].Reason).To(Equal("ValidationFailed")) - }) - }) - }) + var ( + ctx context.Context + vctx *validator.Context + executor *validator.Executor + logger *slog.Logger + ) + + BeforeEach(func() { + ctx = context.Background() + logger = slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{ + Level: slog.LevelWarn, // Reduce noise in test output + })) + + // Clear the global registry before each test + validator.ClearRegistry() + + // Set up minimal config with automatic cleanup + GinkgoT().Setenv("PROJECT_ID", "test-project") + + cfg, err := config.LoadFromEnv() + Expect(err).NotTo(HaveOccurred()) + + // Use NewContext constructor for proper initialization + vctx = validator.NewContext(cfg, logger) + }) + + Describe("ExecuteAll", func() { + Context("with no validators registered", func() { + It("should return error when no validators are enabled", func() { + executor = validator.NewExecutor(vctx, logger) + results, err := executor.ExecuteAll(ctx) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("no validators enabled")) + Expect(results).To(BeNil()) + }) + }) + + Context("with single validator", func() { + var mockValidator *MockValidator + + BeforeEach(func() { + mockValidator = &MockValidator{ + name: "test-validator", + enabled: true, + validateFunc: func(ctx context.Context, vctx *validator.Context) *validator.Result { + return &validator.Result{ + ValidatorName: "test-validator", + Status: validator.StatusSuccess, + Reason: "TestPassed", + Message: "Test validation successful", + } + }, + } + validator.Register(mockValidator) + }) + + It("should execute the validator", func() { + executor = validator.NewExecutor(vctx, logger) + results, err := executor.ExecuteAll(ctx) + Expect(err).NotTo(HaveOccurred()) + Expect(results).To(HaveLen(1)) + Expect(results[0].ValidatorName).To(Equal("test-validator")) + Expect(results[0].Status).To(Equal(validator.StatusSuccess)) + }) + + It("should store result in context", func() { + executor = validator.NewExecutor(vctx, logger) + _, err := executor.ExecuteAll(ctx) + Expect(err).NotTo(HaveOccurred()) + Expect(vctx.Results).To(HaveKey("test-validator")) + }) + + It("should set timestamp and duration", func() { + executor = validator.NewExecutor(vctx, logger) + results, err := executor.ExecuteAll(ctx) + Expect(err).NotTo(HaveOccurred()) + Expect(results[0].Timestamp).NotTo(BeZero()) + Expect(results[0].Duration).To(BeNumerically(">", 0)) + }) + }) + + Context("with disabled validator", func() { + var mockValidator *MockValidator + + BeforeEach(func() { + mockValidator = &MockValidator{ + name: "disabled-validator", + enabled: false, + } + validator.Register(mockValidator) + }) + + It("should skip disabled validators", func() { + executor = validator.NewExecutor(vctx, logger) + _, err := executor.ExecuteAll(ctx) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("no validators enabled")) + }) + }) + + Context("with multiple independent validators", func() { + BeforeEach(func() { + for i := 1; i <= 3; i++ { + name := "validator-" + string(rune('a'+i-1)) + n := name // Capture loop variable for closure + validator.Register(&MockValidator{ + name: n, + enabled: true, + validateFunc: func(ctx context.Context, vctx *validator.Context) *validator.Result { + time.Sleep(10 * time.Millisecond) // Simulate work + return &validator.Result{ + ValidatorName: n, + Status: validator.StatusSuccess, + Reason: "Success", + Message: "Passed", + } + }, + }) + } + }) + + It("should execute all independent validators successfully", func() { + executor = validator.NewExecutor(vctx, logger) + results, err := executor.ExecuteAll(ctx) + + Expect(err).NotTo(HaveOccurred()) + Expect(results).To(HaveLen(3)) + // All validators should complete successfully + for _, result := range results { + Expect(result.Status).To(Equal(validator.StatusSuccess)) + } + }) + + It("should store all results in context", func() { + executor = validator.NewExecutor(vctx, logger) + _, err := executor.ExecuteAll(ctx) + Expect(err).NotTo(HaveOccurred()) + Expect(vctx.Results).To(HaveLen(3)) + }) + }) + + Context("with dependent validators", func() { + var executionOrder []string + var mu sync.Mutex + + BeforeEach(func() { + executionOrder = []string{} + + // Level 0 validator + validator.Register(&MockValidator{ + name: "validator-a", + runAfter: []string{}, + enabled: true, + validateFunc: func(ctx context.Context, vctx *validator.Context) *validator.Result { + mu.Lock() + executionOrder = append(executionOrder, "validator-a") + mu.Unlock() + return &validator.Result{ + ValidatorName: "validator-a", + Status: validator.StatusSuccess, + } + }, + }) + + // Level 1 validators (depend on validator-a) + for _, name := range []string{"validator-b", "validator-c"} { + n := name + validator.Register(&MockValidator{ + name: n, + runAfter: []string{"validator-a"}, + enabled: true, + validateFunc: func(ctx context.Context, vctx *validator.Context) *validator.Result { + mu.Lock() + executionOrder = append(executionOrder, n) + mu.Unlock() + return &validator.Result{ + ValidatorName: n, + Status: validator.StatusSuccess, + } + }, + }) + } + }) + + It("should execute validators in dependency order", func() { + executor = validator.NewExecutor(vctx, logger) + results, err := executor.ExecuteAll(ctx) + Expect(err).NotTo(HaveOccurred()) + Expect(results).To(HaveLen(3)) + + // validator-a should execute before b and c + Expect(executionOrder[0]).To(Equal("validator-a")) + Expect(executionOrder[1:]).To(ConsistOf("validator-b", "validator-c")) + }) + + It("should handle out-of-order registration (dependencies registered before dependents)", func() { + // Clear previous validators and reset execution order + validator.ClearRegistry() + executionOrder = []string{} + + // Register in reverse order: dependents (b, c) before dependency (a) + // This tests that the resolver can handle forward references + for _, name := range []string{"validator-b", "validator-c"} { + n := name + validator.Register(&MockValidator{ + name: n, + runAfter: []string{"validator-a"}, // depends on validator-a which isn't registered yet + enabled: true, + validateFunc: func(ctx context.Context, vctx *validator.Context) *validator.Result { + mu.Lock() + executionOrder = append(executionOrder, n) + mu.Unlock() + return &validator.Result{ + ValidatorName: n, + Status: validator.StatusSuccess, + } + }, + }) + } + + // Now register validator-a (after its dependents) + validator.Register(&MockValidator{ + name: "validator-a", + runAfter: []string{}, + enabled: true, + validateFunc: func(ctx context.Context, vctx *validator.Context) *validator.Result { + mu.Lock() + executionOrder = append(executionOrder, "validator-a") + mu.Unlock() + return &validator.Result{ + ValidatorName: "validator-a", + Status: validator.StatusSuccess, + } + }, + }) + + executor = validator.NewExecutor(vctx, logger) + results, err := executor.ExecuteAll(ctx) + Expect(err).NotTo(HaveOccurred()) + Expect(results).To(HaveLen(3)) + + // Regardless of registration order, validator-a should execute before b and c + Expect(executionOrder[0]).To(Equal("validator-a")) + Expect(executionOrder[1:]).To(ConsistOf("validator-b", "validator-c")) + }) + }) + + Context("with StopOnFirstFailure enabled", func() { + BeforeEach(func() { + vctx.Config.StopOnFirstFailure = true + + // First validator fails + validator.Register(&MockValidator{ + name: "failing-validator", + enabled: true, + validateFunc: func(ctx context.Context, vctx *validator.Context) *validator.Result { + return &validator.Result{ + ValidatorName: "failing-validator", + Status: validator.StatusFailure, + Reason: "TestFailure", + Message: "Intentional failure", + } + }, + }) + + // Second validator should not run + validator.Register(&MockValidator{ + name: "should-not-run", + runAfter: []string{"failing-validator"}, + enabled: true, + validateFunc: func(ctx context.Context, vctx *validator.Context) *validator.Result { + Fail("This validator should not execute") + return nil + }, + }) + }) + + It("should stop execution after first failure", func() { + executor = validator.NewExecutor(vctx, logger) + results, err := executor.ExecuteAll(ctx) + Expect(err).NotTo(HaveOccurred()) + Expect(results).To(HaveLen(1)) + Expect(results[0].Status).To(Equal(validator.StatusFailure)) + }) + }) + + Context("with validator that returns failure", func() { + BeforeEach(func() { + validator.Register(&MockValidator{ + name: "failing-validator", + enabled: true, + validateFunc: func(ctx context.Context, vctx *validator.Context) *validator.Result { + return &validator.Result{ + ValidatorName: "failing-validator", + Status: validator.StatusFailure, + Reason: "ValidationFailed", + Message: "Validation check failed", + Details: map[string]interface{}{ + "error": "Test error", + }, + } + }, + }) + }) + + It("should return the failure result", func() { + executor = validator.NewExecutor(vctx, logger) + results, err := executor.ExecuteAll(ctx) + Expect(err).NotTo(HaveOccurred()) + Expect(results).To(HaveLen(1)) + Expect(results[0].Status).To(Equal(validator.StatusFailure)) + Expect(results[0].Reason).To(Equal("ValidationFailed")) + }) + }) + }) }) diff --git a/validator/pkg/validator/registry.go b/validator/pkg/validator/registry.go index 9dc204d..b1f9e42 100644 --- a/validator/pkg/validator/registry.go +++ b/validator/pkg/validator/registry.go @@ -1,53 +1,53 @@ package validator import ( - "fmt" - "sync" + "fmt" + "sync" ) // Registry holds all registered validators var globalRegistry = NewRegistry() type Registry struct { - mu sync.RWMutex - validators map[string]Validator + mu sync.RWMutex + validators map[string]Validator } // NewRegistry creates a new validator registry func NewRegistry() *Registry { - return &Registry{ - validators: make(map[string]Validator), - } + return &Registry{ + validators: make(map[string]Validator), + } } // Register adds a validator to the registry func (r *Registry) Register(v Validator) { - r.mu.Lock() - defer r.mu.Unlock() + r.mu.Lock() + defer r.mu.Unlock() - meta := v.Metadata() - // Allow overwriting for testing purposes - r.validators[meta.Name] = v + meta := v.Metadata() + // Allow overwriting for testing purposes + r.validators[meta.Name] = v } // GetAll returns all registered validators func (r *Registry) GetAll() []Validator { - r.mu.RLock() - defer r.mu.RUnlock() + r.mu.RLock() + defer r.mu.RUnlock() - validators := make([]Validator, 0, len(r.validators)) - for _, v := range r.validators { - validators = append(validators, v) - } - return validators + validators := make([]Validator, 0, len(r.validators)) + for _, v := range r.validators { + validators = append(validators, v) + } + return validators } // Get retrieves a validator by name func (r *Registry) Get(name string) (Validator, bool) { - r.mu.RLock() - defer r.mu.RUnlock() - v, ok := r.validators[name] - return v, ok + r.mu.RLock() + defer r.mu.RUnlock() + v, ok := r.validators[name] + return v, ok } // Package-level functions for global registry @@ -55,29 +55,29 @@ func (r *Registry) Get(name string) (Validator, bool) { // Register adds a validator to the global registry // This is called from init() functions in validator implementations func Register(v Validator) { - meta := v.Metadata() - globalRegistry.mu.Lock() - defer globalRegistry.mu.Unlock() + meta := v.Metadata() + globalRegistry.mu.Lock() + defer globalRegistry.mu.Unlock() - if _, exists := globalRegistry.validators[meta.Name]; exists { - panic(fmt.Sprintf("validator already registered: %s", meta.Name)) - } - globalRegistry.validators[meta.Name] = v + if _, exists := globalRegistry.validators[meta.Name]; exists { + panic(fmt.Sprintf("validator already registered: %s", meta.Name)) + } + globalRegistry.validators[meta.Name] = v } // GetAll returns all registered validators from global registry func GetAll() []Validator { - return globalRegistry.GetAll() + return globalRegistry.GetAll() } // Get retrieves a validator by name from global registry func Get(name string) (Validator, bool) { - return globalRegistry.Get(name) + return globalRegistry.Get(name) } // ClearRegistry clears all validators from the global registry (for testing) func ClearRegistry() { - globalRegistry.mu.Lock() - defer globalRegistry.mu.Unlock() - globalRegistry.validators = make(map[string]Validator) + globalRegistry.mu.Lock() + defer globalRegistry.mu.Unlock() + globalRegistry.validators = make(map[string]Validator) } diff --git a/validator/pkg/validator/registry_test.go b/validator/pkg/validator/registry_test.go index 9bea53a..5eb8ac7 100644 --- a/validator/pkg/validator/registry_test.go +++ b/validator/pkg/validator/registry_test.go @@ -1,144 +1,144 @@ package validator_test import ( - "context" + "context" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" - "validator/pkg/validator" + "validator/pkg/validator" ) // Mock validator for testing type MockValidator struct { - name string - description string - runAfter []string - tags []string - enabled bool - validateFunc func(ctx context.Context, vctx *validator.Context) *validator.Result + name string + description string + runAfter []string + tags []string + enabled bool + validateFunc func(ctx context.Context, vctx *validator.Context) *validator.Result } func (m *MockValidator) Metadata() validator.ValidatorMetadata { - return validator.ValidatorMetadata{ - Name: m.name, - Description: m.description, - RunAfter: m.runAfter, - Tags: m.tags, - } + return validator.ValidatorMetadata{ + Name: m.name, + Description: m.description, + RunAfter: m.runAfter, + Tags: m.tags, + } } func (m *MockValidator) Enabled(ctx *validator.Context) bool { - return m.enabled + return m.enabled } func (m *MockValidator) Validate(ctx context.Context, vctx *validator.Context) *validator.Result { - if m.validateFunc != nil { - return m.validateFunc(ctx, vctx) - } - return &validator.Result{ - ValidatorName: m.name, - Status: validator.StatusSuccess, - Reason: "TestSuccess", - Message: "Test validation passed", - } + if m.validateFunc != nil { + return m.validateFunc(ctx, vctx) + } + return &validator.Result{ + ValidatorName: m.name, + Status: validator.StatusSuccess, + Reason: "TestSuccess", + Message: "Test validation passed", + } } var _ = Describe("Registry", func() { - var ( - testRegistry *validator.Registry - mockValidator1 *MockValidator - mockValidator2 *MockValidator - ) - - BeforeEach(func() { - testRegistry = validator.NewRegistry() - mockValidator1 = &MockValidator{ - name: "test-validator-1", - description: "First test validator", - runAfter: []string{}, - tags: []string{"test", "mock"}, - enabled: true, - } - mockValidator2 = &MockValidator{ - name: "test-validator-2", - description: "Second test validator", - runAfter: []string{"test-validator-1"}, - tags: []string{"test", "dependent"}, - enabled: true, - } - }) - - Describe("Register", func() { - Context("when registering a new validator", func() { - It("should add the validator to the registry", func() { - testRegistry.Register(mockValidator1) - validators := testRegistry.GetAll() - Expect(validators).To(HaveLen(1)) - Expect(validators[0].Metadata().Name).To(Equal("test-validator-1")) - }) - }) - - Context("when registering multiple validators", func() { - It("should add all validators to the registry", func() { - testRegistry.Register(mockValidator1) - testRegistry.Register(mockValidator2) - validators := testRegistry.GetAll() - Expect(validators).To(HaveLen(2)) - }) - }) - - Context("when registering a validator with duplicate name", func() { - It("should overwrite the existing validator", func() { - testRegistry.Register(mockValidator1) - duplicate := &MockValidator{ - name: "test-validator-1", - description: "Duplicate validator", - enabled: true, - } - testRegistry.Register(duplicate) - validators := testRegistry.GetAll() - Expect(validators).To(HaveLen(1)) - Expect(validators[0].Metadata().Description).To(Equal("Duplicate validator")) - }) - }) - }) - - Describe("GetAll", func() { - Context("when registry is empty", func() { - It("should return an empty slice", func() { - validators := testRegistry.GetAll() - Expect(validators).To(BeEmpty()) - }) - }) - - Context("when registry has validators", func() { - It("should return all registered validators", func() { - testRegistry.Register(mockValidator1) - testRegistry.Register(mockValidator2) - validators := testRegistry.GetAll() - Expect(validators).To(HaveLen(2)) - }) - }) - }) - - Describe("Get", func() { - BeforeEach(func() { - testRegistry.Register(mockValidator1) - testRegistry.Register(mockValidator2) - }) - - Context("when getting a validator by name", func() { - It("should return the validator if it exists", func() { - v, exists := testRegistry.Get("test-validator-1") - Expect(exists).To(BeTrue()) - Expect(v.Metadata().Name).To(Equal("test-validator-1")) - }) - - It("should return false if validator doesn't exist", func() { - _, exists := testRegistry.Get("non-existent") - Expect(exists).To(BeFalse()) - }) - }) - }) + var ( + testRegistry *validator.Registry + mockValidator1 *MockValidator + mockValidator2 *MockValidator + ) + + BeforeEach(func() { + testRegistry = validator.NewRegistry() + mockValidator1 = &MockValidator{ + name: "test-validator-1", + description: "First test validator", + runAfter: []string{}, + tags: []string{"test", "mock"}, + enabled: true, + } + mockValidator2 = &MockValidator{ + name: "test-validator-2", + description: "Second test validator", + runAfter: []string{"test-validator-1"}, + tags: []string{"test", "dependent"}, + enabled: true, + } + }) + + Describe("Register", func() { + Context("when registering a new validator", func() { + It("should add the validator to the registry", func() { + testRegistry.Register(mockValidator1) + validators := testRegistry.GetAll() + Expect(validators).To(HaveLen(1)) + Expect(validators[0].Metadata().Name).To(Equal("test-validator-1")) + }) + }) + + Context("when registering multiple validators", func() { + It("should add all validators to the registry", func() { + testRegistry.Register(mockValidator1) + testRegistry.Register(mockValidator2) + validators := testRegistry.GetAll() + Expect(validators).To(HaveLen(2)) + }) + }) + + Context("when registering a validator with duplicate name", func() { + It("should overwrite the existing validator", func() { + testRegistry.Register(mockValidator1) + duplicate := &MockValidator{ + name: "test-validator-1", + description: "Duplicate validator", + enabled: true, + } + testRegistry.Register(duplicate) + validators := testRegistry.GetAll() + Expect(validators).To(HaveLen(1)) + Expect(validators[0].Metadata().Description).To(Equal("Duplicate validator")) + }) + }) + }) + + Describe("GetAll", func() { + Context("when registry is empty", func() { + It("should return an empty slice", func() { + validators := testRegistry.GetAll() + Expect(validators).To(BeEmpty()) + }) + }) + + Context("when registry has validators", func() { + It("should return all registered validators", func() { + testRegistry.Register(mockValidator1) + testRegistry.Register(mockValidator2) + validators := testRegistry.GetAll() + Expect(validators).To(HaveLen(2)) + }) + }) + }) + + Describe("Get", func() { + BeforeEach(func() { + testRegistry.Register(mockValidator1) + testRegistry.Register(mockValidator2) + }) + + Context("when getting a validator by name", func() { + It("should return the validator if it exists", func() { + v, exists := testRegistry.Get("test-validator-1") + Expect(exists).To(BeTrue()) + Expect(v.Metadata().Name).To(Equal("test-validator-1")) + }) + + It("should return false if validator doesn't exist", func() { + _, exists := testRegistry.Get("non-existent") + Expect(exists).To(BeFalse()) + }) + }) + }) }) diff --git a/validator/pkg/validator/resolver.go b/validator/pkg/validator/resolver.go index 6cbaeeb..82bc971 100644 --- a/validator/pkg/validator/resolver.go +++ b/validator/pkg/validator/resolver.go @@ -1,220 +1,220 @@ package validator import ( - "fmt" - "sort" + "fmt" + "sort" ) // ExecutionGroup represents validators that can run in parallel type ExecutionGroup struct { - Level int // Execution level (0 = first, 1 = second, etc.) - Validators []Validator // Validators to run in parallel at this level + Level int // Execution level (0 = first, 1 = second, etc.) + Validators []Validator // Validators to run in parallel at this level } // DependencyResolver builds execution plan from validators type DependencyResolver struct { - validators map[string]Validator + validators map[string]Validator } // NewDependencyResolver creates a new resolver func NewDependencyResolver(validators []Validator) *DependencyResolver { - m := make(map[string]Validator) - for _, v := range validators { - meta := v.Metadata() - m[meta.Name] = v - } - return &DependencyResolver{validators: m} + m := make(map[string]Validator) + for _, v := range validators { + meta := v.Metadata() + m[meta.Name] = v + } + return &DependencyResolver{validators: m} } // ResolveExecutionGroups organizes validators into parallel execution groups // Validators with no dependencies or same dependencies can run in parallel func (r *DependencyResolver) ResolveExecutionGroups() ([]ExecutionGroup, error) { - // 1. Detect cycles - if err := r.detectCycles(); err != nil { - return nil, err - } - - // 2. Topological sort with level assignment - levels := r.assignLevels() - - // 3. Group by level - groups := make([]ExecutionGroup, 0) - for level := 0; ; level++ { - var validators []Validator - for _, v := range r.validators { - meta := v.Metadata() - if levels[meta.Name] == level { - validators = append(validators, v) - } - } - if len(validators) == 0 { - break - } - - // Sort alphabetically by name within the same level for deterministic execution - sort.Slice(validators, func(i, j int) bool { - return validators[i].Metadata().Name < validators[j].Metadata().Name - }) - - groups = append(groups, ExecutionGroup{ - Level: level, - Validators: validators, - }) - } - - return groups, nil + // 1. Detect cycles + if err := r.detectCycles(); err != nil { + return nil, err + } + + // 2. Topological sort with level assignment + levels := r.assignLevels() + + // 3. Group by level + groups := make([]ExecutionGroup, 0) + for level := 0; ; level++ { + var validators []Validator + for _, v := range r.validators { + meta := v.Metadata() + if levels[meta.Name] == level { + validators = append(validators, v) + } + } + if len(validators) == 0 { + break + } + + // Sort alphabetically by name within the same level for deterministic execution + sort.Slice(validators, func(i, j int) bool { + return validators[i].Metadata().Name < validators[j].Metadata().Name + }) + + groups = append(groups, ExecutionGroup{ + Level: level, + Validators: validators, + }) + } + + return groups, nil } // assignLevels performs topological sort and assigns execution levels func (r *DependencyResolver) assignLevels() map[string]int { - levels := make(map[string]int) - - // Recursive DFS to calculate max depth - var calcLevel func(name string) int - calcLevel = func(name string) int { - if level, ok := levels[name]; ok { - return level - } - - v := r.validators[name] - meta := v.Metadata() - - maxDepLevel := -1 - // Check dependencies from metadata - for _, dep := range meta.RunAfter { - if depValidator, exists := r.validators[dep]; exists { - depLevel := calcLevel(depValidator.Metadata().Name) - if depLevel > maxDepLevel { - maxDepLevel = depLevel - } - } - } - // If RunAfter is empty, maxDepLevel stays -1, so level = 0 - - level := maxDepLevel + 1 - levels[name] = level - return level - } - - for name := range r.validators { - calcLevel(name) - } - - return levels + levels := make(map[string]int) + + // Recursive DFS to calculate max depth + var calcLevel func(name string) int + calcLevel = func(name string) int { + if level, ok := levels[name]; ok { + return level + } + + v := r.validators[name] + meta := v.Metadata() + + maxDepLevel := -1 + // Check dependencies from metadata + for _, dep := range meta.RunAfter { + if depValidator, exists := r.validators[dep]; exists { + depLevel := calcLevel(depValidator.Metadata().Name) + if depLevel > maxDepLevel { + maxDepLevel = depLevel + } + } + } + // If RunAfter is empty, maxDepLevel stays -1, so level = 0 + + level := maxDepLevel + 1 + levels[name] = level + return level + } + + for name := range r.validators { + calcLevel(name) + } + + return levels } // detectCycles detects circular dependencies using DFS func (r *DependencyResolver) detectCycles() error { - visited := make(map[string]bool) - recStack := make(map[string]bool) - - var dfs func(name string) error - dfs = func(name string) error { - visited[name] = true - recStack[name] = true - - v := r.validators[name] - meta := v.Metadata() - - // Check all dependencies from metadata - for _, dep := range meta.RunAfter { - // Skip dependencies that don't exist (will be ignored in level assignment) - if _, exists := r.validators[dep]; !exists { - continue - } - - if !visited[dep] { - if err := dfs(dep); err != nil { - return err - } - } else if recStack[dep] { - return fmt.Errorf("circular dependency detected: %s -> %s", name, dep) - } - } - - recStack[name] = false - return nil - } - - for name := range r.validators { - if !visited[name] { - if err := dfs(name); err != nil { - return err - } - } - } - - return nil + visited := make(map[string]bool) + recStack := make(map[string]bool) + + var dfs func(name string) error + dfs = func(name string) error { + visited[name] = true + recStack[name] = true + + v := r.validators[name] + meta := v.Metadata() + + // Check all dependencies from metadata + for _, dep := range meta.RunAfter { + // Skip dependencies that don't exist (will be ignored in level assignment) + if _, exists := r.validators[dep]; !exists { + continue + } + + if !visited[dep] { + if err := dfs(dep); err != nil { + return err + } + } else if recStack[dep] { + return fmt.Errorf("circular dependency detected: %s -> %s", name, dep) + } + } + + recStack[name] = false + return nil + } + + for name := range r.validators { + if !visited[name] { + if err := dfs(name); err != nil { + return err + } + } + } + + return nil } // ToMermaid generates a Mermaid flowchart showing raw dependency relationships // This visualization shows which validators depend on others based on their RunAfter declarations func (r *DependencyResolver) ToMermaid() string { - var result string - result += "flowchart TD\n" - - // Collect all validators to ensure orphans are shown - allValidators := make(map[string]bool) - for name := range r.validators { - allValidators[name] = true - } - - // Track which validators have dependencies - hasDependencies := make(map[string]bool) - - // Add edges for all dependencies - for name, v := range r.validators { - meta := v.Metadata() - if len(meta.RunAfter) > 0 { - hasDependencies[name] = true - for _, dep := range meta.RunAfter { - // Only show edge if dependency exists in our validator set - if _, exists := r.validators[dep]; exists { - result += fmt.Sprintf(" %s --> %s\n", name, dep) - } - } - } - } - - // Add standalone nodes (validators with no dependencies) - for name := range allValidators { - if !hasDependencies[name] { - result += fmt.Sprintf(" %s\n", name) - } - } - - return result + var result string + result += "flowchart TD\n" + + // Collect all validators to ensure orphans are shown + allValidators := make(map[string]bool) + for name := range r.validators { + allValidators[name] = true + } + + // Track which validators have dependencies + hasDependencies := make(map[string]bool) + + // Add edges for all dependencies + for name, v := range r.validators { + meta := v.Metadata() + if len(meta.RunAfter) > 0 { + hasDependencies[name] = true + for _, dep := range meta.RunAfter { + // Only show edge if dependency exists in our validator set + if _, exists := r.validators[dep]; exists { + result += fmt.Sprintf(" %s --> %s\n", name, dep) + } + } + } + } + + // Add standalone nodes (validators with no dependencies) + for name := range allValidators { + if !hasDependencies[name] { + result += fmt.Sprintf(" %s\n", name) + } + } + + return result } // ToMermaidWithLevels generates a Mermaid flowchart showing the execution plan with levels // Each level is rendered as a subgraph showing which validators run in parallel func (r *DependencyResolver) ToMermaidWithLevels(groups []ExecutionGroup) string { - var result string - result += "flowchart TD\n" - - // Create subgraphs for each level - for _, group := range groups { - parallelInfo := "" - if len(group.Validators) > 1 { - parallelInfo = fmt.Sprintf(" - %d Validators in Parallel", len(group.Validators)) - } - result += fmt.Sprintf(" subgraph \"Level %d%s\"\n", group.Level, parallelInfo) - for _, v := range group.Validators { - meta := v.Metadata() - result += fmt.Sprintf(" %s\n", meta.Name) - } - result += " end\n\n" - } - - // Add dependency edges - for _, v := range r.validators { - meta := v.Metadata() - for _, dep := range meta.RunAfter { - if _, exists := r.validators[dep]; exists { - result += fmt.Sprintf(" %s --> %s\n", meta.Name, dep) - } - } - } - - return result + var result string + result += "flowchart TD\n" + + // Create subgraphs for each level + for _, group := range groups { + parallelInfo := "" + if len(group.Validators) > 1 { + parallelInfo = fmt.Sprintf(" - %d Validators in Parallel", len(group.Validators)) + } + result += fmt.Sprintf(" subgraph \"Level %d%s\"\n", group.Level, parallelInfo) + for _, v := range group.Validators { + meta := v.Metadata() + result += fmt.Sprintf(" %s\n", meta.Name) + } + result += " end\n\n" + } + + // Add dependency edges + for _, v := range r.validators { + meta := v.Metadata() + for _, dep := range meta.RunAfter { + if _, exists := r.validators[dep]; exists { + result += fmt.Sprintf(" %s --> %s\n", meta.Name, dep) + } + } + } + + return result } diff --git a/validator/pkg/validator/validator.go b/validator/pkg/validator/validator.go index a10ea0f..0413330 100644 --- a/validator/pkg/validator/validator.go +++ b/validator/pkg/validator/validator.go @@ -1,108 +1,108 @@ package validator import ( - "context" - "fmt" - "strings" - "time" + "context" + "fmt" + "strings" + "time" ) // ValidatorMetadata contains all validator configuration // This is the single source of truth for validator properties type ValidatorMetadata struct { - Name string // Unique identifier (e.g., "wif-check") - Description string // Human-readable description - RunAfter []string // Validators this should run after (dependencies) - Tags []string // For grouping/filtering (e.g., "mvp", "network", "quota") + Name string // Unique identifier (e.g., "wif-check") + Description string // Human-readable description + RunAfter []string // Validators this should run after (dependencies) + Tags []string // For grouping/filtering (e.g., "mvp", "network", "quota") } // Validator is the core interface all validators must implement type Validator interface { - // Metadata returns validator configuration (name, dependencies, etc.) - Metadata() ValidatorMetadata + // Metadata returns validator configuration (name, dependencies, etc.) + Metadata() ValidatorMetadata - // Enabled determines if this validator should run based on context/config - Enabled(ctx *Context) bool + // Enabled determines if this validator should run based on context/config + Enabled(ctx *Context) bool - // Validate performs the actual validation logic - Validate(ctx context.Context, vctx *Context) *Result + // Validate performs the actual validation logic + Validate(ctx context.Context, vctx *Context) *Result } // Status represents the validation outcome type Status string const ( - StatusSuccess Status = "success" - StatusFailure Status = "failure" - StatusSkipped Status = "skipped" + StatusSuccess Status = "success" + StatusFailure Status = "failure" + StatusSkipped Status = "skipped" ) // Result represents the outcome of a single validator type Result struct { - ValidatorName string `json:"validator_name"` - Status Status `json:"status"` - Reason string `json:"reason"` - Message string `json:"message"` - Details map[string]interface{} `json:"details,omitempty"` - Duration time.Duration `json:"duration_ns"` - Timestamp time.Time `json:"timestamp"` + ValidatorName string `json:"validator_name"` + Status Status `json:"status"` + Reason string `json:"reason"` + Message string `json:"message"` + Details map[string]interface{} `json:"details,omitempty"` + Duration time.Duration `json:"duration_ns"` + Timestamp time.Time `json:"timestamp"` } // AggregatedResult combines all validator results into the expected output format type AggregatedResult struct { - Status Status `json:"status"` - Reason string `json:"reason"` - Message string `json:"message"` - Details map[string]interface{} `json:"details"` + Status Status `json:"status"` + Reason string `json:"reason"` + Message string `json:"message"` + Details map[string]interface{} `json:"details"` } // Aggregate combines multiple validator results into final output func Aggregate(results []*Result) *AggregatedResult { - checksRun := len(results) - checksPassed := 0 - var failedChecks []string - var failureDescriptions []string - - // Single pass to collect all failure information - for _, r := range results { - switch r.Status { - case StatusSuccess: - checksPassed++ - case StatusFailure: - failedChecks = append(failedChecks, r.ValidatorName) - failureDescriptions = append(failureDescriptions, fmt.Sprintf("%s (%s)", r.ValidatorName, r.Reason)) - } - } - - details := map[string]interface{}{ - "checks_run": checksRun, - "checks_passed": checksPassed, - "timestamp": time.Now().UTC().Format(time.RFC3339), - "validators": results, - } - - if checksPassed == checksRun { - return &AggregatedResult{ - Status: StatusSuccess, - Reason: "ValidationPassed", - Message: "All GCP validation checks passed successfully", - Details: details, - } - } - - details["failed_checks"] = failedChecks - - // Build informative failure message with pass ratio and reasons - message := fmt.Sprintf("%d validation check(s) failed: %s. Passed: %d/%d", - len(failureDescriptions), - strings.Join(failureDescriptions, ", "), - checksPassed, - checksRun) - - return &AggregatedResult{ - Status: StatusFailure, - Reason: "ValidationFailed", - Message: message, - Details: details, - } + checksRun := len(results) + checksPassed := 0 + var failedChecks []string + var failureDescriptions []string + + // Single pass to collect all failure information + for _, r := range results { + switch r.Status { + case StatusSuccess: + checksPassed++ + case StatusFailure: + failedChecks = append(failedChecks, r.ValidatorName) + failureDescriptions = append(failureDescriptions, fmt.Sprintf("%s (%s)", r.ValidatorName, r.Reason)) + } + } + + details := map[string]interface{}{ + "checks_run": checksRun, + "checks_passed": checksPassed, + "timestamp": time.Now().UTC().Format(time.RFC3339), + "validators": results, + } + + if checksPassed == checksRun { + return &AggregatedResult{ + Status: StatusSuccess, + Reason: "ValidationPassed", + Message: "All GCP validation checks passed successfully", + Details: details, + } + } + + details["failed_checks"] = failedChecks + + // Build informative failure message with pass ratio and reasons + message := fmt.Sprintf("%d validation check(s) failed: %s. Passed: %d/%d", + len(failureDescriptions), + strings.Join(failureDescriptions, ", "), + checksPassed, + checksRun) + + return &AggregatedResult{ + Status: StatusFailure, + Reason: "ValidationFailed", + Message: message, + Details: details, + } } diff --git a/validator/pkg/validator/validator_suite_test.go b/validator/pkg/validator/validator_suite_test.go index b8fe992..e36b5a1 100644 --- a/validator/pkg/validator/validator_suite_test.go +++ b/validator/pkg/validator/validator_suite_test.go @@ -1,13 +1,13 @@ package validator_test import ( - "testing" + "testing" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" ) func TestValidator(t *testing.T) { - RegisterFailHandler(Fail) - RunSpecs(t, "Validator Suite") + RegisterFailHandler(Fail) + RunSpecs(t, "Validator Suite") } diff --git a/validator/pkg/validators/api_enabled.go b/validator/pkg/validators/api_enabled.go index 2850c83..a745b62 100644 --- a/validator/pkg/validators/api_enabled.go +++ b/validator/pkg/validators/api_enabled.go @@ -1,43 +1,43 @@ package validators import ( - "context" - "errors" - "fmt" - "log/slog" - "time" - - "google.golang.org/api/googleapi" - "validator/pkg/validator" + "context" + "errors" + "fmt" + "log/slog" + "time" + + "google.golang.org/api/googleapi" + "validator/pkg/validator" ) const ( - // Timeout for overall API validation - apiValidationTimeout = 2 * time.Minute - // Timeout for individual API check requests - apiRequestTimeout = 30 * time.Second + // Timeout for overall API validation + apiValidationTimeout = 2 * time.Minute + // Timeout for individual API check requests + apiRequestTimeout = 30 * time.Second ) // extractErrorReason extracts a structured error reason from GCP API errors // Prioritizes GCP-specific error reasons, falls back to HTTP status code func extractErrorReason(err error, fallbackReason string) string { - if err == nil { - return fallbackReason - } - - var apiErr *googleapi.Error - if errors.As(err, &apiErr) { - // First, try to get GCP-specific reason (more detailed) - if len(apiErr.Errors) > 0 && apiErr.Errors[0].Reason != "" { - return apiErr.Errors[0].Reason - } - - // No specific reason provided, return generic HTTP code - return fmt.Sprintf("HTTP_%d", apiErr.Code) - } - - // Not a GCP API error, use fallback - return fallbackReason + if err == nil { + return fallbackReason + } + + var apiErr *googleapi.Error + if errors.As(err, &apiErr) { + // First, try to get GCP-specific reason (more detailed) + if len(apiErr.Errors) > 0 && apiErr.Errors[0].Reason != "" { + return apiErr.Errors[0].Reason + } + + // No specific reason provided, return generic HTTP code + return fmt.Sprintf("HTTP_%d", apiErr.Code) + } + + // Not a GCP API error, use fallback + return fallbackReason } // APIEnabledValidator checks if required GCP APIs are enabled @@ -45,135 +45,135 @@ type APIEnabledValidator struct{} // init registers the APIEnabledValidator with the global validator registry func init() { - validator.Register(&APIEnabledValidator{}) + validator.Register(&APIEnabledValidator{}) } // Metadata returns the validator configuration including name, description, and dependencies func (v *APIEnabledValidator) Metadata() validator.ValidatorMetadata { - return validator.ValidatorMetadata{ - Name: "api-enabled", - Description: "Verify required GCP APIs are enabled in the target project", - RunAfter: []string{}, // No dependencies - WIF is implicitly validated when API calls succeed - Tags: []string{"mvp", "gcp-api"}, - } + return validator.ValidatorMetadata{ + Name: "api-enabled", + Description: "Verify required GCP APIs are enabled in the target project", + RunAfter: []string{}, // No dependencies - WIF is implicitly validated when API calls succeed + Tags: []string{"mvp", "gcp-api"}, + } } // Enabled determines if this validator should run based on configuration func (v *APIEnabledValidator) Enabled(ctx *validator.Context) bool { - return ctx.Config.IsValidatorEnabled("api-enabled") + return ctx.Config.IsValidatorEnabled("api-enabled") } // Validate performs the actual validation logic to check if required GCP APIs are enabled func (v *APIEnabledValidator) Validate(ctx context.Context, vctx *validator.Context) *validator.Result { - slog.Info("Checking if required GCP APIs are enabled") - - // Add timeout for overall validation - ctx, cancel := context.WithTimeout(ctx, apiValidationTimeout) - defer cancel() - - // Get Service Usage client from context (lazy initialization with least privilege) - // Only requests serviceusage.readonly scope when this validator actually runs - svc, err := vctx.GetServiceUsageService(ctx) - if err != nil { - // Log full error for debugging - slog.Error("Failed to get Service Usage client", - "error", err.Error(), - "project_id", vctx.Config.ProjectID) - - // Extract structured reason - reason := extractErrorReason(err, "ServiceUsageClientError") - - return &validator.Result{ - Status: validator.StatusFailure, - Reason: reason, - Message: fmt.Sprintf("Failed to get Service Usage client (check WIF configuration): %v", err), - Details: map[string]interface{}{ - //"error": err.Error(), - "error_type": fmt.Sprintf("%T", err), - "project_id": vctx.Config.ProjectID, - "hint": "Verify WIF annotation on KSA and IAM bindings for GSA", - }, - } - } - - // Check each required API - requiredAPIs := vctx.Config.RequiredAPIs - enabledAPIs := []string{} - disabledAPIs := []string{} - - for _, apiName := range requiredAPIs { - // Add per-request timeout - reqCtx, reqCancel := context.WithTimeout(ctx, apiRequestTimeout) - - serviceName := fmt.Sprintf("projects/%s/services/%s", vctx.Config.ProjectID, apiName) - - slog.Debug("Checking API", "api", apiName) - service, err := svc.Services.Get(serviceName).Context(reqCtx).Do() - reqCancel() // Clean up context - - if err != nil { - // Log full error for debugging - slog.Error("Failed to check API", - "api", apiName, - "error", err.Error(), - "project_id", vctx.Config.ProjectID, - "service_name", serviceName) - - // Extract structured reason - reason := extractErrorReason(err, "APICheckFailed") - - return &validator.Result{ - Status: validator.StatusFailure, - Reason: reason, - Message: fmt.Sprintf("Failed to check API %s: %v", apiName, err), - Details: map[string]interface{}{ - "api": apiName, - //"error": err.Error(), - "error_type": fmt.Sprintf("%T", err), - "project_id": vctx.Config.ProjectID, - "service_name": serviceName, - }, - } - } - - if service.State == "ENABLED" { - enabledAPIs = append(enabledAPIs, apiName) - slog.Debug("API is enabled", "api", apiName) - } else { - disabledAPIs = append(disabledAPIs, apiName) - slog.Warn("API is NOT enabled", "api", apiName, "state", service.State) - } - } - - // Check if any APIs are disabled - if len(disabledAPIs) > 0 { - return &validator.Result{ - Status: validator.StatusFailure, - Reason: "RequiredAPIsDisabled", - Message: fmt.Sprintf("%d required API(s) are not enabled", len(disabledAPIs)), - Details: map[string]interface{}{ - "disabled_apis": disabledAPIs, - "enabled_apis": enabledAPIs, - "project_id": vctx.Config.ProjectID, - "hint": "Enable APIs with: gcloud services enable ", - }, - } - } - - // Build success message based on whether APIs were checked - message := fmt.Sprintf("All %d required APIs are enabled", len(enabledAPIs)) - if len(enabledAPIs) == 0 { - message = "No required APIs to validate" - } - slog.Info(message) - - return &validator.Result{ - Status: validator.StatusSuccess, - Reason: "AllAPIsEnabled", - Message: message, - Details: map[string]interface{}{ - "enabled_apis": enabledAPIs, - "project_id": vctx.Config.ProjectID, - }, - } + slog.Info("Checking if required GCP APIs are enabled") + + // Add timeout for overall validation + ctx, cancel := context.WithTimeout(ctx, apiValidationTimeout) + defer cancel() + + // Get Service Usage client from context (lazy initialization with least privilege) + // Only requests serviceusage.readonly scope when this validator actually runs + svc, err := vctx.GetServiceUsageService(ctx) + if err != nil { + // Log full error for debugging + slog.Error("Failed to get Service Usage client", + "error", err.Error(), + "project_id", vctx.Config.ProjectID) + + // Extract structured reason + reason := extractErrorReason(err, "ServiceUsageClientError") + + return &validator.Result{ + Status: validator.StatusFailure, + Reason: reason, + Message: fmt.Sprintf("Failed to get Service Usage client (check WIF configuration): %v", err), + Details: map[string]interface{}{ + //"error": err.Error(), + "error_type": fmt.Sprintf("%T", err), + "project_id": vctx.Config.ProjectID, + "hint": "Verify WIF annotation on KSA and IAM bindings for GSA", + }, + } + } + + // Check each required API + requiredAPIs := vctx.Config.RequiredAPIs + enabledAPIs := []string{} + disabledAPIs := []string{} + + for _, apiName := range requiredAPIs { + // Add per-request timeout + reqCtx, reqCancel := context.WithTimeout(ctx, apiRequestTimeout) + + serviceName := fmt.Sprintf("projects/%s/services/%s", vctx.Config.ProjectID, apiName) + + slog.Debug("Checking API", "api", apiName) + service, err := svc.Services.Get(serviceName).Context(reqCtx).Do() + reqCancel() // Clean up context + + if err != nil { + // Log full error for debugging + slog.Error("Failed to check API", + "api", apiName, + "error", err.Error(), + "project_id", vctx.Config.ProjectID, + "service_name", serviceName) + + // Extract structured reason + reason := extractErrorReason(err, "APICheckFailed") + + return &validator.Result{ + Status: validator.StatusFailure, + Reason: reason, + Message: fmt.Sprintf("Failed to check API %s: %v", apiName, err), + Details: map[string]interface{}{ + "api": apiName, + //"error": err.Error(), + "error_type": fmt.Sprintf("%T", err), + "project_id": vctx.Config.ProjectID, + "service_name": serviceName, + }, + } + } + + if service.State == "ENABLED" { + enabledAPIs = append(enabledAPIs, apiName) + slog.Debug("API is enabled", "api", apiName) + } else { + disabledAPIs = append(disabledAPIs, apiName) + slog.Warn("API is NOT enabled", "api", apiName, "state", service.State) + } + } + + // Check if any APIs are disabled + if len(disabledAPIs) > 0 { + return &validator.Result{ + Status: validator.StatusFailure, + Reason: "RequiredAPIsDisabled", + Message: fmt.Sprintf("%d required API(s) are not enabled", len(disabledAPIs)), + Details: map[string]interface{}{ + "disabled_apis": disabledAPIs, + "enabled_apis": enabledAPIs, + "project_id": vctx.Config.ProjectID, + "hint": "Enable APIs with: gcloud services enable ", + }, + } + } + + // Build success message based on whether APIs were checked + message := fmt.Sprintf("All %d required APIs are enabled", len(enabledAPIs)) + if len(enabledAPIs) == 0 { + message = "No required APIs to validate" + } + slog.Info(message) + + return &validator.Result{ + Status: validator.StatusSuccess, + Reason: "AllAPIsEnabled", + Message: message, + Details: map[string]interface{}{ + "enabled_apis": enabledAPIs, + "project_id": vctx.Config.ProjectID, + }, + } } diff --git a/validator/pkg/validators/api_enabled_test.go b/validator/pkg/validators/api_enabled_test.go index 15d96c5..71e9c7d 100644 --- a/validator/pkg/validators/api_enabled_test.go +++ b/validator/pkg/validators/api_enabled_test.go @@ -1,143 +1,143 @@ package validators_test import ( - "log/slog" - "os" + "log/slog" + "os" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" - "validator/pkg/config" - "validator/pkg/validator" - "validator/pkg/validators" + "validator/pkg/config" + "validator/pkg/validator" + "validator/pkg/validators" ) var _ = Describe("APIEnabledValidator", func() { - var ( - v *validators.APIEnabledValidator - vctx *validator.Context - ) - - BeforeEach(func() { - v = &validators.APIEnabledValidator{} - - // Set up minimal config with automatic cleanup - GinkgoT().Setenv("PROJECT_ID", "test-project") - GinkgoT().Setenv("REQUIRED_APIS", "") - - cfg, err := config.LoadFromEnv() - Expect(err).NotTo(HaveOccurred()) - - // Use NewContext constructor for proper initialization - logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{ - Level: slog.LevelWarn, - })) - vctx = validator.NewContext(cfg, logger) - }) - - Describe("Metadata", func() { - It("should return correct metadata", func() { - meta := v.Metadata() - Expect(meta.Name).To(Equal("api-enabled")) - Expect(meta.Description).To(ContainSubstring("GCP APIs")) - Expect(meta.RunAfter).To(BeEmpty()) // No dependencies - WIF is implicitly validated - Expect(meta.Tags).To(ContainElement("mvp")) - Expect(meta.Tags).To(ContainElement("gcp-api")) - }) - - It("should have no dependencies (Level 0)", func() { - meta := v.Metadata() - Expect(meta.RunAfter).To(BeEmpty()) - }) - }) - - Describe("Enabled", func() { - Context("when validator is not explicitly disabled", func() { - It("should be enabled by default", func() { - enabled := v.Enabled(vctx) - Expect(enabled).To(BeTrue()) - }) - }) - - Context("when validator is explicitly disabled", func() { - BeforeEach(func() { - GinkgoT().Setenv("DISABLED_VALIDATORS", "api-enabled") - cfg, err := config.LoadFromEnv() - Expect(err).NotTo(HaveOccurred()) - vctx.Config = cfg - }) - - It("should be disabled", func() { - enabled := v.Enabled(vctx) - Expect(enabled).To(BeFalse()) - }) - }) - - }) - - Describe("Configuration", func() { - It("should use default required APIs", func() { - Expect(vctx.Config.RequiredAPIs).To(ConsistOf( - "compute.googleapis.com", - "iam.googleapis.com", - "cloudresourcemanager.googleapis.com", - )) - }) - - Context("with custom required APIs", func() { - BeforeEach(func() { - GinkgoT().Setenv("REQUIRED_APIS", "storage.googleapis.com,bigquery.googleapis.com") - cfg, err := config.LoadFromEnv() - Expect(err).NotTo(HaveOccurred()) - vctx.Config = cfg - }) - - It("should use custom APIs list", func() { - Expect(vctx.Config.RequiredAPIs).To(ConsistOf( - "storage.googleapis.com", - "bigquery.googleapis.com", - )) - }) - }) - - Context("with APIs containing whitespace", func() { - BeforeEach(func() { - GinkgoT().Setenv("REQUIRED_APIS", " storage.googleapis.com , bigquery.googleapis.com ") - cfg, err := config.LoadFromEnv() - Expect(err).NotTo(HaveOccurred()) - vctx.Config = cfg - }) - - It("should trim whitespace from API names", func() { - Expect(vctx.Config.RequiredAPIs).To(ConsistOf( - "storage.googleapis.com", - "bigquery.googleapis.com", - )) - }) - }) - }) - - Describe("GCP Project Configuration", func() { - It("should have GCP project ID from config", func() { - Expect(vctx.Config.ProjectID).To(Equal("test-project")) - }) - - Context("with different project ID", func() { - BeforeEach(func() { - GinkgoT().Setenv("PROJECT_ID", "production-project-456") - cfg, err := config.LoadFromEnv() - Expect(err).NotTo(HaveOccurred()) - vctx.Config = cfg - }) - - It("should use the specified project ID", func() { - Expect(vctx.Config.ProjectID).To(Equal("production-project-456")) - }) - }) - }) - - // Note: Testing Validate() method requires either: - // 1. A real GCP project with Service Usage API enabled (integration test) - // 2. Mocked GCP client (complex setup) - // These tests would be added in integration test suite + var ( + v *validators.APIEnabledValidator + vctx *validator.Context + ) + + BeforeEach(func() { + v = &validators.APIEnabledValidator{} + + // Set up minimal config with automatic cleanup + GinkgoT().Setenv("PROJECT_ID", "test-project") + GinkgoT().Setenv("REQUIRED_APIS", "") + + cfg, err := config.LoadFromEnv() + Expect(err).NotTo(HaveOccurred()) + + // Use NewContext constructor for proper initialization + logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{ + Level: slog.LevelWarn, + })) + vctx = validator.NewContext(cfg, logger) + }) + + Describe("Metadata", func() { + It("should return correct metadata", func() { + meta := v.Metadata() + Expect(meta.Name).To(Equal("api-enabled")) + Expect(meta.Description).To(ContainSubstring("GCP APIs")) + Expect(meta.RunAfter).To(BeEmpty()) // No dependencies - WIF is implicitly validated + Expect(meta.Tags).To(ContainElement("mvp")) + Expect(meta.Tags).To(ContainElement("gcp-api")) + }) + + It("should have no dependencies (Level 0)", func() { + meta := v.Metadata() + Expect(meta.RunAfter).To(BeEmpty()) + }) + }) + + Describe("Enabled", func() { + Context("when validator is not explicitly disabled", func() { + It("should be enabled by default", func() { + enabled := v.Enabled(vctx) + Expect(enabled).To(BeTrue()) + }) + }) + + Context("when validator is explicitly disabled", func() { + BeforeEach(func() { + GinkgoT().Setenv("DISABLED_VALIDATORS", "api-enabled") + cfg, err := config.LoadFromEnv() + Expect(err).NotTo(HaveOccurred()) + vctx.Config = cfg + }) + + It("should be disabled", func() { + enabled := v.Enabled(vctx) + Expect(enabled).To(BeFalse()) + }) + }) + + }) + + Describe("Configuration", func() { + It("should use default required APIs", func() { + Expect(vctx.Config.RequiredAPIs).To(ConsistOf( + "compute.googleapis.com", + "iam.googleapis.com", + "cloudresourcemanager.googleapis.com", + )) + }) + + Context("with custom required APIs", func() { + BeforeEach(func() { + GinkgoT().Setenv("REQUIRED_APIS", "storage.googleapis.com,bigquery.googleapis.com") + cfg, err := config.LoadFromEnv() + Expect(err).NotTo(HaveOccurred()) + vctx.Config = cfg + }) + + It("should use custom APIs list", func() { + Expect(vctx.Config.RequiredAPIs).To(ConsistOf( + "storage.googleapis.com", + "bigquery.googleapis.com", + )) + }) + }) + + Context("with APIs containing whitespace", func() { + BeforeEach(func() { + GinkgoT().Setenv("REQUIRED_APIS", " storage.googleapis.com , bigquery.googleapis.com ") + cfg, err := config.LoadFromEnv() + Expect(err).NotTo(HaveOccurred()) + vctx.Config = cfg + }) + + It("should trim whitespace from API names", func() { + Expect(vctx.Config.RequiredAPIs).To(ConsistOf( + "storage.googleapis.com", + "bigquery.googleapis.com", + )) + }) + }) + }) + + Describe("GCP Project Configuration", func() { + It("should have GCP project ID from config", func() { + Expect(vctx.Config.ProjectID).To(Equal("test-project")) + }) + + Context("with different project ID", func() { + BeforeEach(func() { + GinkgoT().Setenv("PROJECT_ID", "production-project-456") + cfg, err := config.LoadFromEnv() + Expect(err).NotTo(HaveOccurred()) + vctx.Config = cfg + }) + + It("should use the specified project ID", func() { + Expect(vctx.Config.ProjectID).To(Equal("production-project-456")) + }) + }) + }) + + // Note: Testing Validate() method requires either: + // 1. A real GCP project with Service Usage API enabled (integration test) + // 2. Mocked GCP client (complex setup) + // These tests would be added in integration test suite }) diff --git a/validator/pkg/validators/quota_check.go b/validator/pkg/validators/quota_check.go index d265add..1535db0 100644 --- a/validator/pkg/validators/quota_check.go +++ b/validator/pkg/validators/quota_check.go @@ -1,10 +1,10 @@ package validators import ( - "context" - "log/slog" + "context" + "log/slog" - "validator/pkg/validator" + "validator/pkg/validator" ) // QuotaCheckValidator verifies sufficient GCP quota is available @@ -13,79 +13,79 @@ type QuotaCheckValidator struct{} // init registers the QuotaCheckValidator with the global validator registry func init() { - validator.Register(&QuotaCheckValidator{}) + validator.Register(&QuotaCheckValidator{}) } // Metadata returns the validator configuration including name, description, and dependencies func (v *QuotaCheckValidator) Metadata() validator.ValidatorMetadata { - return validator.ValidatorMetadata{ - Name: "quota-check", - Description: "Verify sufficient GCP quota is available (stub - requires implementation)", - RunAfter: []string{"api-enabled"}, // Depends on api-enabled to ensure GCP access works - Tags: []string{"post-mvp", "quota", "stub"}, - } + return validator.ValidatorMetadata{ + Name: "quota-check", + Description: "Verify sufficient GCP quota is available (stub - requires implementation)", + RunAfter: []string{"api-enabled"}, // Depends on api-enabled to ensure GCP access works + Tags: []string{"post-mvp", "quota", "stub"}, + } } // Enabled determines if this validator should run based on configuration func (v *QuotaCheckValidator) Enabled(ctx *validator.Context) bool { - return ctx.Config.IsValidatorEnabled("quota-check") + return ctx.Config.IsValidatorEnabled("quota-check") } // Validate performs the actual validation logic (currently a stub returning success) func (v *QuotaCheckValidator) Validate(ctx context.Context, vctx *validator.Context) *validator.Result { - slog.Info("Running quota check validator (stub implementation)") + slog.Info("Running quota check validator (stub implementation)") - // TODO: Implement actual quota validation - // This should check: - // 1. Compute Engine quota (CPUs, disk, IPs, etc.) - // 2. Use the Compute API to get quota information - // 3. Compare against required resources for cluster creation - // - // Example implementation structure: - // - // // Get Compute service from context (lazy initialization with least privilege) - // computeSvc, err := vctx.GetComputeService(ctx) - // if err != nil { - // return &validator.Result{ - // Status: validator.StatusFailure, - // Reason: "ComputeClientError", - // Message: fmt.Sprintf("Failed to get Compute client: %v", err), - // } - // } - // - // // Get project quota - // project, err := computeSvc.Projects.Get(vctx.Config.ProjectID).Context(ctx).Do() - // if err != nil { - // return &validator.Result{ - // Status: validator.StatusFailure, - // Reason: "QuotaCheckFailed", - // Message: fmt.Sprintf("Failed to get project quota: %v", err), - // } - // } - // - // // Check specific quotas - // for _, quota := range project.Quotas { - // if quota.Metric == "CPUS" && quota.Limit-quota.Usage < requiredCPUs { - // return &validator.Result{ - // Status: validator.StatusFailure, - // Reason: "InsufficientQuota", - // Message: fmt.Sprintf("Insufficient CPU quota: available=%d, required=%d", - // int(quota.Limit-quota.Usage), requiredCPUs), - // } - // } - // } + // TODO: Implement actual quota validation + // This should check: + // 1. Compute Engine quota (CPUs, disk, IPs, etc.) + // 2. Use the Compute API to get quota information + // 3. Compare against required resources for cluster creation + // + // Example implementation structure: + // + // // Get Compute service from context (lazy initialization with least privilege) + // computeSvc, err := vctx.GetComputeService(ctx) + // if err != nil { + // return &validator.Result{ + // Status: validator.StatusFailure, + // Reason: "ComputeClientError", + // Message: fmt.Sprintf("Failed to get Compute client: %v", err), + // } + // } + // + // // Get project quota + // project, err := computeSvc.Projects.Get(vctx.Config.ProjectID).Context(ctx).Do() + // if err != nil { + // return &validator.Result{ + // Status: validator.StatusFailure, + // Reason: "QuotaCheckFailed", + // Message: fmt.Sprintf("Failed to get project quota: %v", err), + // } + // } + // + // // Check specific quotas + // for _, quota := range project.Quotas { + // if quota.Metric == "CPUS" && quota.Limit-quota.Usage < requiredCPUs { + // return &validator.Result{ + // Status: validator.StatusFailure, + // Reason: "InsufficientQuota", + // Message: fmt.Sprintf("Insufficient CPU quota: available=%d, required=%d", + // int(quota.Limit-quota.Usage), requiredCPUs), + // } + // } + // } - slog.Warn("Quota check not yet implemented - returning success by default") + slog.Warn("Quota check not yet implemented - returning success by default") - return &validator.Result{ - Status: validator.StatusSuccess, - Reason: "QuotaCheckStub", - Message: "Quota check validation not yet implemented (stub returning success)", - Details: map[string]interface{}{ - "stub": true, - "implemented": false, - "project_id": vctx.Config.ProjectID, - "note": "This validator needs to be implemented to check actual GCP quotas", - }, - } + return &validator.Result{ + Status: validator.StatusSuccess, + Reason: "QuotaCheckStub", + Message: "Quota check validation not yet implemented (stub returning success)", + Details: map[string]interface{}{ + "stub": true, + "implemented": false, + "project_id": vctx.Config.ProjectID, + "note": "This validator needs to be implemented to check actual GCP quotas", + }, + } } diff --git a/validator/pkg/validators/quota_check_test.go b/validator/pkg/validators/quota_check_test.go index 92418ef..c5db997 100644 --- a/validator/pkg/validators/quota_check_test.go +++ b/validator/pkg/validators/quota_check_test.go @@ -1,99 +1,99 @@ package validators_test import ( - "context" - "log/slog" - "os" + "context" + "log/slog" + "os" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" - "validator/pkg/config" - "validator/pkg/validator" - "validator/pkg/validators" + "validator/pkg/config" + "validator/pkg/validator" + "validator/pkg/validators" ) var _ = Describe("QuotaCheckValidator", func() { - var ( - v *validators.QuotaCheckValidator - vctx *validator.Context - ) - - BeforeEach(func() { - v = &validators.QuotaCheckValidator{} - - // Set up minimal config with automatic cleanup - GinkgoT().Setenv("PROJECT_ID", "test-project") - - cfg, err := config.LoadFromEnv() - Expect(err).NotTo(HaveOccurred()) - - // Use NewContext constructor for proper initialization - logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{ - Level: slog.LevelWarn, - })) - vctx = validator.NewContext(cfg, logger) - }) - - Describe("Metadata", func() { - It("should return correct metadata", func() { - meta := v.Metadata() - Expect(meta.Name).To(Equal("quota-check")) - Expect(meta.Description).To(ContainSubstring("quota")) - Expect(meta.Description).To(ContainSubstring("stub")) - Expect(meta.RunAfter).To(ConsistOf("api-enabled")) // Depends on api-enabled - Expect(meta.Tags).To(ContainElement("post-mvp")) - Expect(meta.Tags).To(ContainElement("quota")) - Expect(meta.Tags).To(ContainElement("stub")) - }) - - It("should depend on api-enabled (Level 1)", func() { - meta := v.Metadata() - Expect(meta.RunAfter).To(ConsistOf("api-enabled")) - }) - }) - - Describe("Enabled", func() { - Context("when validator is not explicitly disabled", func() { - It("should be enabled by default", func() { - enabled := v.Enabled(vctx) - Expect(enabled).To(BeTrue()) - }) - }) - - Context("when validator is explicitly disabled", func() { - BeforeEach(func() { - GinkgoT().Setenv("DISABLED_VALIDATORS", "quota-check") - cfg, err := config.LoadFromEnv() - Expect(err).NotTo(HaveOccurred()) - vctx.Config = cfg - }) - - It("should be disabled", func() { - enabled := v.Enabled(vctx) - Expect(enabled).To(BeFalse()) - }) - }) - - }) - - Describe("Validate", func() { - It("should return success with stub message", func() { - ctx := context.Background() - result := v.Validate(ctx, vctx) - Expect(result).NotTo(BeNil()) - Expect(result.Status).To(Equal(validator.StatusSuccess)) - Expect(result.Reason).To(Equal("QuotaCheckStub")) - Expect(result.Message).To(ContainSubstring("not yet implemented")) - }) - - It("should include stub metadata in details", func() { - ctx := context.Background() - result := v.Validate(ctx, vctx) - Expect(result.Details).To(HaveKey("stub")) - Expect(result.Details["stub"]).To(BeTrue()) - Expect(result.Details).To(HaveKey("implemented")) - Expect(result.Details["implemented"]).To(BeFalse()) - }) - }) + var ( + v *validators.QuotaCheckValidator + vctx *validator.Context + ) + + BeforeEach(func() { + v = &validators.QuotaCheckValidator{} + + // Set up minimal config with automatic cleanup + GinkgoT().Setenv("PROJECT_ID", "test-project") + + cfg, err := config.LoadFromEnv() + Expect(err).NotTo(HaveOccurred()) + + // Use NewContext constructor for proper initialization + logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{ + Level: slog.LevelWarn, + })) + vctx = validator.NewContext(cfg, logger) + }) + + Describe("Metadata", func() { + It("should return correct metadata", func() { + meta := v.Metadata() + Expect(meta.Name).To(Equal("quota-check")) + Expect(meta.Description).To(ContainSubstring("quota")) + Expect(meta.Description).To(ContainSubstring("stub")) + Expect(meta.RunAfter).To(ConsistOf("api-enabled")) // Depends on api-enabled + Expect(meta.Tags).To(ContainElement("post-mvp")) + Expect(meta.Tags).To(ContainElement("quota")) + Expect(meta.Tags).To(ContainElement("stub")) + }) + + It("should depend on api-enabled (Level 1)", func() { + meta := v.Metadata() + Expect(meta.RunAfter).To(ConsistOf("api-enabled")) + }) + }) + + Describe("Enabled", func() { + Context("when validator is not explicitly disabled", func() { + It("should be enabled by default", func() { + enabled := v.Enabled(vctx) + Expect(enabled).To(BeTrue()) + }) + }) + + Context("when validator is explicitly disabled", func() { + BeforeEach(func() { + GinkgoT().Setenv("DISABLED_VALIDATORS", "quota-check") + cfg, err := config.LoadFromEnv() + Expect(err).NotTo(HaveOccurred()) + vctx.Config = cfg + }) + + It("should be disabled", func() { + enabled := v.Enabled(vctx) + Expect(enabled).To(BeFalse()) + }) + }) + + }) + + Describe("Validate", func() { + It("should return success with stub message", func() { + ctx := context.Background() + result := v.Validate(ctx, vctx) + Expect(result).NotTo(BeNil()) + Expect(result.Status).To(Equal(validator.StatusSuccess)) + Expect(result.Reason).To(Equal("QuotaCheckStub")) + Expect(result.Message).To(ContainSubstring("not yet implemented")) + }) + + It("should include stub metadata in details", func() { + ctx := context.Background() + result := v.Validate(ctx, vctx) + Expect(result.Details).To(HaveKey("stub")) + Expect(result.Details["stub"]).To(BeTrue()) + Expect(result.Details).To(HaveKey("implemented")) + Expect(result.Details["implemented"]).To(BeFalse()) + }) + }) }) diff --git a/validator/pkg/validators/validators_suite_test.go b/validator/pkg/validators/validators_suite_test.go index 3a6cb6f..4e4c55f 100644 --- a/validator/pkg/validators/validators_suite_test.go +++ b/validator/pkg/validators/validators_suite_test.go @@ -1,13 +1,13 @@ package validators_test import ( - "testing" + "testing" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" ) func TestValidators(t *testing.T) { - RegisterFailHandler(Fail) - RunSpecs(t, "Validators Suite") + RegisterFailHandler(Fail) + RunSpecs(t, "Validators Suite") } diff --git a/validator/test/integration/context_integration_test.go b/validator/test/integration/context_integration_test.go index 9b238e4..9b3c96c 100644 --- a/validator/test/integration/context_integration_test.go +++ b/validator/test/integration/context_integration_test.go @@ -4,269 +4,269 @@ package integration_test import ( - "context" - "log/slog" - "os" - "time" + "context" + "log/slog" + "os" + "time" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" - "validator/pkg/config" - "validator/pkg/validator" + "validator/pkg/config" + "validator/pkg/validator" ) var _ = Describe("Context Integration Tests", func() { - var ( - ctx context.Context - cancel context.CancelFunc - vctx *validator.Context - cfg *config.Config - logger *slog.Logger - ) - - BeforeEach(func() { - // Create context with reasonable timeout for integration tests - ctx, cancel = context.WithTimeout(context.Background(), 30*time.Second) - - // Set up logger - logger = slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{ - Level: slog.LevelInfo, - })) - - // Load configuration from environment - // Requires: PROJECT_ID environment variable - var err error - cfg, err = config.LoadFromEnv() - Expect(err).NotTo(HaveOccurred(), "Failed to load config - ensure PROJECT_ID is set") - Expect(cfg.ProjectID).NotTo(BeEmpty(), "PROJECT_ID must be set for integration tests") - - // Create new context with client factory - vctx = validator.NewContext(cfg, logger) - }) - - AfterEach(func() { - cancel() - }) - - Describe("Lazy Initialization with Real GCP Services", func() { - Context("GetServiceUsageService", func() { - It("should successfully create service with valid credentials", func() { - svc, err := vctx.GetServiceUsageService(ctx) - - Expect(err).NotTo(HaveOccurred(), "Should create service with valid GCP credentials") - Expect(svc).NotTo(BeNil(), "Service should not be nil") - }) - - It("should return cached service on subsequent calls", func() { - // First call - creates the service - svc1, err := vctx.GetServiceUsageService(ctx) - Expect(err).NotTo(HaveOccurred()) - Expect(svc1).NotTo(BeNil()) - - // Second call - should return cached instance - svc2, err := vctx.GetServiceUsageService(ctx) - Expect(err).NotTo(HaveOccurred()) - Expect(svc2).NotTo(BeNil()) - - // Verify it's the exact same instance (pointer equality) - Expect(svc2).To(BeIdenticalTo(svc1), "Should return cached service instance") - }) - - It("should successfully make API calls with created service", func() { - svc, err := vctx.GetServiceUsageService(ctx) - Expect(err).NotTo(HaveOccurred()) - - // Make a real API call to verify the service works - serviceName := "projects/" + cfg.ProjectID + "/services/compute.googleapis.com" - service, err := svc.Services.Get(serviceName).Context(ctx).Do() - - // This may fail if compute API is not enabled, but shouldn't fail on auth - if err != nil { - // Log the error but don't fail - API might not be enabled - logger.Info("API check failed (might not be enabled)", "error", err.Error()) - } else { - Expect(service).NotTo(BeNil()) - logger.Info("Successfully called Service Usage API", "state", service.State) - } - }) - }) - - Context("GetComputeService", func() { - It("should successfully create service with valid credentials", func() { - svc, err := vctx.GetComputeService(ctx) - - Expect(err).NotTo(HaveOccurred(), "Should create service with valid GCP credentials") - Expect(svc).NotTo(BeNil(), "Service should not be nil") - }) - - It("should return cached service on subsequent calls", func() { - svc1, err := vctx.GetComputeService(ctx) - Expect(err).NotTo(HaveOccurred()) - - svc2, err := vctx.GetComputeService(ctx) - Expect(err).NotTo(HaveOccurred()) - - Expect(svc2).To(BeIdenticalTo(svc1), "Should return cached service instance") - }) - }) - - Context("GetIAMService", func() { - It("should successfully create service with valid credentials", func() { - svc, err := vctx.GetIAMService(ctx) - - Expect(err).NotTo(HaveOccurred(), "Should create service with valid GCP credentials") - Expect(svc).NotTo(BeNil(), "Service should not be nil") - }) - - It("should return cached service on subsequent calls", func() { - svc1, err := vctx.GetIAMService(ctx) - Expect(err).NotTo(HaveOccurred()) - - svc2, err := vctx.GetIAMService(ctx) - Expect(err).NotTo(HaveOccurred()) - - Expect(svc2).To(BeIdenticalTo(svc1), "Should return cached service instance") - }) - }) - - Context("GetCloudResourceManagerService", func() { - It("should successfully create service with valid credentials", func() { - svc, err := vctx.GetCloudResourceManagerService(ctx) - - Expect(err).NotTo(HaveOccurred(), "Should create service with valid GCP credentials") - Expect(svc).NotTo(BeNil(), "Service should not be nil") - }) - - It("should return cached service on subsequent calls", func() { - svc1, err := vctx.GetCloudResourceManagerService(ctx) - Expect(err).NotTo(HaveOccurred()) - - svc2, err := vctx.GetCloudResourceManagerService(ctx) - Expect(err).NotTo(HaveOccurred()) - - Expect(svc2).To(BeIdenticalTo(svc1), "Should return cached service instance") - }) - - It("should successfully make API calls with created service", func() { - svc, err := vctx.GetCloudResourceManagerService(ctx) - Expect(err).NotTo(HaveOccurred()) - - // Make a real API call to get project details - project, err := svc.Projects.Get(cfg.ProjectID).Context(ctx).Do() - - Expect(err).NotTo(HaveOccurred(), "Should successfully get project details") - Expect(project).NotTo(BeNil()) - Expect(project.ProjectId).To(Equal(cfg.ProjectID)) - logger.Info("Successfully retrieved project", - "projectId", project.ProjectId, - "projectNumber", project.ProjectNumber, - "state", project.LifecycleState) - }) - }) - - Context("GetMonitoringService", func() { - It("should successfully create service with valid credentials", func() { - svc, err := vctx.GetMonitoringService(ctx) - - Expect(err).NotTo(HaveOccurred(), "Should create service with valid GCP credentials") - Expect(svc).NotTo(BeNil(), "Service should not be nil") - }) - - It("should return cached service on subsequent calls", func() { - svc1, err := vctx.GetMonitoringService(ctx) - Expect(err).NotTo(HaveOccurred()) - - svc2, err := vctx.GetMonitoringService(ctx) - Expect(err).NotTo(HaveOccurred()) - - Expect(svc2).To(BeIdenticalTo(svc1), "Should return cached service instance") - }) - }) - }) - - - Describe("Context Cancellation with Real Services", func() { - It("should respect context timeout during service creation", func() { - // Create a context with very short timeout - shortCtx, shortCancel := context.WithTimeout(context.Background(), 1*time.Nanosecond) - defer shortCancel() - - // Wait for context to expire - time.Sleep(10 * time.Millisecond) - - // Try to create service with expired context - _, err := vctx.GetServiceUsageService(shortCtx) - - // Should fail due to context timeout - // Note: Might still succeed if service was already cached - if err != nil { - Expect(err.Error()).To(Or( - ContainSubstring("context"), - ContainSubstring("deadline"), - ContainSubstring("timeout"), - ), "Error should be context-related") - } - }) - - It("should handle context cancellation gracefully", func() { - cancelCtx, cancelFunc := context.WithCancel(context.Background()) - cancelFunc() // Cancel immediately - - // Create new context (not cached yet) with cancelled context - freshVctx := validator.NewContext(cfg, logger) - - _, err := freshVctx.GetServiceUsageService(cancelCtx) - - // Should fail gracefully (no panic) - if err != nil { - logger.Info("Context cancellation handled", "error", err.Error()) - } - }) - }) - - Describe("Least Privilege Verification", func() { - It("should only create services when getters are called", func() { - // Create a fresh context - freshVctx := validator.NewContext(cfg, logger) - - // At this point, NO services should be created - // We can't directly verify this without exposing internals, - // but we can verify that calling different getters succeeds - - // Call only ServiceUsageService - svc, err := freshVctx.GetServiceUsageService(ctx) - Expect(err).NotTo(HaveOccurred()) - Expect(svc).NotTo(BeNil()) - - // Other services should be lazily created only when needed - // This verifies the lazy initialization pattern - - logger.Info("Verified lazy initialization - service created only when requested") - }) - - It("should create all services when all getters are called", func() { - // Call all getters - computeSvc, err1 := vctx.GetComputeService(ctx) - iamSvc, err2 := vctx.GetIAMService(ctx) - crmSvc, err3 := vctx.GetCloudResourceManagerService(ctx) - suSvc, err4 := vctx.GetServiceUsageService(ctx) - monSvc, err5 := vctx.GetMonitoringService(ctx) - - // All should succeed - Expect(err1).NotTo(HaveOccurred()) - Expect(err2).NotTo(HaveOccurred()) - Expect(err3).NotTo(HaveOccurred()) - Expect(err4).NotTo(HaveOccurred()) - Expect(err5).NotTo(HaveOccurred()) - - Expect(computeSvc).NotTo(BeNil()) - Expect(iamSvc).NotTo(BeNil()) - Expect(crmSvc).NotTo(BeNil()) - Expect(suSvc).NotTo(BeNil()) - Expect(monSvc).NotTo(BeNil()) - - logger.Info("Successfully created all 5 GCP service clients") - }) - }) + var ( + ctx context.Context + cancel context.CancelFunc + vctx *validator.Context + cfg *config.Config + logger *slog.Logger + ) + + BeforeEach(func() { + // Create context with reasonable timeout for integration tests + ctx, cancel = context.WithTimeout(context.Background(), 30*time.Second) + + // Set up logger + logger = slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{ + Level: slog.LevelInfo, + })) + + // Load configuration from environment + // Requires: PROJECT_ID environment variable + var err error + cfg, err = config.LoadFromEnv() + Expect(err).NotTo(HaveOccurred(), "Failed to load config - ensure PROJECT_ID is set") + Expect(cfg.ProjectID).NotTo(BeEmpty(), "PROJECT_ID must be set for integration tests") + + // Create new context with client factory + vctx = validator.NewContext(cfg, logger) + }) + + AfterEach(func() { + cancel() + }) + + Describe("Lazy Initialization with Real GCP Services", func() { + Context("GetServiceUsageService", func() { + It("should successfully create service with valid credentials", func() { + svc, err := vctx.GetServiceUsageService(ctx) + + Expect(err).NotTo(HaveOccurred(), "Should create service with valid GCP credentials") + Expect(svc).NotTo(BeNil(), "Service should not be nil") + }) + + It("should return cached service on subsequent calls", func() { + // First call - creates the service + svc1, err := vctx.GetServiceUsageService(ctx) + Expect(err).NotTo(HaveOccurred()) + Expect(svc1).NotTo(BeNil()) + + // Second call - should return cached instance + svc2, err := vctx.GetServiceUsageService(ctx) + Expect(err).NotTo(HaveOccurred()) + Expect(svc2).NotTo(BeNil()) + + // Verify it's the exact same instance (pointer equality) + Expect(svc2).To(BeIdenticalTo(svc1), "Should return cached service instance") + }) + + It("should successfully make API calls with created service", func() { + svc, err := vctx.GetServiceUsageService(ctx) + Expect(err).NotTo(HaveOccurred()) + + // Make a real API call to verify the service works + serviceName := "projects/" + cfg.ProjectID + "/services/compute.googleapis.com" + service, err := svc.Services.Get(serviceName).Context(ctx).Do() + + // This may fail if compute API is not enabled, but shouldn't fail on auth + if err != nil { + // Log the error but don't fail - API might not be enabled + logger.Info("API check failed (might not be enabled)", "error", err.Error()) + } else { + Expect(service).NotTo(BeNil()) + logger.Info("Successfully called Service Usage API", "state", service.State) + } + }) + }) + + Context("GetComputeService", func() { + It("should successfully create service with valid credentials", func() { + svc, err := vctx.GetComputeService(ctx) + + Expect(err).NotTo(HaveOccurred(), "Should create service with valid GCP credentials") + Expect(svc).NotTo(BeNil(), "Service should not be nil") + }) + + It("should return cached service on subsequent calls", func() { + svc1, err := vctx.GetComputeService(ctx) + Expect(err).NotTo(HaveOccurred()) + + svc2, err := vctx.GetComputeService(ctx) + Expect(err).NotTo(HaveOccurred()) + + Expect(svc2).To(BeIdenticalTo(svc1), "Should return cached service instance") + }) + }) + + Context("GetIAMService", func() { + It("should successfully create service with valid credentials", func() { + svc, err := vctx.GetIAMService(ctx) + + Expect(err).NotTo(HaveOccurred(), "Should create service with valid GCP credentials") + Expect(svc).NotTo(BeNil(), "Service should not be nil") + }) + + It("should return cached service on subsequent calls", func() { + svc1, err := vctx.GetIAMService(ctx) + Expect(err).NotTo(HaveOccurred()) + + svc2, err := vctx.GetIAMService(ctx) + Expect(err).NotTo(HaveOccurred()) + + Expect(svc2).To(BeIdenticalTo(svc1), "Should return cached service instance") + }) + }) + + Context("GetCloudResourceManagerService", func() { + It("should successfully create service with valid credentials", func() { + svc, err := vctx.GetCloudResourceManagerService(ctx) + + Expect(err).NotTo(HaveOccurred(), "Should create service with valid GCP credentials") + Expect(svc).NotTo(BeNil(), "Service should not be nil") + }) + + It("should return cached service on subsequent calls", func() { + svc1, err := vctx.GetCloudResourceManagerService(ctx) + Expect(err).NotTo(HaveOccurred()) + + svc2, err := vctx.GetCloudResourceManagerService(ctx) + Expect(err).NotTo(HaveOccurred()) + + Expect(svc2).To(BeIdenticalTo(svc1), "Should return cached service instance") + }) + + It("should successfully make API calls with created service", func() { + svc, err := vctx.GetCloudResourceManagerService(ctx) + Expect(err).NotTo(HaveOccurred()) + + // Make a real API call to get project details + project, err := svc.Projects.Get(cfg.ProjectID).Context(ctx).Do() + + Expect(err).NotTo(HaveOccurred(), "Should successfully get project details") + Expect(project).NotTo(BeNil()) + Expect(project.ProjectId).To(Equal(cfg.ProjectID)) + logger.Info("Successfully retrieved project", + "projectId", project.ProjectId, + "projectNumber", project.ProjectNumber, + "state", project.LifecycleState) + }) + }) + + Context("GetMonitoringService", func() { + It("should successfully create service with valid credentials", func() { + svc, err := vctx.GetMonitoringService(ctx) + + Expect(err).NotTo(HaveOccurred(), "Should create service with valid GCP credentials") + Expect(svc).NotTo(BeNil(), "Service should not be nil") + }) + + It("should return cached service on subsequent calls", func() { + svc1, err := vctx.GetMonitoringService(ctx) + Expect(err).NotTo(HaveOccurred()) + + svc2, err := vctx.GetMonitoringService(ctx) + Expect(err).NotTo(HaveOccurred()) + + Expect(svc2).To(BeIdenticalTo(svc1), "Should return cached service instance") + }) + }) + }) + + + Describe("Context Cancellation with Real Services", func() { + It("should respect context timeout during service creation", func() { + // Create a context with very short timeout + shortCtx, shortCancel := context.WithTimeout(context.Background(), 1*time.Nanosecond) + defer shortCancel() + + // Wait for context to expire + time.Sleep(10 * time.Millisecond) + + // Try to create service with expired context + _, err := vctx.GetServiceUsageService(shortCtx) + + // Should fail due to context timeout + // Note: Might still succeed if service was already cached + if err != nil { + Expect(err.Error()).To(Or( + ContainSubstring("context"), + ContainSubstring("deadline"), + ContainSubstring("timeout"), + ), "Error should be context-related") + } + }) + + It("should handle context cancellation gracefully", func() { + cancelCtx, cancelFunc := context.WithCancel(context.Background()) + cancelFunc() // Cancel immediately + + // Create new context (not cached yet) with cancelled context + freshVctx := validator.NewContext(cfg, logger) + + _, err := freshVctx.GetServiceUsageService(cancelCtx) + + // Should fail gracefully (no panic) + if err != nil { + logger.Info("Context cancellation handled", "error", err.Error()) + } + }) + }) + + Describe("Least Privilege Verification", func() { + It("should only create services when getters are called", func() { + // Create a fresh context + freshVctx := validator.NewContext(cfg, logger) + + // At this point, NO services should be created + // We can't directly verify this without exposing internals, + // but we can verify that calling different getters succeeds + + // Call only ServiceUsageService + svc, err := freshVctx.GetServiceUsageService(ctx) + Expect(err).NotTo(HaveOccurred()) + Expect(svc).NotTo(BeNil()) + + // Other services should be lazily created only when needed + // This verifies the lazy initialization pattern + + logger.Info("Verified lazy initialization - service created only when requested") + }) + + It("should create all services when all getters are called", func() { + // Call all getters + computeSvc, err1 := vctx.GetComputeService(ctx) + iamSvc, err2 := vctx.GetIAMService(ctx) + crmSvc, err3 := vctx.GetCloudResourceManagerService(ctx) + suSvc, err4 := vctx.GetServiceUsageService(ctx) + monSvc, err5 := vctx.GetMonitoringService(ctx) + + // All should succeed + Expect(err1).NotTo(HaveOccurred()) + Expect(err2).NotTo(HaveOccurred()) + Expect(err3).NotTo(HaveOccurred()) + Expect(err4).NotTo(HaveOccurred()) + Expect(err5).NotTo(HaveOccurred()) + + Expect(computeSvc).NotTo(BeNil()) + Expect(iamSvc).NotTo(BeNil()) + Expect(crmSvc).NotTo(BeNil()) + Expect(suSvc).NotTo(BeNil()) + Expect(monSvc).NotTo(BeNil()) + + logger.Info("Successfully created all 5 GCP service clients") + }) + }) }) diff --git a/validator/test/integration/suite_test.go b/validator/test/integration/suite_test.go index 9424e2f..5d5eaf7 100644 --- a/validator/test/integration/suite_test.go +++ b/validator/test/integration/suite_test.go @@ -4,13 +4,13 @@ package integration_test import ( - "testing" + "testing" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" ) func TestIntegration(t *testing.T) { - RegisterFailHandler(Fail) - RunSpecs(t, "Integration Suite") + RegisterFailHandler(Fail) + RunSpecs(t, "Integration Suite") } diff --git a/validator/test/integration/validator_integration_test.go b/validator/test/integration/validator_integration_test.go index 0ad79b6..4289ea1 100644 --- a/validator/test/integration/validator_integration_test.go +++ b/validator/test/integration/validator_integration_test.go @@ -4,289 +4,289 @@ package integration_test import ( - "context" - "log/slog" - "os" - "time" + "context" + "log/slog" + "os" + "time" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" - "validator/pkg/config" - "validator/pkg/validator" - _ "validator/pkg/validators" // Import to trigger validator registration + "validator/pkg/config" + "validator/pkg/validator" + _ "validator/pkg/validators" // Import to trigger validator registration ) var _ = Describe("Validator Integration Tests", func() { - var ( - ctx context.Context - cancel context.CancelFunc - vctx *validator.Context - cfg *config.Config - logger *slog.Logger - ) - - BeforeEach(func() { - // Create context with reasonable timeout - ctx, cancel = context.WithTimeout(context.Background(), 60*time.Second) - - // Set up logger - logger = slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{ - Level: slog.LevelInfo, - })) - - // Load configuration from environment - var err error - cfg, err = config.LoadFromEnv() - Expect(err).NotTo(HaveOccurred()) - Expect(cfg.ProjectID).NotTo(BeEmpty(), "PROJECT_ID must be set") - - // Create validation context - vctx = validator.NewContext(cfg, logger) - }) - - AfterEach(func() { - cancel() - }) - - Describe("End-to-End Validator Execution", func() { - Context("with all validators enabled", func() { - It("should execute all enabled validators successfully", func() { - executor := validator.NewExecutor(vctx, logger) - - results, err := executor.ExecuteAll(ctx) - - Expect(err).NotTo(HaveOccurred(), "Executor should complete without error") - Expect(results).NotTo(BeEmpty(), "Should have at least one validator result") - - logger.Info("Validator execution completed", - "total_validators", len(results), - "project_id", cfg.ProjectID) - - // Log each result - for _, result := range results { - logger.Info("Validator result", - "name", result.ValidatorName, - "status", result.Status, - "reason", result.Reason, - "message", result.Message, - "duration", result.Duration) - } - }) - }) - - Context("api-enabled validator", func() { - It("should successfully check if required APIs are enabled", func() { - // Get the api-enabled validator - v, exists := validator.Get("api-enabled") - Expect(exists).To(BeTrue(), "api-enabled validator should be registered") - - // Check if it's enabled - enabled := v.Enabled(vctx) - if !enabled { - Skip("api-enabled validator is disabled in configuration") - } - - // Execute the validator - result := v.Validate(ctx, vctx) - - Expect(result).NotTo(BeNil()) - // Note: ValidatorName is set by Executor, not by Validate method directly - - // Log the result - logger.Info("API enabled check result", - "status", result.Status, - "reason", result.Reason, - "message", result.Message, - "details", result.Details) - - // Verify result structure - // Note: Timestamp, Duration, and ValidatorName are set by Executor, not by Validate directly - Expect(result.Status).To(BeElementOf( - validator.StatusSuccess, - validator.StatusFailure, - ), "Status should be success or failure") - Expect(result.Reason).NotTo(BeEmpty(), "Reason should not be empty") - Expect(result.Message).NotTo(BeEmpty(), "Message should not be empty") - }) - }) - - Context("quota-check validator", func() { - It("should run quota-check validator (stub)", func() { - v, exists := validator.Get("quota-check") - Expect(exists).To(BeTrue(), "quota-check validator should be registered") - - enabled := v.Enabled(vctx) - if !enabled { - Skip("quota-check validator is disabled in configuration") - } - - result := v.Validate(ctx, vctx) - - Expect(result).NotTo(BeNil()) - // Note: ValidatorName is set by Executor, not by Validate method directly - - logger.Info("Quota check result", - "status", result.Status, - "reason", result.Reason, - "message", result.Message) - - // Currently a stub, so should succeed - Expect(result.Status).To(Equal(validator.StatusSuccess)) - }) - }) - }) - - Describe("Validator Aggregation", func() { - It("should aggregate multiple validator results correctly", func() { - executor := validator.NewExecutor(vctx, logger) - - results, err := executor.ExecuteAll(ctx) - Expect(err).NotTo(HaveOccurred()) - - // Aggregate results - aggregated := validator.Aggregate(results) - - Expect(aggregated).NotTo(BeNil()) - Expect(aggregated.Status).To(BeElementOf( - validator.StatusSuccess, - validator.StatusFailure, - )) - Expect(aggregated.Message).NotTo(BeEmpty()) - Expect(aggregated.Details).NotTo(BeEmpty()) - - // Extract counts from Details map - checksRun, ok := aggregated.Details["checks_run"].(int) - Expect(ok).To(BeTrue(), "checks_run should be an int") - Expect(checksRun).To(Equal(len(results))) - - checksPassed, ok := aggregated.Details["checks_passed"].(int) - Expect(ok).To(BeTrue(), "checks_passed should be an int") - - successCount := 0 - failureCount := 0 - for _, r := range results { - if r.Status == validator.StatusSuccess { - successCount++ - } else { - failureCount++ - } - } - - Expect(checksPassed).To(Equal(successCount)) - Expect(checksRun - checksPassed).To(Equal(failureCount)) - - logger.Info("Aggregated results", - "status", aggregated.Status, - "checks_run", checksRun, - "checks_passed", checksPassed, - "checks_failed", checksRun-checksPassed, - "message", aggregated.Message) - }) - }) - - Describe("Shared State Between Validators", func() { - It("should maintain shared state in context across validators", func() { - executor := validator.NewExecutor(vctx, logger) - - results, err := executor.ExecuteAll(ctx) - Expect(err).NotTo(HaveOccurred()) - - // Verify results are stored in context - Expect(vctx.Results).To(HaveLen(len(results))) - - for _, result := range results { - Expect(vctx.Results).To(HaveKey(result.ValidatorName)) - Expect(vctx.Results[result.ValidatorName]).To(Equal(result)) - } - - logger.Info("Verified shared state", - "validators_in_context", len(vctx.Results)) - }) - }) - - Describe("Real GCP API Integration", func() { - Context("when checking actual GCP project state", func() { - It("should successfully interact with GCP APIs", func() { - // Get Cloud Resource Manager service - svc, err := vctx.GetCloudResourceManagerService(ctx) - Expect(err).NotTo(HaveOccurred()) - - // Make real API call - project, err := svc.Projects.Get(cfg.ProjectID).Context(ctx).Do() - Expect(err).NotTo(HaveOccurred()) - - Expect(project.ProjectId).To(Equal(cfg.ProjectID)) - Expect(project.ProjectNumber).To(BeNumerically(">", 0)) - Expect(project.LifecycleState).To(Equal("ACTIVE")) - - // Store project number in context (validators might use this) - vctx.ProjectNumber = project.ProjectNumber - - logger.Info("Successfully retrieved real project details", - "projectId", project.ProjectId, - "projectNumber", project.ProjectNumber, - "name", project.Name, - "state", project.LifecycleState) - }) - - It("should successfully check if Compute API is accessible", func() { - svc, err := vctx.GetServiceUsageService(ctx) - Expect(err).NotTo(HaveOccurred()) - - serviceName := "projects/" + cfg.ProjectID + "/services/compute.googleapis.com" - service, err := svc.Services.Get(serviceName).Context(ctx).Do() - - if err != nil { - logger.Warn("Failed to get Compute API status", "error", err.Error()) - // Don't fail test - API might not be enabled - return - } - - Expect(service).NotTo(BeNil()) - logger.Info("Compute API status", - "name", service.Name, - "state", service.State) - }) - }) - }) - - Describe("Performance and Timeout", func() { - It("should complete all validators within reasonable time", func() { - start := time.Now() - - executor := validator.NewExecutor(vctx, logger) - _, err := executor.ExecuteAll(ctx) - - duration := time.Since(start) - - Expect(err).NotTo(HaveOccurred()) - Expect(duration).To(BeNumerically("<", 30*time.Second), - "All validators should complete within 30 seconds") - - logger.Info("Performance test completed", - "total_duration", duration.String()) - }) - - It("should respect global timeout from configuration", func() { - // Create short timeout config - shortTimeout := 5 * time.Second - cfg.MaxWaitTimeSeconds = int(shortTimeout.Seconds()) - - shortCtx, shortCancel := context.WithTimeout(context.Background(), shortTimeout) - defer shortCancel() - - executor := validator.NewExecutor(vctx, logger) - results, err := executor.ExecuteAll(shortCtx) - - // Should either complete or respect timeout - if err != nil { - Expect(err.Error()).To(ContainSubstring("context")) - } else { - Expect(results).NotTo(BeNil()) - } - - logger.Info("Timeout test completed") - }) - }) + var ( + ctx context.Context + cancel context.CancelFunc + vctx *validator.Context + cfg *config.Config + logger *slog.Logger + ) + + BeforeEach(func() { + // Create context with reasonable timeout + ctx, cancel = context.WithTimeout(context.Background(), 60*time.Second) + + // Set up logger + logger = slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{ + Level: slog.LevelInfo, + })) + + // Load configuration from environment + var err error + cfg, err = config.LoadFromEnv() + Expect(err).NotTo(HaveOccurred()) + Expect(cfg.ProjectID).NotTo(BeEmpty(), "PROJECT_ID must be set") + + // Create validation context + vctx = validator.NewContext(cfg, logger) + }) + + AfterEach(func() { + cancel() + }) + + Describe("End-to-End Validator Execution", func() { + Context("with all validators enabled", func() { + It("should execute all enabled validators successfully", func() { + executor := validator.NewExecutor(vctx, logger) + + results, err := executor.ExecuteAll(ctx) + + Expect(err).NotTo(HaveOccurred(), "Executor should complete without error") + Expect(results).NotTo(BeEmpty(), "Should have at least one validator result") + + logger.Info("Validator execution completed", + "total_validators", len(results), + "project_id", cfg.ProjectID) + + // Log each result + for _, result := range results { + logger.Info("Validator result", + "name", result.ValidatorName, + "status", result.Status, + "reason", result.Reason, + "message", result.Message, + "duration", result.Duration) + } + }) + }) + + Context("api-enabled validator", func() { + It("should successfully check if required APIs are enabled", func() { + // Get the api-enabled validator + v, exists := validator.Get("api-enabled") + Expect(exists).To(BeTrue(), "api-enabled validator should be registered") + + // Check if it's enabled + enabled := v.Enabled(vctx) + if !enabled { + Skip("api-enabled validator is disabled in configuration") + } + + // Execute the validator + result := v.Validate(ctx, vctx) + + Expect(result).NotTo(BeNil()) + // Note: ValidatorName is set by Executor, not by Validate method directly + + // Log the result + logger.Info("API enabled check result", + "status", result.Status, + "reason", result.Reason, + "message", result.Message, + "details", result.Details) + + // Verify result structure + // Note: Timestamp, Duration, and ValidatorName are set by Executor, not by Validate directly + Expect(result.Status).To(BeElementOf( + validator.StatusSuccess, + validator.StatusFailure, + ), "Status should be success or failure") + Expect(result.Reason).NotTo(BeEmpty(), "Reason should not be empty") + Expect(result.Message).NotTo(BeEmpty(), "Message should not be empty") + }) + }) + + Context("quota-check validator", func() { + It("should run quota-check validator (stub)", func() { + v, exists := validator.Get("quota-check") + Expect(exists).To(BeTrue(), "quota-check validator should be registered") + + enabled := v.Enabled(vctx) + if !enabled { + Skip("quota-check validator is disabled in configuration") + } + + result := v.Validate(ctx, vctx) + + Expect(result).NotTo(BeNil()) + // Note: ValidatorName is set by Executor, not by Validate method directly + + logger.Info("Quota check result", + "status", result.Status, + "reason", result.Reason, + "message", result.Message) + + // Currently a stub, so should succeed + Expect(result.Status).To(Equal(validator.StatusSuccess)) + }) + }) + }) + + Describe("Validator Aggregation", func() { + It("should aggregate multiple validator results correctly", func() { + executor := validator.NewExecutor(vctx, logger) + + results, err := executor.ExecuteAll(ctx) + Expect(err).NotTo(HaveOccurred()) + + // Aggregate results + aggregated := validator.Aggregate(results) + + Expect(aggregated).NotTo(BeNil()) + Expect(aggregated.Status).To(BeElementOf( + validator.StatusSuccess, + validator.StatusFailure, + )) + Expect(aggregated.Message).NotTo(BeEmpty()) + Expect(aggregated.Details).NotTo(BeEmpty()) + + // Extract counts from Details map + checksRun, ok := aggregated.Details["checks_run"].(int) + Expect(ok).To(BeTrue(), "checks_run should be an int") + Expect(checksRun).To(Equal(len(results))) + + checksPassed, ok := aggregated.Details["checks_passed"].(int) + Expect(ok).To(BeTrue(), "checks_passed should be an int") + + successCount := 0 + failureCount := 0 + for _, r := range results { + if r.Status == validator.StatusSuccess { + successCount++ + } else { + failureCount++ + } + } + + Expect(checksPassed).To(Equal(successCount)) + Expect(checksRun - checksPassed).To(Equal(failureCount)) + + logger.Info("Aggregated results", + "status", aggregated.Status, + "checks_run", checksRun, + "checks_passed", checksPassed, + "checks_failed", checksRun-checksPassed, + "message", aggregated.Message) + }) + }) + + Describe("Shared State Between Validators", func() { + It("should maintain shared state in context across validators", func() { + executor := validator.NewExecutor(vctx, logger) + + results, err := executor.ExecuteAll(ctx) + Expect(err).NotTo(HaveOccurred()) + + // Verify results are stored in context + Expect(vctx.Results).To(HaveLen(len(results))) + + for _, result := range results { + Expect(vctx.Results).To(HaveKey(result.ValidatorName)) + Expect(vctx.Results[result.ValidatorName]).To(Equal(result)) + } + + logger.Info("Verified shared state", + "validators_in_context", len(vctx.Results)) + }) + }) + + Describe("Real GCP API Integration", func() { + Context("when checking actual GCP project state", func() { + It("should successfully interact with GCP APIs", func() { + // Get Cloud Resource Manager service + svc, err := vctx.GetCloudResourceManagerService(ctx) + Expect(err).NotTo(HaveOccurred()) + + // Make real API call + project, err := svc.Projects.Get(cfg.ProjectID).Context(ctx).Do() + Expect(err).NotTo(HaveOccurred()) + + Expect(project.ProjectId).To(Equal(cfg.ProjectID)) + Expect(project.ProjectNumber).To(BeNumerically(">", 0)) + Expect(project.LifecycleState).To(Equal("ACTIVE")) + + // Store project number in context (validators might use this) + vctx.ProjectNumber = project.ProjectNumber + + logger.Info("Successfully retrieved real project details", + "projectId", project.ProjectId, + "projectNumber", project.ProjectNumber, + "name", project.Name, + "state", project.LifecycleState) + }) + + It("should successfully check if Compute API is accessible", func() { + svc, err := vctx.GetServiceUsageService(ctx) + Expect(err).NotTo(HaveOccurred()) + + serviceName := "projects/" + cfg.ProjectID + "/services/compute.googleapis.com" + service, err := svc.Services.Get(serviceName).Context(ctx).Do() + + if err != nil { + logger.Warn("Failed to get Compute API status", "error", err.Error()) + // Don't fail test - API might not be enabled + return + } + + Expect(service).NotTo(BeNil()) + logger.Info("Compute API status", + "name", service.Name, + "state", service.State) + }) + }) + }) + + Describe("Performance and Timeout", func() { + It("should complete all validators within reasonable time", func() { + start := time.Now() + + executor := validator.NewExecutor(vctx, logger) + _, err := executor.ExecuteAll(ctx) + + duration := time.Since(start) + + Expect(err).NotTo(HaveOccurred()) + Expect(duration).To(BeNumerically("<", 30*time.Second), + "All validators should complete within 30 seconds") + + logger.Info("Performance test completed", + "total_duration", duration.String()) + }) + + It("should respect global timeout from configuration", func() { + // Create short timeout config + shortTimeout := 5 * time.Second + cfg.MaxWaitTimeSeconds = int(shortTimeout.Seconds()) + + shortCtx, shortCancel := context.WithTimeout(context.Background(), shortTimeout) + defer shortCancel() + + executor := validator.NewExecutor(vctx, logger) + results, err := executor.ExecuteAll(shortCtx) + + // Should either complete or respect timeout + if err != nil { + Expect(err.Error()).To(ContainSubstring("context")) + } else { + Expect(results).NotTo(BeNil()) + } + + logger.Info("Timeout test completed") + }) + }) }) From 92ca51271bf4644f62ef26301159695df9976b8c Mon Sep 17 00:00:00 2001 From: dawang Date: Fri, 23 Jan 2026 19:38:40 +0800 Subject: [PATCH 12/14] Support thread-safe and fix some issue with coderabbit review comments --- validator/pkg/validator/context.go | 71 +++++++++++++------ validator/pkg/validator/context_test.go | 47 +++++++++--- validator/pkg/validator/resolver.go | 13 ++-- validator/pkg/validator/resolver_test.go | 26 ++++++- .../integration/validator_integration_test.go | 43 +---------- 5 files changed, 122 insertions(+), 78 deletions(-) diff --git a/validator/pkg/validator/context.go b/validator/pkg/validator/context.go index 9efdb85..054dc41 100644 --- a/validator/pkg/validator/context.go +++ b/validator/pkg/validator/context.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "log/slog" + "sync" "google.golang.org/api/cloudresourcemanager/v1" "google.golang.org/api/compute/v1" @@ -20,6 +21,7 @@ import ( // - Services are only created when first requested by validators // - OAuth scopes are only requested for services that are actually used // - Disabled validators never trigger authentication for their services +// Thread-safe: Uses sync.Once to ensure services are initialized exactly once type Context struct { // Configuration Config *config.Config @@ -35,6 +37,15 @@ type Context struct { serviceUsageService *serviceusage.Service monitoringService *monitoring.Service + // Thread-safe lazy initialization guards + // Each sync.Once ensures its corresponding service is created exactly once, + // even when called concurrently from multiple validators + computeOnce sync.Once + iamOnce sync.Once + cloudResourceMgrOnce sync.Once + serviceUsageOnce sync.Once + monitoringOnce sync.Once + // Shared state between validators ProjectNumber int64 @@ -53,65 +64,85 @@ func NewContext(cfg *config.Config, logger *slog.Logger) *Context { // GetComputeService returns the Compute Engine service, creating it lazily on first use // Only requests compute.readonly scope when a validator actually needs it +// Thread-safe: Uses sync.Once to ensure the service is created exactly once func (c *Context) GetComputeService(ctx context.Context) (*compute.Service, error) { - if c.computeService == nil { - svc, err := c.clientFactory.CreateComputeService(ctx) + var err error + c.computeOnce.Do(func() { + c.computeService, err = c.clientFactory.CreateComputeService(ctx) if err != nil { - return nil, fmt.Errorf("failed to create compute service: %w", err) + err = fmt.Errorf("failed to create compute service: %w", err) } - c.computeService = svc + }) + if err != nil { + return nil, err } return c.computeService, nil } // GetIAMService returns the IAM service, creating it lazily on first use // Only requests cloud-platform.read-only scope when a validator actually needs it +// Thread-safe: Uses sync.Once to ensure the service is created exactly once func (c *Context) GetIAMService(ctx context.Context) (*iam.Service, error) { - if c.iamService == nil { - svc, err := c.clientFactory.CreateIAMService(ctx) + var err error + c.iamOnce.Do(func() { + c.iamService, err = c.clientFactory.CreateIAMService(ctx) if err != nil { - return nil, fmt.Errorf("failed to create IAM service: %w", err) + err = fmt.Errorf("failed to create IAM service: %w", err) } - c.iamService = svc + }) + if err != nil { + return nil, err } return c.iamService, nil } // GetCloudResourceManagerService returns the Cloud Resource Manager service, creating it lazily on first use // Only requests cloudresourcemanager.readonly scope when a validator actually needs it +// Thread-safe: Uses sync.Once to ensure the service is created exactly once func (c *Context) GetCloudResourceManagerService(ctx context.Context) (*cloudresourcemanager.Service, error) { - if c.cloudResourceManagerSvc == nil { - svc, err := c.clientFactory.CreateCloudResourceManagerService(ctx) + var err error + c.cloudResourceMgrOnce.Do(func() { + c.cloudResourceManagerSvc, err = c.clientFactory.CreateCloudResourceManagerService(ctx) if err != nil { - return nil, fmt.Errorf("failed to create cloud resource manager service: %w", err) + err = fmt.Errorf("failed to create cloud resource manager service: %w", err) } - c.cloudResourceManagerSvc = svc + }) + if err != nil { + return nil, err } return c.cloudResourceManagerSvc, nil } // GetServiceUsageService returns the Service Usage service, creating it lazily on first use // Only requests serviceusage.readonly scope when a validator actually needs it +// Thread-safe: Uses sync.Once to ensure the service is created exactly once func (c *Context) GetServiceUsageService(ctx context.Context) (*serviceusage.Service, error) { - if c.serviceUsageService == nil { - svc, err := c.clientFactory.CreateServiceUsageService(ctx) + var err error + c.serviceUsageOnce.Do(func() { + c.serviceUsageService, err = c.clientFactory.CreateServiceUsageService(ctx) if err != nil { - return nil, fmt.Errorf("failed to create service usage service: %w", err) + err = fmt.Errorf("failed to create service usage service: %w", err) } - c.serviceUsageService = svc + }) + if err != nil { + return nil, err } return c.serviceUsageService, nil } // GetMonitoringService returns the Monitoring service, creating it lazily on first use // Only requests monitoring.read scope when a validator actually needs it +// Thread-safe: Uses sync.Once to ensure the service is created exactly once func (c *Context) GetMonitoringService(ctx context.Context) (*monitoring.Service, error) { - if c.monitoringService == nil { - svc, err := c.clientFactory.CreateMonitoringService(ctx) + var err error + c.monitoringOnce.Do(func() { + c.monitoringService, err = c.clientFactory.CreateMonitoringService(ctx) if err != nil { - return nil, fmt.Errorf("failed to create monitoring service: %w", err) + err = fmt.Errorf("failed to create monitoring service: %w", err) } - c.monitoringService = svc + }) + if err != nil { + return nil, err } return c.monitoringService, nil } diff --git a/validator/pkg/validator/context_test.go b/validator/pkg/validator/context_test.go index 66385f8..5ab197e 100644 --- a/validator/pkg/validator/context_test.go +++ b/validator/pkg/validator/context_test.go @@ -187,27 +187,56 @@ var _ = Describe("Context", func() { vctx = validator.NewContext(cfg, logger) }) + It("should handle concurrent access to the SAME getter safely", func() { + ctx := context.Background() + var wg sync.WaitGroup + const numGoroutines = 50 + + // This test validates the critical race condition fix: + // Multiple goroutines calling the same getter concurrently should only + // create the service once, not 50 times. + // Before the sync.Once fix, all 50 goroutines could pass the nil check + // and create duplicate service instances (resource waste + race condition). + for i := 0; i < numGoroutines; i++ { + wg.Add(1) + go func() { + defer GinkgoRecover() + defer wg.Done() + _, _ = vctx.GetServiceUsageService(ctx) + // Don't check error - we just verify sync.Once prevents race conditions + }() + } + + // Should complete without race conditions or panics + // Run with -race flag to detect any data races + wg.Wait() + }) - It("should handle concurrent access to different getters safely", func() { + It("should handle concurrent access to ALL getters from many goroutines", func() { ctx := context.Background() var wg sync.WaitGroup + const goroutinesPerGetter = 20 - // Launch multiple goroutines calling different getters + // All service getters getters := []func(context.Context) (interface{}, error){ func(ctx context.Context) (interface{}, error) { return vctx.GetComputeService(ctx) }, func(ctx context.Context) (interface{}, error) { return vctx.GetIAMService(ctx) }, func(ctx context.Context) (interface{}, error) { return vctx.GetServiceUsageService(ctx) }, func(ctx context.Context) (interface{}, error) { return vctx.GetMonitoringService(ctx) }, + func(ctx context.Context) (interface{}, error) { return vctx.GetCloudResourceManagerService(ctx) }, } + // Launch multiple goroutines for each getter + // This simulates real-world parallel validator execution for _, getter := range getters { - wg.Add(1) - go func(g func(context.Context) (interface{}, error)) { - defer GinkgoRecover() - defer wg.Done() - _, _ = g(ctx) - // Don't check error - just verify no race conditions/panics - }(getter) + for i := 0; i < goroutinesPerGetter; i++ { + wg.Add(1) + go func(g func(context.Context) (interface{}, error)) { + defer GinkgoRecover() + defer wg.Done() + _, _ = g(ctx) + }(getter) + } } // Should complete without race conditions or panics diff --git a/validator/pkg/validator/resolver.go b/validator/pkg/validator/resolver.go index 82bc971..b2c6162 100644 --- a/validator/pkg/validator/resolver.go +++ b/validator/pkg/validator/resolver.go @@ -165,13 +165,12 @@ func (r *DependencyResolver) ToMermaid() string { // Add edges for all dependencies for name, v := range r.validators { meta := v.Metadata() - if len(meta.RunAfter) > 0 { - hasDependencies[name] = true - for _, dep := range meta.RunAfter { - // Only show edge if dependency exists in our validator set - if _, exists := r.validators[dep]; exists { - result += fmt.Sprintf(" %s --> %s\n", name, dep) - } + for _, dep := range meta.RunAfter { + // Only show edge if dependency exists in our validator set + if _, exists := r.validators[dep]; exists { + result += fmt.Sprintf(" %s --> %s\n", name, dep) + // Only mark as having dependencies when at least one edge is actually emitted + hasDependencies[name] = true } } } diff --git a/validator/pkg/validator/resolver_test.go b/validator/pkg/validator/resolver_test.go index 1c3edb6..46c9ee9 100644 --- a/validator/pkg/validator/resolver_test.go +++ b/validator/pkg/validator/resolver_test.go @@ -421,11 +421,35 @@ var _ = Describe("DependencyResolver", func() { resolver = validator.NewDependencyResolver(validators) }) - It("should not render edges for missing dependencies", func() { + It("should not render edges for missing dependencies but still show the validator as standalone node", func() { mermaid := resolver.ToMermaid() Expect(mermaid).To(ContainSubstring("flowchart TD")) Expect(mermaid).NotTo(ContainSubstring("-->")) Expect(mermaid).NotTo(ContainSubstring("non-existent")) + // Validator should still appear as a standalone node since no edges were emitted + Expect(mermaid).To(ContainSubstring("validator-a")) + }) + }) + + Context("with partial missing dependencies", func() { + BeforeEach(func() { + validators = []validator.Validator{ + &MockValidator{name: "validator-a", runAfter: []string{}, enabled: true}, + &MockValidator{name: "validator-b", runAfter: []string{"validator-a", "non-existent"}, enabled: true}, + } + resolver = validator.NewDependencyResolver(validators) + }) + + It("should render edges only for existing dependencies and not show validator as standalone", func() { + mermaid := resolver.ToMermaid() + Expect(mermaid).To(ContainSubstring("flowchart TD")) + // Should have edge to existing dependency + Expect(mermaid).To(ContainSubstring("validator-b --> validator-a")) + // Should not reference missing dependency + Expect(mermaid).NotTo(ContainSubstring("non-existent")) + // validator-b should not appear as standalone since it has at least one valid edge + // validator-a should appear as standalone + Expect(mermaid).To(MatchRegexp(`(?m)^\s+validator-a\s*$`)) }) }) }) diff --git a/validator/test/integration/validator_integration_test.go b/validator/test/integration/validator_integration_test.go index 4289ea1..de04dac 100644 --- a/validator/test/integration/validator_integration_test.go +++ b/validator/test/integration/validator_integration_test.go @@ -239,8 +239,8 @@ var _ = Describe("Validator Integration Tests", func() { if err != nil { logger.Warn("Failed to get Compute API status", "error", err.Error()) - // Don't fail test - API might not be enabled - return + // Skip test - API might not be enabled + Skip("Compute API not accessible: " + err.Error()) } Expect(service).NotTo(BeNil()) @@ -250,43 +250,4 @@ var _ = Describe("Validator Integration Tests", func() { }) }) }) - - Describe("Performance and Timeout", func() { - It("should complete all validators within reasonable time", func() { - start := time.Now() - - executor := validator.NewExecutor(vctx, logger) - _, err := executor.ExecuteAll(ctx) - - duration := time.Since(start) - - Expect(err).NotTo(HaveOccurred()) - Expect(duration).To(BeNumerically("<", 30*time.Second), - "All validators should complete within 30 seconds") - - logger.Info("Performance test completed", - "total_duration", duration.String()) - }) - - It("should respect global timeout from configuration", func() { - // Create short timeout config - shortTimeout := 5 * time.Second - cfg.MaxWaitTimeSeconds = int(shortTimeout.Seconds()) - - shortCtx, shortCancel := context.WithTimeout(context.Background(), shortTimeout) - defer shortCancel() - - executor := validator.NewExecutor(vctx, logger) - results, err := executor.ExecuteAll(shortCtx) - - // Should either complete or respect timeout - if err != nil { - Expect(err.Error()).To(ContainSubstring("context")) - } else { - Expect(results).NotTo(BeNil()) - } - - logger.Info("Timeout test completed") - }) - }) }) From 05684ad8481bbc540eda5e8521ff472d7ac7f1b7 Mon Sep 17 00:00:00 2001 From: dawang Date: Fri, 23 Jan 2026 20:13:50 +0800 Subject: [PATCH 13/14] Remove confused Enabled of validator and update related test cases --- validator/pkg/validator/executor.go | 6 +- validator/pkg/validator/executor_test.go | 12 +-- validator/pkg/validator/registry_test.go | 8 -- validator/pkg/validator/resolver_test.go | 78 +++++++------------ validator/pkg/validator/validator.go | 3 - validator/pkg/validators/api_enabled.go | 5 -- validator/pkg/validators/api_enabled_test.go | 12 +-- validator/pkg/validators/quota_check.go | 5 -- validator/pkg/validators/quota_check_test.go | 12 +-- .../integration/validator_integration_test.go | 9 ++- 10 files changed, 51 insertions(+), 99 deletions(-) diff --git a/validator/pkg/validator/executor.go b/validator/pkg/validator/executor.go index ce7c791..74c2cd5 100644 --- a/validator/pkg/validator/executor.go +++ b/validator/pkg/validator/executor.go @@ -29,13 +29,13 @@ func (e *Executor) ExecuteAll(ctx context.Context) ([]*Result, error) { // 1. Get all registered validators allValidators := GetAll() - // 2. Filter enabled validators + // 2. Filter enabled validators using config enabledValidators := []Validator{} for _, v := range allValidators { - if v.Enabled(e.ctx) { + meta := v.Metadata() + if e.ctx.Config.IsValidatorEnabled(meta.Name) { enabledValidators = append(enabledValidators, v) } else { - meta := v.Metadata() e.logger.Info("Validator disabled, skipping", "validator", meta.Name) } } diff --git a/validator/pkg/validator/executor_test.go b/validator/pkg/validator/executor_test.go index 13b23b0..e005e8c 100644 --- a/validator/pkg/validator/executor_test.go +++ b/validator/pkg/validator/executor_test.go @@ -58,7 +58,6 @@ var _ = Describe("Executor", func() { BeforeEach(func() { mockValidator = &MockValidator{ name: "test-validator", - enabled: true, validateFunc: func(ctx context.Context, vctx *validator.Context) *validator.Result { return &validator.Result{ ValidatorName: "test-validator", @@ -102,9 +101,10 @@ var _ = Describe("Executor", func() { BeforeEach(func() { mockValidator = &MockValidator{ name: "disabled-validator", - enabled: false, } validator.Register(mockValidator) + // Disable the validator via config + vctx.Config.DisabledValidators = []string{"disabled-validator"} }) It("should skip disabled validators", func() { @@ -122,7 +122,6 @@ var _ = Describe("Executor", func() { n := name // Capture loop variable for closure validator.Register(&MockValidator{ name: n, - enabled: true, validateFunc: func(ctx context.Context, vctx *validator.Context) *validator.Result { time.Sleep(10 * time.Millisecond) // Simulate work return &validator.Result{ @@ -167,7 +166,6 @@ var _ = Describe("Executor", func() { validator.Register(&MockValidator{ name: "validator-a", runAfter: []string{}, - enabled: true, validateFunc: func(ctx context.Context, vctx *validator.Context) *validator.Result { mu.Lock() executionOrder = append(executionOrder, "validator-a") @@ -185,7 +183,6 @@ var _ = Describe("Executor", func() { validator.Register(&MockValidator{ name: n, runAfter: []string{"validator-a"}, - enabled: true, validateFunc: func(ctx context.Context, vctx *validator.Context) *validator.Result { mu.Lock() executionOrder = append(executionOrder, n) @@ -222,7 +219,6 @@ var _ = Describe("Executor", func() { validator.Register(&MockValidator{ name: n, runAfter: []string{"validator-a"}, // depends on validator-a which isn't registered yet - enabled: true, validateFunc: func(ctx context.Context, vctx *validator.Context) *validator.Result { mu.Lock() executionOrder = append(executionOrder, n) @@ -239,7 +235,6 @@ var _ = Describe("Executor", func() { validator.Register(&MockValidator{ name: "validator-a", runAfter: []string{}, - enabled: true, validateFunc: func(ctx context.Context, vctx *validator.Context) *validator.Result { mu.Lock() executionOrder = append(executionOrder, "validator-a") @@ -269,7 +264,6 @@ var _ = Describe("Executor", func() { // First validator fails validator.Register(&MockValidator{ name: "failing-validator", - enabled: true, validateFunc: func(ctx context.Context, vctx *validator.Context) *validator.Result { return &validator.Result{ ValidatorName: "failing-validator", @@ -284,7 +278,6 @@ var _ = Describe("Executor", func() { validator.Register(&MockValidator{ name: "should-not-run", runAfter: []string{"failing-validator"}, - enabled: true, validateFunc: func(ctx context.Context, vctx *validator.Context) *validator.Result { Fail("This validator should not execute") return nil @@ -305,7 +298,6 @@ var _ = Describe("Executor", func() { BeforeEach(func() { validator.Register(&MockValidator{ name: "failing-validator", - enabled: true, validateFunc: func(ctx context.Context, vctx *validator.Context) *validator.Result { return &validator.Result{ ValidatorName: "failing-validator", diff --git a/validator/pkg/validator/registry_test.go b/validator/pkg/validator/registry_test.go index 5eb8ac7..510d1c7 100644 --- a/validator/pkg/validator/registry_test.go +++ b/validator/pkg/validator/registry_test.go @@ -15,7 +15,6 @@ type MockValidator struct { description string runAfter []string tags []string - enabled bool validateFunc func(ctx context.Context, vctx *validator.Context) *validator.Result } @@ -28,10 +27,6 @@ func (m *MockValidator) Metadata() validator.ValidatorMetadata { } } -func (m *MockValidator) Enabled(ctx *validator.Context) bool { - return m.enabled -} - func (m *MockValidator) Validate(ctx context.Context, vctx *validator.Context) *validator.Result { if m.validateFunc != nil { return m.validateFunc(ctx, vctx) @@ -58,14 +53,12 @@ var _ = Describe("Registry", func() { description: "First test validator", runAfter: []string{}, tags: []string{"test", "mock"}, - enabled: true, } mockValidator2 = &MockValidator{ name: "test-validator-2", description: "Second test validator", runAfter: []string{"test-validator-1"}, tags: []string{"test", "dependent"}, - enabled: true, } }) @@ -94,7 +87,6 @@ var _ = Describe("Registry", func() { duplicate := &MockValidator{ name: "test-validator-1", description: "Duplicate validator", - enabled: true, } testRegistry.Register(duplicate) validators := testRegistry.GetAll() diff --git a/validator/pkg/validator/resolver_test.go b/validator/pkg/validator/resolver_test.go index 46c9ee9..32d929a 100644 --- a/validator/pkg/validator/resolver_test.go +++ b/validator/pkg/validator/resolver_test.go @@ -20,17 +20,14 @@ var _ = Describe("DependencyResolver", func() { &MockValidator{ name: "validator-a", runAfter: []string{}, - enabled: true, }, &MockValidator{ name: "validator-b", runAfter: []string{}, - enabled: true, }, &MockValidator{ name: "validator-c", runAfter: []string{}, - enabled: true, }, } resolver = validator.NewDependencyResolver(validators) @@ -61,17 +58,14 @@ var _ = Describe("DependencyResolver", func() { &MockValidator{ name: "validator-a", runAfter: []string{}, - enabled: true, }, &MockValidator{ name: "validator-b", runAfter: []string{"validator-a"}, - enabled: true, }, &MockValidator{ name: "validator-c", runAfter: []string{"validator-b"}, - enabled: true, }, } resolver = validator.NewDependencyResolver(validators) @@ -102,22 +96,18 @@ var _ = Describe("DependencyResolver", func() { &MockValidator{ name: "wif-check", runAfter: []string{}, - enabled: true, }, &MockValidator{ name: "api-enabled", runAfter: []string{"wif-check"}, - enabled: true, }, &MockValidator{ name: "quota-check", runAfter: []string{"wif-check"}, - enabled: true, }, &MockValidator{ name: "network-check", runAfter: []string{"wif-check"}, - enabled: true, }, } resolver = validator.NewDependencyResolver(validators) @@ -150,27 +140,22 @@ var _ = Describe("DependencyResolver", func() { &MockValidator{ name: "wif-check", runAfter: []string{}, - enabled: true, }, &MockValidator{ name: "api-enabled", runAfter: []string{"wif-check"}, - enabled: true, }, &MockValidator{ name: "quota-check", runAfter: []string{"wif-check"}, - enabled: true, }, &MockValidator{ name: "iam-check", runAfter: []string{"api-enabled"}, - enabled: true, }, &MockValidator{ name: "network-check", runAfter: []string{"api-enabled", "quota-check"}, - enabled: true, }, } resolver = validator.NewDependencyResolver(validators) @@ -201,22 +186,18 @@ var _ = Describe("DependencyResolver", func() { &MockValidator{ name: "wif-check", runAfter: []string{}, - enabled: true, }, &MockValidator{ name: "api-enabled", runAfter: []string{"wif-check"}, - enabled: true, }, &MockValidator{ name: "quota-check", runAfter: []string{"wif-check"}, - enabled: true, }, &MockValidator{ name: "network-check", runAfter: []string{"wif-check", "api-enabled"}, - enabled: true, }, } resolver = validator.NewDependencyResolver(validators) @@ -254,12 +235,10 @@ var _ = Describe("DependencyResolver", func() { &MockValidator{ name: "validator-a", runAfter: []string{"validator-b"}, - enabled: true, }, &MockValidator{ name: "validator-b", runAfter: []string{"validator-a"}, - enabled: true, }, } resolver = validator.NewDependencyResolver(validators) @@ -278,7 +257,6 @@ var _ = Describe("DependencyResolver", func() { &MockValidator{ name: "validator-a", runAfter: []string{"validator-a"}, - enabled: true, }, } resolver = validator.NewDependencyResolver(validators) @@ -297,17 +275,14 @@ var _ = Describe("DependencyResolver", func() { &MockValidator{ name: "validator-a", runAfter: []string{"validator-c"}, - enabled: true, }, &MockValidator{ name: "validator-b", runAfter: []string{"validator-a"}, - enabled: true, }, &MockValidator{ name: "validator-c", runAfter: []string{"validator-b"}, - enabled: true, }, } resolver = validator.NewDependencyResolver(validators) @@ -326,7 +301,6 @@ var _ = Describe("DependencyResolver", func() { &MockValidator{ name: "validator-a", runAfter: []string{"non-existent"}, - enabled: true, }, } resolver = validator.NewDependencyResolver(validators) @@ -359,8 +333,8 @@ var _ = Describe("DependencyResolver", func() { Context("with validators that have no dependencies", func() { BeforeEach(func() { validators = []validator.Validator{ - &MockValidator{name: "validator-a", runAfter: []string{}, enabled: true}, - &MockValidator{name: "validator-b", runAfter: []string{}, enabled: true}, + &MockValidator{name: "validator-a", runAfter: []string{}}, + &MockValidator{name: "validator-b", runAfter: []string{}}, } resolver = validator.NewDependencyResolver(validators) }) @@ -377,9 +351,9 @@ var _ = Describe("DependencyResolver", func() { Context("with linear dependencies", func() { BeforeEach(func() { validators = []validator.Validator{ - &MockValidator{name: "validator-a", runAfter: []string{}, enabled: true}, - &MockValidator{name: "validator-b", runAfter: []string{"validator-a"}, enabled: true}, - &MockValidator{name: "validator-c", runAfter: []string{"validator-b"}, enabled: true}, + &MockValidator{name: "validator-a", runAfter: []string{}}, + &MockValidator{name: "validator-b", runAfter: []string{"validator-a"}}, + &MockValidator{name: "validator-c", runAfter: []string{"validator-b"}}, } resolver = validator.NewDependencyResolver(validators) }) @@ -395,10 +369,10 @@ var _ = Describe("DependencyResolver", func() { Context("with complex dependencies", func() { BeforeEach(func() { validators = []validator.Validator{ - &MockValidator{name: "wif-check", runAfter: []string{}, enabled: true}, - &MockValidator{name: "api-enabled", runAfter: []string{"wif-check"}, enabled: true}, - &MockValidator{name: "quota-check", runAfter: []string{"wif-check"}, enabled: true}, - &MockValidator{name: "network-check", runAfter: []string{"api-enabled", "quota-check"}, enabled: true}, + &MockValidator{name: "wif-check", runAfter: []string{}}, + &MockValidator{name: "api-enabled", runAfter: []string{"wif-check"}}, + &MockValidator{name: "quota-check", runAfter: []string{"wif-check"}}, + &MockValidator{name: "network-check", runAfter: []string{"api-enabled", "quota-check"}}, } resolver = validator.NewDependencyResolver(validators) }) @@ -416,7 +390,7 @@ var _ = Describe("DependencyResolver", func() { Context("with missing dependency", func() { BeforeEach(func() { validators = []validator.Validator{ - &MockValidator{name: "validator-a", runAfter: []string{"non-existent"}, enabled: true}, + &MockValidator{name: "validator-a", runAfter: []string{"non-existent"}}, } resolver = validator.NewDependencyResolver(validators) }) @@ -434,8 +408,8 @@ var _ = Describe("DependencyResolver", func() { Context("with partial missing dependencies", func() { BeforeEach(func() { validators = []validator.Validator{ - &MockValidator{name: "validator-a", runAfter: []string{}, enabled: true}, - &MockValidator{name: "validator-b", runAfter: []string{"validator-a", "non-existent"}, enabled: true}, + &MockValidator{name: "validator-a", runAfter: []string{}}, + &MockValidator{name: "validator-b", runAfter: []string{"validator-a", "non-existent"}}, } resolver = validator.NewDependencyResolver(validators) }) @@ -458,8 +432,8 @@ var _ = Describe("DependencyResolver", func() { Context("with validators that have no dependencies", func() { BeforeEach(func() { validators = []validator.Validator{ - &MockValidator{name: "validator-a", runAfter: []string{}, enabled: true}, - &MockValidator{name: "validator-b", runAfter: []string{}, enabled: true}, + &MockValidator{name: "validator-a", runAfter: []string{}}, + &MockValidator{name: "validator-b", runAfter: []string{}}, } resolver = validator.NewDependencyResolver(validators) }) @@ -478,9 +452,9 @@ var _ = Describe("DependencyResolver", func() { Context("with linear dependencies", func() { BeforeEach(func() { validators = []validator.Validator{ - &MockValidator{name: "validator-a", runAfter: []string{}, enabled: true}, - &MockValidator{name: "validator-b", runAfter: []string{"validator-a"}, enabled: true}, - &MockValidator{name: "validator-c", runAfter: []string{"validator-b"}, enabled: true}, + &MockValidator{name: "validator-a", runAfter: []string{}}, + &MockValidator{name: "validator-b", runAfter: []string{"validator-a"}}, + &MockValidator{name: "validator-c", runAfter: []string{"validator-b"}}, } resolver = validator.NewDependencyResolver(validators) }) @@ -501,10 +475,10 @@ var _ = Describe("DependencyResolver", func() { Context("with parallel dependencies", func() { BeforeEach(func() { validators = []validator.Validator{ - &MockValidator{name: "wif-check", runAfter: []string{}, enabled: true}, - &MockValidator{name: "api-enabled", runAfter: []string{"wif-check"}, enabled: true}, - &MockValidator{name: "quota-check", runAfter: []string{"wif-check"}, enabled: true}, - &MockValidator{name: "network-check", runAfter: []string{"wif-check"}, enabled: true}, + &MockValidator{name: "wif-check", runAfter: []string{}}, + &MockValidator{name: "api-enabled", runAfter: []string{"wif-check"}}, + &MockValidator{name: "quota-check", runAfter: []string{"wif-check"}}, + &MockValidator{name: "network-check", runAfter: []string{"wif-check"}}, } resolver = validator.NewDependencyResolver(validators) }) @@ -526,11 +500,11 @@ var _ = Describe("DependencyResolver", func() { Context("with complex dependency graph", func() { BeforeEach(func() { validators = []validator.Validator{ - &MockValidator{name: "wif-check", runAfter: []string{}, enabled: true}, - &MockValidator{name: "api-enabled", runAfter: []string{"wif-check"}, enabled: true}, - &MockValidator{name: "quota-check", runAfter: []string{"wif-check"}, enabled: true}, - &MockValidator{name: "iam-check", runAfter: []string{"api-enabled"}, enabled: true}, - &MockValidator{name: "network-check", runAfter: []string{"api-enabled", "quota-check"}, enabled: true}, + &MockValidator{name: "wif-check", runAfter: []string{}}, + &MockValidator{name: "api-enabled", runAfter: []string{"wif-check"}}, + &MockValidator{name: "quota-check", runAfter: []string{"wif-check"}}, + &MockValidator{name: "iam-check", runAfter: []string{"api-enabled"}}, + &MockValidator{name: "network-check", runAfter: []string{"api-enabled", "quota-check"}}, } resolver = validator.NewDependencyResolver(validators) }) diff --git a/validator/pkg/validator/validator.go b/validator/pkg/validator/validator.go index 0413330..941003a 100644 --- a/validator/pkg/validator/validator.go +++ b/validator/pkg/validator/validator.go @@ -21,9 +21,6 @@ type Validator interface { // Metadata returns validator configuration (name, dependencies, etc.) Metadata() ValidatorMetadata - // Enabled determines if this validator should run based on context/config - Enabled(ctx *Context) bool - // Validate performs the actual validation logic Validate(ctx context.Context, vctx *Context) *Result } diff --git a/validator/pkg/validators/api_enabled.go b/validator/pkg/validators/api_enabled.go index a745b62..954d7c4 100644 --- a/validator/pkg/validators/api_enabled.go +++ b/validator/pkg/validators/api_enabled.go @@ -58,11 +58,6 @@ func (v *APIEnabledValidator) Metadata() validator.ValidatorMetadata { } } -// Enabled determines if this validator should run based on configuration -func (v *APIEnabledValidator) Enabled(ctx *validator.Context) bool { - return ctx.Config.IsValidatorEnabled("api-enabled") -} - // Validate performs the actual validation logic to check if required GCP APIs are enabled func (v *APIEnabledValidator) Validate(ctx context.Context, vctx *validator.Context) *validator.Result { slog.Info("Checking if required GCP APIs are enabled") diff --git a/validator/pkg/validators/api_enabled_test.go b/validator/pkg/validators/api_enabled_test.go index 71e9c7d..50dafd3 100644 --- a/validator/pkg/validators/api_enabled_test.go +++ b/validator/pkg/validators/api_enabled_test.go @@ -51,10 +51,11 @@ var _ = Describe("APIEnabledValidator", func() { }) }) - Describe("Enabled", func() { + Describe("Enabled Status", func() { Context("when validator is not explicitly disabled", func() { - It("should be enabled by default", func() { - enabled := v.Enabled(vctx) + It("should be enabled by default in config", func() { + meta := v.Metadata() + enabled := vctx.Config.IsValidatorEnabled(meta.Name) Expect(enabled).To(BeTrue()) }) }) @@ -67,8 +68,9 @@ var _ = Describe("APIEnabledValidator", func() { vctx.Config = cfg }) - It("should be disabled", func() { - enabled := v.Enabled(vctx) + It("should be disabled in config", func() { + meta := v.Metadata() + enabled := vctx.Config.IsValidatorEnabled(meta.Name) Expect(enabled).To(BeFalse()) }) }) diff --git a/validator/pkg/validators/quota_check.go b/validator/pkg/validators/quota_check.go index 1535db0..a747a17 100644 --- a/validator/pkg/validators/quota_check.go +++ b/validator/pkg/validators/quota_check.go @@ -26,11 +26,6 @@ func (v *QuotaCheckValidator) Metadata() validator.ValidatorMetadata { } } -// Enabled determines if this validator should run based on configuration -func (v *QuotaCheckValidator) Enabled(ctx *validator.Context) bool { - return ctx.Config.IsValidatorEnabled("quota-check") -} - // Validate performs the actual validation logic (currently a stub returning success) func (v *QuotaCheckValidator) Validate(ctx context.Context, vctx *validator.Context) *validator.Result { slog.Info("Running quota check validator (stub implementation)") diff --git a/validator/pkg/validators/quota_check_test.go b/validator/pkg/validators/quota_check_test.go index c5db997..6f047bc 100644 --- a/validator/pkg/validators/quota_check_test.go +++ b/validator/pkg/validators/quota_check_test.go @@ -53,10 +53,11 @@ var _ = Describe("QuotaCheckValidator", func() { }) }) - Describe("Enabled", func() { + Describe("Enabled Status", func() { Context("when validator is not explicitly disabled", func() { - It("should be enabled by default", func() { - enabled := v.Enabled(vctx) + It("should be enabled by default in config", func() { + meta := v.Metadata() + enabled := vctx.Config.IsValidatorEnabled(meta.Name) Expect(enabled).To(BeTrue()) }) }) @@ -69,8 +70,9 @@ var _ = Describe("QuotaCheckValidator", func() { vctx.Config = cfg }) - It("should be disabled", func() { - enabled := v.Enabled(vctx) + It("should be disabled in config", func() { + meta := v.Metadata() + enabled := vctx.Config.IsValidatorEnabled(meta.Name) Expect(enabled).To(BeFalse()) }) }) diff --git a/validator/test/integration/validator_integration_test.go b/validator/test/integration/validator_integration_test.go index de04dac..ca6b797 100644 --- a/validator/test/integration/validator_integration_test.go +++ b/validator/test/integration/validator_integration_test.go @@ -81,8 +81,9 @@ var _ = Describe("Validator Integration Tests", func() { v, exists := validator.Get("api-enabled") Expect(exists).To(BeTrue(), "api-enabled validator should be registered") - // Check if it's enabled - enabled := v.Enabled(vctx) + // Check if it's enabled using config + meta := v.Metadata() + enabled := vctx.Config.IsValidatorEnabled(meta.Name) if !enabled { Skip("api-enabled validator is disabled in configuration") } @@ -116,7 +117,9 @@ var _ = Describe("Validator Integration Tests", func() { v, exists := validator.Get("quota-check") Expect(exists).To(BeTrue(), "quota-check validator should be registered") - enabled := v.Enabled(vctx) + // Check if it's enabled using config + meta := v.Metadata() + enabled := vctx.Config.IsValidatorEnabled(meta.Name) if !enabled { Skip("quota-check validator is disabled in configuration") } From 54fc351df5fac610ffd4b623a8db8e9d09255084 Mon Sep 17 00:00:00 2001 From: dawang Date: Fri, 23 Jan 2026 20:27:43 +0800 Subject: [PATCH 14/14] Remove StatusSkipped --- validator/README.md | 2 +- validator/pkg/validator/executor.go | 4 ---- validator/pkg/validator/validator.go | 1 - 3 files changed, 1 insertion(+), 6 deletions(-) diff --git a/validator/README.md b/validator/README.md index 7b2c4e0..653efa8 100644 --- a/validator/README.md +++ b/validator/README.md @@ -50,7 +50,7 @@ docker run --rm \ ### Optional - `RESULTS_PATH` - Output file path (default: `/results/adapter-result.json`) -- `DISABLED_VALIDATORS` - Comma-separated list to disable (e.g., `quota-check`) +- `DISABLED_VALIDATORS` - Comma-separated list to disable (e.g., `quota-check`). Note: At least one validator must remain enabled. - `STOP_ON_FIRST_FAILURE` - Stop on first failure (default: `false`) - `REQUIRED_APIS` - APIs to check (default: `compute.googleapis.com,iam.googleapis.com,cloudresourcemanager.googleapis.com`) - `LOG_LEVEL` - Log level: `debug`, `info`, `warn`, `error` (default: `info`) diff --git a/validator/pkg/validator/executor.go b/validator/pkg/validator/executor.go index 74c2cd5..4120a16 100644 --- a/validator/pkg/validator/executor.go +++ b/validator/pkg/validator/executor.go @@ -178,10 +178,6 @@ func (e *Executor) executeGroup(ctx context.Context, group ExecutionGroup) []*Re "reason", result.Reason, "message", result.Message) e.logger.Warn("Validator completed with failure", logAttrs...) - case StatusSkipped: - // Add reason for skipped validators - logAttrs = append(logAttrs, "reason", result.Reason) - e.logger.Info("Validator skipped", logAttrs...) default: e.logger.Info("Validator completed", logAttrs...) } diff --git a/validator/pkg/validator/validator.go b/validator/pkg/validator/validator.go index 941003a..8c2acaa 100644 --- a/validator/pkg/validator/validator.go +++ b/validator/pkg/validator/validator.go @@ -31,7 +31,6 @@ type Status string const ( StatusSuccess Status = "success" StatusFailure Status = "failure" - StatusSkipped Status = "skipped" ) // Result represents the outcome of a single validator