From 914384dfcac5d66742466190b70af5fafa957f1b Mon Sep 17 00:00:00 2001 From: Simon Baird Date: Wed, 8 Jul 2026 12:34:54 -0400 Subject: [PATCH 01/12] Only run license lint on checked in files Fix a long-running annoyance. Avoid these kind of errors when running `make lint` locally: Missing license header in: input.yaml Missing license header in: zz.yaml (Unrelated to, but included in PR for...) Ref: https://redhat.atlassian.net/browse/EC-1882 Co-Authored-By: Claude Opus 4.6 --- Makefile | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Makefile b/Makefile index 41ddf1491..9f094e69e 100644 --- a/Makefile +++ b/Makefile @@ -227,15 +227,15 @@ LINT_TO_GITHUB_ANNOTATIONS='map(map(.)[])[][] as $$d | $$d.posn | split(":") as .PHONY: lint lint: tekton-lint go-mod-lint ## Run linter # addlicense doesn't give us a nice explanation so we prefix it with one - @go run -modfile tools/go.mod github.com/google/addlicense -c '$(COPY)' -y '' -s -check $(LICENSE_IGNORE) . | sed 's/^/Missing license header in: /g' + @git ls-files -z | xargs -0 go run -modfile tools/go.mod github.com/google/addlicense -c '$(COPY)' -y '' -s -check $(LICENSE_IGNORE) | sed 's/^/Missing license header in: /g' # piping to sed above looses the exit code, luckily addlicense is fast so we invoke it for the second time to exit 1 in case of issues - @go run -modfile tools/go.mod github.com/google/addlicense -c '$(COPY)' -y '' -s -check $(LICENSE_IGNORE) . >/dev/null 2>&1 + @git ls-files -z | xargs -0 go run -modfile tools/go.mod github.com/google/addlicense -c '$(COPY)' -y '' -s -check $(LICENSE_IGNORE) >/dev/null 2>&1 @go run -modfile tools/go.mod github.com/golangci/golangci-lint/v2/cmd/golangci-lint run $(if $(GITHUB_ACTIONS), --timeout=10m0s) @(cd acceptance && go run -modfile ../tools/go.mod github.com/golangci/golangci-lint/v2/cmd/golangci-lint run --path-prefix acceptance $(if $(GITHUB_ACTIONS), --timeout=10m0s)) .PHONY: lint-fix lint-fix: ## Fix linting issues automagically - @go run -modfile tools/go.mod github.com/google/addlicense -c '$(COPY)' -y '' -s $(LICENSE_IGNORE) . + @git ls-files -z | xargs -0 go run -modfile tools/go.mod github.com/google/addlicense -c '$(COPY)' -y '' -s $(LICENSE_IGNORE) @go run -modfile tools/go.mod github.com/golangci/golangci-lint/v2/cmd/golangci-lint run --fix @(cd acceptance && go run -modfile ../tools/go.mod github.com/golangci/golangci-lint/v2/cmd/golangci-lint run --path-prefix acceptance --fix) # We don't apply the fixes from the internal (error handling) linter. From 2eeb09339cb6bb71cef9a5a0d4cf1c9070b7eb43 Mon Sep 17 00:00:00 2001 From: Simon Baird Date: Mon, 6 Jul 2026 14:37:10 -0400 Subject: [PATCH 02/12] Add --server flag, and health endpoints Add --server and --server-port flags to `ec validate input` that start a persistent HTTP server instead of running a one-shot evaluation. Policies are loaded once at startup via pre-created evaluators. The server shuts down gracefully on context cancellation. Health endpoints /live and /ready support Kubernetes-style probes. Ref: https://redhat.atlassian.net/browse/EC-1883 Ref: https://redhat.atlassian.net/browse/EC-1886 Co-Authored-By: Claude Opus 4.6 --- cmd/validate/input.go | 35 ++++- .../modules/ROOT/pages/ec_validate_input.adoc | 5 + internal/server/server.go | 135 ++++++++++++++++++ 3 files changed, 174 insertions(+), 1 deletion(-) create mode 100644 internal/server/server.go diff --git a/cmd/validate/input.go b/cmd/validate/input.go index f6b6df7f1..ca005135f 100644 --- a/cmd/validate/input.go +++ b/cmd/validate/input.go @@ -33,6 +33,7 @@ import ( "github.com/conforma/cli/internal/input" "github.com/conforma/cli/internal/output" "github.com/conforma/cli/internal/policy" + "github.com/conforma/cli/internal/server" "github.com/conforma/cli/internal/utils" validate_utils "github.com/conforma/cli/internal/validate" ) @@ -53,10 +54,13 @@ func validateInputCmd(validate InputValidationFunc) *cobra.Command { policyConfiguration string strict bool workers int + serverMode bool + serverPort int }{ strict: true, workers: 5, filterType: "include-exclude", // Default to include-exclude filter + serverPort: 8080, } cmd := &cobra.Command{ Use: "input [file ...] [flags]", @@ -116,7 +120,11 @@ func validateInputCmd(validate InputValidationFunc) *cobra.Command { return strings.TrimSpace(s) == "" }) - if len(data.filePaths) == 0 { + if data.serverMode && len(data.filePaths) > 0 { + allErrors = errors.Join(allErrors, fmt.Errorf("--server and input files are mutually exclusive")) + return + } + if !data.serverMode && len(data.filePaths) == 0 { allErrors = errors.Join(allErrors, fmt.Errorf("at least one input file must be specified as a positional argument or via the --file flag")) return } @@ -138,6 +146,22 @@ func validateInputCmd(validate InputValidationFunc) *cobra.Command { return }, RunE: func(cmd *cobra.Command, args []string) error { + if data.serverMode { + showSuccesses, _ := cmd.Flags().GetBool("show-successes") + showWarnings, _ := cmd.Flags().GetBool("show-warnings") + showPolicyDocsLink, _ := cmd.Flags().GetBool("show-policy-docs-link") + + srv := server.New(server.Config{ + Port: data.serverPort, + Policy: data.policy, + Info: data.info, + ShowSuccesses: showSuccesses, + ShowWarnings: showWarnings, + ShowPolicyDocsLink: showPolicyDocsLink, + }) + return srv.Start(cmd.Context()) + } + if trace.IsEnabled() { ctx, task := trace.NewTask(cmd.Context(), "ec:validate-inputs") cmd.SetContext(ctx) @@ -310,6 +334,15 @@ func validateInputCmd(validate InputValidationFunc) *cobra.Command { panic(err) } + cmd.Flags().BoolVar(&data.serverMode, "server", false, hd.Doc(` + Start a persistent HTTP server instead of running a one-shot evaluation. + Mutually exclusive with input files. Policies are loaded once at startup. + Note: the server has no built-in authentication or rate limiting. + Use a reverse proxy or network policy to restrict access in production.`)) + + cmd.Flags().IntVar(&data.serverPort, "server-port", data.serverPort, hd.Doc(` + Port for the HTTP server when running in server mode.`)) + if err := cmd.MarkFlagRequired("policy"); err != nil { panic(err) } diff --git a/docs/modules/ROOT/pages/ec_validate_input.adoc b/docs/modules/ROOT/pages/ec_validate_input.adoc index 873b861ed..bdc2b0510 100644 --- a/docs/modules/ROOT/pages/ec_validate_input.adoc +++ b/docs/modules/ROOT/pages/ec_validate_input.adoc @@ -69,6 +69,11 @@ mark (?) sign, for example: --output text=output.txt?show-successes=false * file (policy.yaml) * git reference (github.com/user/repo//default?ref=main), or * inline JSON ('{sources: {...}}')") +--server:: Start a persistent HTTP server instead of running a one-shot evaluation. +Mutually exclusive with input files. Policies are loaded once at startup. +Note: the server has no built-in authentication or rate limiting. +Use a reverse proxy or network policy to restrict access in production. (Default: false) +--server-port:: Port for the HTTP server when running in server mode. (Default: 8080) -s, --strict:: Return non-zero status on non-successful validation (Default: true) --workers:: Number of workers to use for validation. Defaults to 5. (Default: 5) diff --git a/internal/server/server.go b/internal/server/server.go new file mode 100644 index 000000000..b96daa6ca --- /dev/null +++ b/internal/server/server.go @@ -0,0 +1,135 @@ +// Copyright The Conforma Contributors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: Apache-2.0 + +package server + +import ( + "context" + "errors" + "fmt" + "net" + "net/http" + "sync/atomic" + "time" + + log "github.com/sirupsen/logrus" + + "github.com/conforma/cli/internal/evaluation_target/input" + "github.com/conforma/cli/internal/evaluator" + "github.com/conforma/cli/internal/policy" +) + +type Config struct { + Port int + Policy policy.Policy + Info bool + ShowSuccesses bool + ShowWarnings bool + ShowPolicyDocsLink bool +} + +type Server struct { + cfg Config + evaluators []evaluator.Evaluator + ready atomic.Bool +} + +func New(cfg Config) *Server { + return &Server{cfg: cfg} +} + +func (s *Server) Start(ctx context.Context) error { + log.Infof("Loading policy sources...") + inp, err := input.NewInput(ctx, nil, s.cfg.Policy) + if err != nil { + return fmt.Errorf("loading policy sources: %w", err) + } + s.evaluators = inp.Evaluators + s.ready.Store(true) + log.Infof("Policy sources loaded, %d evaluator(s) ready", len(s.evaluators)) + + mux := http.NewServeMux() + s.registerRoutes(mux) + + httpServer := &http.Server{ + Addr: fmt.Sprintf(":%d", s.cfg.Port), + Handler: mux, + ReadHeaderTimeout: 10 * time.Second, + ReadTimeout: 30 * time.Second, + WriteTimeout: 120 * time.Second, + IdleTimeout: 60 * time.Second, + } + + errCh := make(chan error, 1) + go func() { + ln, err := net.Listen("tcp", httpServer.Addr) + if err != nil { + errCh <- fmt.Errorf("listen on %s: %w", httpServer.Addr, err) + return + } + log.Infof("Server listening on %s", ln.Addr()) + if err := httpServer.Serve(ln); err != nil && !errors.Is(err, http.ErrServerClosed) { + errCh <- err + } + }() + + select { + case err := <-errCh: + s.destroyEvaluators() + return err + case <-ctx.Done(): + } + + log.Info("Shutting down server...") + shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + if err := httpServer.Shutdown(shutdownCtx); err != nil { + log.WithField("error", err).Warn("Server shutdown error") + } + + s.destroyEvaluators() + log.Info("Server stopped") + return nil +} + +func (s *Server) registerRoutes(mux *http.ServeMux) { + mux.HandleFunc("GET /live", s.handleLive) + mux.HandleFunc("GET /ready", s.handleReady) +} + +func (s *Server) handleLive(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + fmt.Fprint(w, `{"status":"ok"}`) +} + +func (s *Server) handleReady(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + if s.ready.Load() { + w.WriteHeader(http.StatusOK) + fmt.Fprint(w, `{"status":"ready"}`) + return + } + w.WriteHeader(http.StatusServiceUnavailable) + fmt.Fprint(w, `{"status":"not ready"}`) +} + +func (s *Server) destroyEvaluators() { + for _, e := range s.evaluators { + e.Destroy() + } +} From 61cbf269d474079322b59775952b542e4dee55da Mon Sep 17 00:00:00 2001 From: Simon Baird Date: Mon, 6 Jul 2026 14:38:22 -0400 Subject: [PATCH 03/12] Implement policy eval endpoint with error handling Add POST /v1/validate/input endpoint that accepts JSON or YAML input, evaluates it against pre-loaded policies, and returns the same JSON report structure as `--output json`. Includes Content-Type detection, 10MB body size limit, structured JSON error responses, and panic recovery middleware. Ref: https://redhat.atlassian.net/browse/EC-1884 Ref: https://redhat.atlassian.net/browse/EC-1887 Co-Authored-By: Claude Opus 4.6 --- internal/evaluator/conftest_evaluator.go | 5 +- internal/evaluator/evaluator.go | 4 + internal/server/handler.go | 177 +++++++++++++++++++++++ internal/server/server.go | 5 +- 4 files changed, 189 insertions(+), 2 deletions(-) create mode 100644 internal/server/handler.go diff --git a/internal/evaluator/conftest_evaluator.go b/internal/evaluator/conftest_evaluator.go index 84a4d48ae..26ad323be 100644 --- a/internal/evaluator/conftest_evaluator.go +++ b/internal/evaluator/conftest_evaluator.go @@ -430,7 +430,10 @@ func (c conftestEvaluator) Evaluate(ctx context.Context, target EvaluationTarget nonAnnotatedRules := nonAnnotatedRules{} // Track data source directories for prepareDataDirs dataSourceDirs := []string{} - // Download all sources + // Resolve all policy sources. Despite calling GetPolicy on every Evaluate, + // actual downloads are cached via sync.OnceValues in getPolicyThroughCache, + // (so we are not downloading policies on each request when the `ec validate + // input --server` web service is running). for _, s := range c.policySources { dir, err := s.GetPolicy(ctx, c.workDir, false) if err != nil { diff --git a/internal/evaluator/evaluator.go b/internal/evaluator/evaluator.go index ee19225c9..4e935d379 100644 --- a/internal/evaluator/evaluator.go +++ b/internal/evaluator/evaluator.go @@ -27,6 +27,10 @@ type EvaluationTarget struct { ParsedInput map[string]any } +// Evaluator implementations must be safe for concurrent use. The server (when +// using ec validate input --server) shares evaluator instances across HTTP +// requests, relying on Evaluate being read-only with respect to shared state +// after initialization. type Evaluator interface { Evaluate(ctx context.Context, target EvaluationTarget) ([]Outcome, error) diff --git a/internal/server/handler.go b/internal/server/handler.go new file mode 100644 index 000000000..75f30160b --- /dev/null +++ b/internal/server/handler.go @@ -0,0 +1,177 @@ +// Copyright The Conforma Contributors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: Apache-2.0 + +package server + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "os" + + log "github.com/sirupsen/logrus" + + "github.com/conforma/cli/internal/evaluator" + "github.com/conforma/cli/internal/input" + "github.com/conforma/cli/internal/output" + "github.com/conforma/cli/internal/utils" +) + +const maxRequestBodySize = 10 << 20 // 10 MB + +type errorResponse struct { + Error string `json:"error"` + Status int `json:"status"` +} + +func (s *Server) handleValidateInput(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + body, err := io.ReadAll(io.LimitReader(r.Body, maxRequestBodySize+1)) + if err != nil { + writeError(w, http.StatusBadRequest, "failed to read request body") + return + } + if len(body) == 0 { + writeError(w, http.StatusBadRequest, "request body is empty") + return + } + if len(body) > maxRequestBodySize { + writeError(w, http.StatusRequestEntityTooLarge, "request body exceeds 10MB limit") + return + } + + if !isValidInput(r, body) { + writeError(w, http.StatusBadRequest, "request body is not valid JSON or YAML") + return + } + + tmpFile, err := os.CreateTemp("", "ec-server-input-*"+inputExtension(r, body)) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to create temp file") + return + } + tmpPath := tmpFile.Name() + defer os.Remove(tmpPath) + + if _, err := tmpFile.Write(body); err != nil { + tmpFile.Close() + writeError(w, http.StatusInternalServerError, "failed to write temp file") + return + } + tmpFile.Close() + + var allResults []evaluator.Outcome + for _, e := range s.evaluators { + results, err := e.Evaluate(ctx, evaluator.EvaluationTarget{Inputs: []string{tmpPath}}) + if err != nil { + log.WithField("error", err).Error("Evaluation failed") + writeError(w, http.StatusInternalServerError, fmt.Sprintf("evaluation failed: %v", err)) + return + } + allResults = append(allResults, results...) + } + + out := output.Output{Detailed: s.cfg.Info} + out.SetPolicyCheck(allResults) + + inp := input.Input{ + FilePath: "input", + Success: true, + } + + inp.Violations = out.Violations() + + warnings := out.Warnings() + if s.cfg.ShowWarnings { + inp.Warnings = warnings + } + + successes := out.Successes() + inp.SuccessCount = len(successes) + if s.cfg.ShowSuccesses { + inp.Successes = successes + } + inp.Success = len(inp.Violations) == 0 + + report, err := input.NewReport( + []input.Input{inp}, + s.cfg.Policy, + [][]byte{out.PolicyInput}, + s.cfg.ShowSuccesses, + s.cfg.ShowWarnings, + s.cfg.ShowPolicyDocsLink, + ) + if err != nil { + writeError(w, http.StatusInternalServerError, fmt.Sprintf("failed to build report: %v", err)) + return + } + + data, err := json.Marshal(report) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to marshal report") + return + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write(data) +} + +func inputExtension(r *http.Request, body []byte) string { + switch r.Header.Get("Content-Type") { + case "application/json": + return ".json" + case "application/yaml", "application/x-yaml", "text/yaml": + return ".yaml" + default: + if utils.IsJson(string(body)) { + return ".json" + } + return ".yaml" + } +} + +func isValidInput(r *http.Request, body []byte) bool { + ct := r.Header.Get("Content-Type") + switch ct { + case "application/json": + return utils.IsJson(string(body)) + case "application/yaml", "application/x-yaml", "text/yaml": + return utils.IsYamlMap(string(body)) + default: + return utils.IsJson(string(body)) || utils.IsYamlMap(string(body)) + } +} + +func writeError(w http.ResponseWriter, status int, message string) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(errorResponse{Error: message, Status: status}) +} + +func recoveryMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + defer func() { + if rec := recover(); rec != nil { + log.WithField("panic", rec).Error("Recovered from panic in handler") + writeError(w, http.StatusInternalServerError, "internal server error") + } + }() + next.ServeHTTP(w, r) + }) +} diff --git a/internal/server/server.go b/internal/server/server.go index b96daa6ca..31a9ff3b5 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -64,9 +64,11 @@ func (s *Server) Start(ctx context.Context) error { mux := http.NewServeMux() s.registerRoutes(mux) + handler := recoveryMiddleware(mux) + httpServer := &http.Server{ Addr: fmt.Sprintf(":%d", s.cfg.Port), - Handler: mux, + Handler: handler, ReadHeaderTimeout: 10 * time.Second, ReadTimeout: 30 * time.Second, WriteTimeout: 120 * time.Second, @@ -109,6 +111,7 @@ func (s *Server) Start(ctx context.Context) error { func (s *Server) registerRoutes(mux *http.ServeMux) { mux.HandleFunc("GET /live", s.handleLive) mux.HandleFunc("GET /ready", s.handleReady) + mux.HandleFunc("POST /v1/validate/input", s.handleValidateInput) } func (s *Server) handleLive(w http.ResponseWriter, _ *http.Request) { From c424bd7cbf4d2e26a026d7a154b7012533a4f83b Mon Sep 17 00:00:00 2001 From: Simon Baird Date: Mon, 6 Jul 2026 14:38:56 -0400 Subject: [PATCH 04/12] Add structured logging for server mode Add request logging middleware that logs method, path, status code, latency, and remote address for every request. Switch to JSON log format in server mode for log aggregation in containerized environments. Log server lifecycle events (startup config, policy loading, shutdown). Ref: https://redhat.atlassian.net/browse/EC-1908 Co-Authored-By: Claude Opus 4.6 --- internal/server/middleware.go | 52 +++++++++++++++++++++++++++++++++++ internal/server/server.go | 21 ++++++++++++-- 2 files changed, 71 insertions(+), 2 deletions(-) create mode 100644 internal/server/middleware.go diff --git a/internal/server/middleware.go b/internal/server/middleware.go new file mode 100644 index 000000000..d4e1a7e09 --- /dev/null +++ b/internal/server/middleware.go @@ -0,0 +1,52 @@ +// Copyright The Conforma Contributors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: Apache-2.0 + +package server + +import ( + "net/http" + "time" + + log "github.com/sirupsen/logrus" +) + +type statusCapture struct { + http.ResponseWriter + status int +} + +func (sc *statusCapture) WriteHeader(code int) { + sc.status = code + sc.ResponseWriter.WriteHeader(code) +} + +func requestLoggingMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + start := time.Now() + sc := &statusCapture{ResponseWriter: w, status: http.StatusOK} + + next.ServeHTTP(sc, r) + + log.WithFields(log.Fields{ + "method": r.Method, + "path": r.URL.Path, + "status": sc.status, + "latency": time.Since(start).String(), + "size": r.ContentLength, + "remote": r.RemoteAddr, + }).Info("Request completed") + }) +} diff --git a/internal/server/server.go b/internal/server/server.go index 31a9ff3b5..ed00b25ba 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -52,7 +52,24 @@ func New(cfg Config) *Server { } func (s *Server) Start(ctx context.Context) error { - log.Infof("Loading policy sources...") + // The CLI default is "warn" but for the long-running http service we want + // to use "info" so startup, shutdown, and request details are logged. + // Don't downgrade if the user already requested a more verbose level using + // --debug or --verbose. + if log.GetLevel() < log.InfoLevel { + log.SetLevel(log.InfoLevel) + } + // Beware we're mutating the global logrus logger here, which is should be + // fine since server mode is a terminal execution path, but would need + // revisiting if the server was embedded. + log.SetFormatter(&log.JSONFormatter{}) + + log.WithFields(log.Fields{ + "port": s.cfg.Port, + "sources": len(s.cfg.Policy.Spec().Sources), + }).Info("Starting server") + + log.Info("Loading policy sources...") inp, err := input.NewInput(ctx, nil, s.cfg.Policy) if err != nil { return fmt.Errorf("loading policy sources: %w", err) @@ -64,7 +81,7 @@ func (s *Server) Start(ctx context.Context) error { mux := http.NewServeMux() s.registerRoutes(mux) - handler := recoveryMiddleware(mux) + handler := requestLoggingMiddleware(recoveryMiddleware(mux)) httpServer := &http.Server{ Addr: fmt.Sprintf(":%d", s.cfg.Port), From a62854966513f4c371910da3609f9bfd213adc47 Mon Sep 17 00:00:00 2001 From: Simon Baird Date: Mon, 6 Jul 2026 14:42:02 -0400 Subject: [PATCH 05/12] Document server mode for ec validate input Update CLI help text to describe server mode, available endpoints, request/response format, and configuration options. Add usage examples for starting the server and sending evaluation requests with curl. Ref: https://redhat.atlassian.net/browse/EC-1889 Co-Authored-By: Claude Opus 4.6 --- cmd/validate/input.go | 24 +++++++++++++++++++ .../modules/ROOT/pages/ec_validate_input.adoc | 24 +++++++++++++++++++ 2 files changed, 48 insertions(+) diff --git a/cmd/validate/input.go b/cmd/validate/input.go index ca005135f..e3b084934 100644 --- a/cmd/validate/input.go +++ b/cmd/validate/input.go @@ -73,6 +73,17 @@ func validateInputCmd(validate InputValidationFunc) *cobra.Command { Input files can be specified as positional arguments or via the --file flag (deprecated). If both are provided, the values are combined. + + When --server is provided, a persistent HTTP server is started instead of running a one-shot + evaluation. Policies are loaded once at startup. The server exposes the following endpoints: + + POST /v1/validate/input - Evaluate input (JSON or YAML body) + GET /live - Liveness probe (always 200) + GET /ready - Readiness probe (200 when policies are loaded) + + The evaluation endpoint returns the same JSON structure as --output json. HTTP 200 is returned + for all completed evaluations (the "success" field distinguishes pass/fail). Restart the server + to pick up policy changes. `), Example: hd.Doc(` Validate a single file using positional arguments @@ -99,6 +110,19 @@ func validateInputCmd(validate InputValidationFunc) *cobra.Command { ec validate input /path/to/file.yaml --policy github.com/user/repo + Start a persistent HTTP server for policy evaluation over REST: + + ec validate input --server --policy my-policy.yaml + + Start the server on a custom port: + + ec validate input --server --server-port 9090 --policy my-policy.yaml + + Send a request to the evaluation endpoint: + + curl -X POST -H 'Content-Type: application/json' --data-binary @input.json http://localhost:8080/v1/validate/input + + curl -X POST -H 'Content-Type: application/yaml' --data-binary @input.yaml http://localhost:8080/v1/validate/input `), PreRunE: func(cmd *cobra.Command, args []string) (allErrors error) { // Merge positional arguments into filePaths diff --git a/docs/modules/ROOT/pages/ec_validate_input.adoc b/docs/modules/ROOT/pages/ec_validate_input.adoc index bdc2b0510..c6b12a6cd 100644 --- a/docs/modules/ROOT/pages/ec_validate_input.adoc +++ b/docs/modules/ROOT/pages/ec_validate_input.adoc @@ -13,6 +13,17 @@ defined in the EnterpriseContractPolicy. Input files can be specified as positional arguments or via the --file flag (deprecated). If both are provided, the values are combined. +When --server is provided, a persistent HTTP server is started instead of running a one-shot +evaluation. Policies are loaded once at startup. The server exposes the following endpoints: + + POST /v1/validate/input - Evaluate input (JSON or YAML body) + GET /live - Liveness probe (always 200) + GET /ready - Readiness probe (200 when policies are loaded) + +The evaluation endpoint returns the same JSON structure as --output json. HTTP 200 is returned +for all completed evaluations (the "success" field distinguishes pass/fail). Restart the server +to pick up policy changes. + [source,shell] ---- ec validate input [file ...] [flags] @@ -43,6 +54,19 @@ of the git repo. For git repos not hosted on 'github.com' or 'gitlab.com', prefi ec validate input /path/to/file.yaml --policy github.com/user/repo +Start a persistent HTTP server for policy evaluation over REST: + + ec validate input --server --policy my-policy.yaml + +Start the server on a custom port: + + ec validate input --server --server-port 9090 --policy my-policy.yaml + +Send a request to the evaluation endpoint: + + curl -X POST -H 'Content-Type: application/json' --data-binary @input.json http://localhost:8080/v1/validate/input + + curl -X POST -H 'Content-Type: application/yaml' --data-binary @input.yaml http://localhost:8080/v1/validate/input == Options From ce38151d337e4a11e95d448b95c15a1c76c0f409 Mon Sep 17 00:00:00 2001 From: Simon Baird Date: Mon, 6 Jul 2026 14:41:23 -0400 Subject: [PATCH 06/12] Add unit tests for server mode Test health endpoints (/live, /ready), evaluation endpoint (success, violations, errors, YAML input, empty body, invalid input), recovery middleware, and server lifecycle (start, request handling, shutdown). Uses mock evaluators and stub policy for isolation. Ref: https://redhat.atlassian.net/browse/EC-1888 Co-Authored-By: Claude Opus 4.6 --- cmd/validate/input_test.go | 47 +++++ internal/server/server_test.go | 327 +++++++++++++++++++++++++++++++++ 2 files changed, 374 insertions(+) create mode 100644 internal/server/server_test.go diff --git a/cmd/validate/input_test.go b/cmd/validate/input_test.go index 982fd9186..25a610f49 100644 --- a/cmd/validate/input_test.go +++ b/cmd/validate/input_test.go @@ -25,6 +25,7 @@ import ( "errors" "sort" "testing" + "time" "github.com/spf13/afero" "github.com/spf13/cobra" @@ -268,6 +269,52 @@ func Test_ValidateInputCmd_NoPolicyProvided(t *testing.T) { assert.Contains(t, err.Error(), "required flag(s) \"policy\" not set") } +func Test_ValidateInputCmd_ServerAndFileMutuallyExclusive(t *testing.T) { + fs := afero.NewMemMapFs() + require.NoError(t, afero.WriteFile(fs, "/input.yaml", []byte("some: data"), 0o644)) + + cmd, _ := setUpValidateInputCmd(nil, fs) + cmd.SetArgs([]string{ + "input", + "--server", + "--file", "/input.yaml", + "--policy", `{"publicKey":"testkey"}`, + }) + + err := cmd.Execute() + assert.Error(t, err) + assert.Contains(t, err.Error(), "--server and input files are mutually exclusive") +} + +func Test_ValidateInputCmd_ServerMode(t *testing.T) { + fs := afero.NewMemMapFs() + + validateCmd := NewValidateCmd() + cmd := validateInputCmd(nil) + validateCmd.AddCommand(cmd) + + client := fake.FakeClient{} + // The timeout lets the server start and bind, then triggers ctx.Done() which + // srv.Start observes for graceful shutdown. No sleep/goroutine race needed. + ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond) + defer cancel() + ctx = utils.WithFS(ctx, fs) + ctx = oci.WithClient(ctx, &client) + validateCmd.SetContext(ctx) + + utils.SetTestRekorPublicKey(t) + + validateCmd.SetArgs([]string{ + "input", + "--server", + "--server-port", "0", + "--policy", `{"publicKey":"testkey"}`, + }) + + err := validateCmd.Execute() + assert.NoError(t, err) +} + func Test_ValidateInputCmd_NoFileProvided(t *testing.T) { // No SetTestRekorPublicKey call: this test verifies PreRunE returns early // before policy initialization (which would require Rekor key setup). diff --git a/internal/server/server_test.go b/internal/server/server_test.go new file mode 100644 index 000000000..0c9dbbd1a --- /dev/null +++ b/internal/server/server_test.go @@ -0,0 +1,327 @@ +// Copyright The Conforma Contributors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: Apache-2.0 + +//go:build unit + +package server + +import ( + "context" + "encoding/json" + "fmt" + "net" + "net/http" + "net/http/httptest" + "path/filepath" + "strings" + "sync" + "testing" + "time" + + ecc "github.com/conforma/crds/api/v1alpha1" + cosign "github.com/sigstore/cosign/v3/pkg/cosign" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/conforma/cli/internal/evaluator" + "github.com/conforma/cli/internal/policy" +) + +type mockEvaluator struct { + mu sync.Mutex + results []evaluator.Outcome + err error + target evaluator.EvaluationTarget +} + +func (m *mockEvaluator) Evaluate(_ context.Context, target evaluator.EvaluationTarget) ([]evaluator.Outcome, error) { + m.mu.Lock() + m.target = target + m.mu.Unlock() + return m.results, m.err +} + +func (m *mockEvaluator) Target() evaluator.EvaluationTarget { + m.mu.Lock() + defer m.mu.Unlock() + return m.target +} + +func (m *mockEvaluator) Destroy() {} + +func (m *mockEvaluator) CapabilitiesPath() string { return "" } + +type stubPolicy struct{} + +func (p *stubPolicy) PublicKeyPEM() ([]byte, error) { return nil, nil } +func (p *stubPolicy) CheckOpts() (*cosign.CheckOpts, error) { return nil, nil } +func (p *stubPolicy) WithSpec(ecc.EnterpriseContractPolicySpec) policy.Policy { return p } +func (p *stubPolicy) Spec() ecc.EnterpriseContractPolicySpec { + return ecc.EnterpriseContractPolicySpec{} +} +func (p *stubPolicy) EffectiveTime() time.Time { return time.Now() } +func (p *stubPolicy) SkipImageSigCheck() bool { return false } +func (p *stubPolicy) SkipAttSigCheck() bool { return false } +func (p *stubPolicy) AttestationTime(time.Time) {} +func (p *stubPolicy) Identity() cosign.Identity { return cosign.Identity{} } +func (p *stubPolicy) Keyless() bool { return false } +func (p *stubPolicy) SigstoreOpts() (policy.SigstoreOpts, error) { return policy.SigstoreOpts{}, nil } + +func newTestServer(evals ...evaluator.Evaluator) *Server { + s := &Server{ + cfg: Config{ + ShowWarnings: true, + Policy: &stubPolicy{}, + }, + } + s.evaluators = evals + s.ready.Store(true) + return s +} + +func TestHandleLive(t *testing.T) { + s := newTestServer() + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/live", nil) + + s.handleLive(rec, req) + + assert.Equal(t, http.StatusOK, rec.Code) + assert.JSONEq(t, `{"status":"ok"}`, rec.Body.String()) +} + +func TestHandleReady_Ready(t *testing.T) { + s := newTestServer() + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/ready", nil) + + s.handleReady(rec, req) + + assert.Equal(t, http.StatusOK, rec.Code) + assert.JSONEq(t, `{"status":"ready"}`, rec.Body.String()) +} + +func TestHandleReady_NotReady(t *testing.T) { + s := &Server{} + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/ready", nil) + + s.handleReady(rec, req) + + assert.Equal(t, http.StatusServiceUnavailable, rec.Code) + assert.JSONEq(t, `{"status":"not ready"}`, rec.Body.String()) +} + +func TestHandleValidateInput_EmptyBody(t *testing.T) { + s := newTestServer() + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/v1/validate/input", nil) + + s.handleValidateInput(rec, req) + + assert.Equal(t, http.StatusBadRequest, rec.Code) + var resp errorResponse + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp)) + assert.Equal(t, "request body is empty", resp.Error) +} + +func TestHandleValidateInput_InvalidInput(t *testing.T) { + s := newTestServer() + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/v1/validate/input", strings.NewReader("not valid json or yaml map")) + + s.handleValidateInput(rec, req) + + assert.Equal(t, http.StatusBadRequest, rec.Code) + var resp errorResponse + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp)) + assert.Equal(t, "request body is not valid JSON or YAML", resp.Error) +} + +func TestHandleValidateInput_SuccessJSON(t *testing.T) { + mock := &mockEvaluator{ + results: []evaluator.Outcome{ + { + FileName: "input", + Namespace: "test.main", + Successes: []evaluator.Result{ + {Message: "check passed", Metadata: map[string]interface{}{"code": "test.check"}}, + }, + }, + }, + } + + s := newTestServer(mock) + s.cfg.ShowSuccesses = true + + body := `{"kind": "Pipeline", "apiVersion": "v1"}` + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/v1/validate/input", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + + s.handleValidateInput(rec, req) + + assert.Equal(t, http.StatusOK, rec.Code) + assert.Equal(t, "application/json", rec.Header().Get("Content-Type")) + + require.Len(t, mock.Target().Inputs, 1) + assert.Equal(t, ".json", filepath.Ext(mock.Target().Inputs[0])) + + var report map[string]interface{} + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &report)) + assert.Equal(t, true, report["success"]) +} + +func TestHandleValidateInput_WithViolations(t *testing.T) { + mock := &mockEvaluator{ + results: []evaluator.Outcome{ + { + FileName: "input", + Namespace: "test.main", + Failures: []evaluator.Result{ + {Message: "check failed", Metadata: map[string]interface{}{"code": "test.fail"}}, + }, + }, + }, + } + + s := newTestServer(mock) + + body := `{"kind": "Pipeline"}` + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/v1/validate/input", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + + s.handleValidateInput(rec, req) + + // 200 for completed evaluations, even with violations + assert.Equal(t, http.StatusOK, rec.Code) + + var report map[string]interface{} + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &report)) + assert.Equal(t, false, report["success"]) +} + +func TestHandleValidateInput_EvaluationError(t *testing.T) { + mock := &mockEvaluator{ + err: assert.AnError, + } + + s := newTestServer(mock) + + body := `{"kind": "Pipeline"}` + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/v1/validate/input", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + + s.handleValidateInput(rec, req) + + assert.Equal(t, http.StatusInternalServerError, rec.Code) +} + +func TestHandleValidateInput_YAMLInput(t *testing.T) { + mock := &mockEvaluator{ + results: []evaluator.Outcome{ + {FileName: "input", Namespace: "test.main"}, + }, + } + + s := newTestServer(mock) + + body := "kind: Pipeline\napiVersion: v1\n" + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/v1/validate/input", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/yaml") + + s.handleValidateInput(rec, req) + + assert.Equal(t, http.StatusOK, rec.Code) + + require.Len(t, mock.Target().Inputs, 1) + assert.Equal(t, ".yaml", filepath.Ext(mock.Target().Inputs[0])) +} + +func TestRecoveryMiddleware(t *testing.T) { + panicking := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + panic("test panic") + }) + + handler := recoveryMiddleware(panicking) + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/", nil) + + handler.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusInternalServerError, rec.Code) + var resp errorResponse + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp)) + assert.Equal(t, "internal server error", resp.Error) +} + +func TestServerLifecycle(t *testing.T) { + ln, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + port := ln.Addr().(*net.TCPAddr).Port + ln.Close() + + s := &Server{ + cfg: Config{ + Port: port, + Policy: &stubPolicy{}, + }, + } + s.evaluators = []evaluator.Evaluator{&mockEvaluator{}} + s.ready.Store(true) + + ctx, cancel := context.WithCancel(context.Background()) + + errCh := make(chan error, 1) + go func() { + mux := http.NewServeMux() + s.registerRoutes(mux) + httpServer := &http.Server{ + Addr: fmt.Sprintf(":%d", port), + Handler: mux, + ReadHeaderTimeout: 10 * time.Second, + } + + go func() { + <-ctx.Done() + shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 2*time.Second) + defer shutdownCancel() + _ = httpServer.Shutdown(shutdownCtx) + }() + + errCh <- httpServer.ListenAndServe() + }() + + time.Sleep(100 * time.Millisecond) + + resp, err := http.Get(fmt.Sprintf("http://127.0.0.1:%d/live", port)) + if err == nil { + assert.Equal(t, http.StatusOK, resp.StatusCode) + resp.Body.Close() + } + + cancel() + + select { + case err := <-errCh: + assert.ErrorIs(t, err, http.ErrServerClosed) + case <-time.After(5 * time.Second): + t.Fatal("server did not shut down in time") + } +} From be1b451882175917b2c019d94d6b8bd44d0badbd Mon Sep 17 00:00:00 2001 From: Simon Baird Date: Mon, 6 Jul 2026 17:15:15 -0400 Subject: [PATCH 07/12] Add acceptance tests for server mode The way the http service is tested is to start, access, then stop the http service synchronously, rather than have a persistent http service running in a container. Ref: https://redhat.atlassian.net/browse/EC-1888 Co-Authored-By: Claude Opus 4.6 --- acceptance/acceptance_test.go | 1 + acceptance/cli/server.go | 340 ++++++++++++++++++ .../__snapshots__/validate_input_server.snap | 85 +++++ features/validate_input_server.feature | 67 ++++ 4 files changed, 493 insertions(+) create mode 100644 acceptance/cli/server.go create mode 100755 features/__snapshots__/validate_input_server.snap create mode 100644 features/validate_input_server.feature diff --git a/acceptance/acceptance_test.go b/acceptance/acceptance_test.go index 8ceb94e9b..26df3c64b 100644 --- a/acceptance/acceptance_test.go +++ b/acceptance/acceptance_test.go @@ -124,6 +124,7 @@ var tracker = &scenarioTracker{} // provided godog.ScenarioContext func initializeScenario(sc *godog.ScenarioContext) { cli.AddStepsTo(sc) + cli.AddServerStepsTo(sc) crypto.AddStepsTo(sc) git.AddStepsTo(sc) image.AddStepsTo(sc) diff --git a/acceptance/cli/server.go b/acceptance/cli/server.go new file mode 100644 index 000000000..4bea02944 --- /dev/null +++ b/acceptance/cli/server.go @@ -0,0 +1,340 @@ +// Copyright The Conforma Contributors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: Apache-2.0 + +package cli + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net" + "net/http" + "os" + "os/exec" + "path" + "runtime" + "strings" + "syscall" + "time" + + "github.com/cucumber/godog" + + log "github.com/conforma/cli/acceptance/log" + "github.com/conforma/cli/acceptance/snaps" +) + +type serverStateKey int + +const ( + serverKey serverStateKey = iota +) + +type serverState struct { + cmd *exec.Cmd + port int + stdout *bytes.Buffer + stderr *bytes.Buffer + vars map[string]string +} + +type httpResponse struct { + statusCode int + body string +} + +type responseKey int + +const ( + httpResponseKey responseKey = iota +) + +func ecServerStartedWith(ctx context.Context, parameters string) (context.Context, error) { + ec := path.Join("dist", fmt.Sprintf("ec_%s_%s", runtime.GOOS, runtime.GOARCH)) + if _, err := os.Stat(ec); err != nil { + return ctx, fmt.Errorf("%s does not exist, run a build (`make build`) first", ec) + } + + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + return ctx, fmt.Errorf("allocating free port: %w", err) + } + port := ln.Addr().(*net.TCPAddr).Port + ln.Close() + + ctx, environment, vars, err := variables(ctx) + if err != nil { + return ctx, err + } + + vars["SERVER_PORT"] = fmt.Sprintf("%d", port) + + args := os.Expand(parameters, func(key string) string { + return vars[key] + }) + + cmd := exec.Command(ec) + cmd.Args = append([]string{ec}, strings.Split(args, " ")...) + cmd.Args = append(cmd.Args, "--server-port", fmt.Sprintf("%d", port)) + cmd.Env = environment + + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + if err := cmd.Start(); err != nil { + return ctx, fmt.Errorf("starting server: %w", err) + } + + state := &serverState{ + cmd: cmd, + port: port, + stdout: &stdout, + stderr: &stderr, + vars: vars, + } + ctx = context.WithValue(ctx, serverKey, state) + + if err := waitForReady(port, 60*time.Second); err != nil { + state.stop() + return ctx, fmt.Errorf("server did not become ready: %w\nstderr: %s", err, stderr.String()) + } + + return ctx, nil +} + +func waitForReady(port int, timeout time.Duration) error { + deadline := time.Now().Add(timeout) + url := fmt.Sprintf("http://127.0.0.1:%d/ready", port) + client := &http.Client{Timeout: 2 * time.Second} + + for time.Now().Before(deadline) { + resp, err := client.Get(url) + if err == nil { + resp.Body.Close() + if resp.StatusCode == http.StatusOK { + return nil + } + } + time.Sleep(500 * time.Millisecond) + } + return fmt.Errorf("timeout after %s waiting for /ready on port %d", timeout, port) +} + +func (s *serverState) stop() { + if s.cmd == nil || s.cmd.Process == nil { + return + } + + _ = s.cmd.Process.Signal(syscall.SIGINT) + + done := make(chan error, 1) + go func() { done <- s.cmd.Wait() }() + + select { + case <-done: + case <-time.After(5 * time.Second): + _ = s.cmd.Process.Kill() + <-done + } +} + +func getServerState(ctx context.Context) (*serverState, error) { + state, ok := ctx.Value(serverKey).(*serverState) + if !ok { + return nil, errors.New("no server running, use 'ec server is started with' first") + } + return state, nil +} + +func aGETRequestIsSentToTheServer(ctx context.Context, urlPath string) (context.Context, error) { + state, err := getServerState(ctx) + if err != nil { + return ctx, err + } + + url := fmt.Sprintf("http://127.0.0.1:%d%s", state.port, urlPath) + resp, err := http.Get(url) + if err != nil { + return ctx, fmt.Errorf("GET %s: %w", urlPath, err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return ctx, fmt.Errorf("reading response: %w", err) + } + + return context.WithValue(ctx, httpResponseKey, &httpResponse{ + statusCode: resp.StatusCode, + body: string(body), + }), nil +} + +func aPOSTRequestIsSentToTheServerWithBody(ctx context.Context, urlPath string, content *godog.DocString) (context.Context, error) { + state, err := getServerState(ctx) + if err != nil { + return ctx, err + } + + body := os.Expand(content.Content, func(key string) string { + return state.vars[key] + }) + + url := fmt.Sprintf("http://127.0.0.1:%d%s", state.port, urlPath) + resp, err := http.Post(url, "application/json", strings.NewReader(body)) + if err != nil { + return ctx, fmt.Errorf("POST %s: %w", urlPath, err) + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return ctx, fmt.Errorf("reading response: %w", err) + } + + return context.WithValue(ctx, httpResponseKey, &httpResponse{ + statusCode: resp.StatusCode, + body: string(respBody), + }), nil +} + +func aPOSTRequestIsSentToTheServerWithFile(ctx context.Context, urlPath, filePath string) (context.Context, error) { + state, err := getServerState(ctx) + if err != nil { + return ctx, err + } + + expanded := os.Expand(filePath, func(key string) string { + return state.vars[key] + }) + + data, err := os.ReadFile(expanded) + if err != nil { + return ctx, fmt.Errorf("reading file %s: %w", expanded, err) + } + + url := fmt.Sprintf("http://127.0.0.1:%d%s", state.port, urlPath) + resp, err := http.Post(url, "application/yaml", strings.NewReader(string(data))) + if err != nil { + return ctx, fmt.Errorf("POST %s: %w", urlPath, err) + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return ctx, fmt.Errorf("reading response: %w", err) + } + + return context.WithValue(ctx, httpResponseKey, &httpResponse{ + statusCode: resp.StatusCode, + body: string(respBody), + }), nil +} + +func getResponse(ctx context.Context) (*httpResponse, error) { + resp, ok := ctx.Value(httpResponseKey).(*httpResponse) + if !ok { + return nil, errors.New("no HTTP response, send a request first") + } + return resp, nil +} + +func theResponseStatusShouldBe(ctx context.Context, expected int) error { + resp, err := getResponse(ctx) + if err != nil { + return err + } + + if resp.statusCode != expected { + return fmt.Errorf("expected status %d, got %d\nbody: %s", expected, resp.statusCode, resp.body) + } + return nil +} + +func theResponseShouldContain(ctx context.Context, expected string) error { + resp, err := getResponse(ctx) + if err != nil { + return err + } + + if !strings.Contains(resp.body, expected) { + return fmt.Errorf("expected response to contain %q\nbody: %s", expected, resp.body) + } + return nil +} + +func theResponseShouldMatchTheSnapshot(ctx context.Context) error { + resp, err := getResponse(ctx) + if err != nil { + return err + } + + state, err := getServerState(ctx) + if err != nil { + return err + } + + return snaps.MatchSnapshot(ctx, "response", resp.body, state.vars) +} + +func theResponseFieldShouldBe(ctx context.Context, field, expected string) error { + resp, err := getResponse(ctx) + if err != nil { + return err + } + + var data map[string]interface{} + if err := json.Unmarshal([]byte(resp.body), &data); err != nil { + return fmt.Errorf("response is not valid JSON: %w\nbody: %s", err, resp.body) + } + + actual := fmt.Sprintf("%v", data[field]) + if actual != expected { + return fmt.Errorf("expected %s=%q, got %q\nbody: %s", field, expected, actual, resp.body) + } + return nil +} + +// AddServerStepsTo registers server mode step definitions +func AddServerStepsTo(sc *godog.ScenarioContext) { + sc.Step(`^ec server is started with "(.+)"$`, ecServerStartedWith) + sc.Step(`^a GET request is sent to the server at "(.+)"$`, aGETRequestIsSentToTheServer) + sc.Step(`^a POST request is sent to the server at "(.+)" with body$`, aPOSTRequestIsSentToTheServerWithBody) + sc.Step(`^a POST request is sent to the server at "(.+)" with file "(.+)"$`, aPOSTRequestIsSentToTheServerWithFile) + sc.Step(`^the response status should be (\d+)$`, theResponseStatusShouldBe) + sc.Step(`^the response should contain "(.+)"$`, theResponseShouldContain) + sc.Step(`^the response should match the snapshot$`, theResponseShouldMatchTheSnapshot) + sc.Step(`^the response field "(.+)" should be "(.+)"$`, theResponseFieldShouldBe) + + sc.After(func(ctx context.Context, sc *godog.Scenario, scenarioErr error) (context.Context, error) { + state, ok := ctx.Value(serverKey).(*serverState) + if !ok { + return ctx, nil + } + + if scenarioErr != nil { + var logger log.Logger + logger, ctx = log.LoggerFor(ctx) + logger.Log(fmt.Errorf("server stderr: %s", state.stderr.String())) + } + + state.stop() + return ctx, nil + }) +} diff --git a/features/__snapshots__/validate_input_server.snap b/features/__snapshots__/validate_input_server.snap new file mode 100755 index 000000000..d92e80811 --- /dev/null +++ b/features/__snapshots__/validate_input_server.snap @@ -0,0 +1,85 @@ + +[TestFeatures/server mode evaluation with passing policy:response - 1] +{ + "success": true, + "filepaths": [ + { + "filepath": "input", + "violations": [], + "warnings": [], + "successes": null, + "success": true, + "success-count": 1 + } + ], + "policy": { + "sources": [ + { + "policy": [ + "git::https://${GITHOST}/git/happy-day-policy.git" + ] + } + ] + }, + "ec-version": "${EC_VERSION}", + "effective-time": "${TIMESTAMP}" +} +--- + +[TestFeatures/server mode evaluation with violations:response - 1] +{ + "success": false, + "filepaths": [ + { + "filepath": "input", + "violations": [ + { + "msg": "ham is not delicious", + "metadata": { + "code": "ham.delicious" + } + }, + { + "msg": "spam is not true", + "metadata": { + "code": "spam.valid" + } + } + ], + "warnings": [], + "successes": null, + "success": false, + "success-count": 0 + } + ], + "policy": { + "sources": [ + { + "policy": [ + "git::https://${GITHOST}/git/ham-policy" + ] + }, + { + "policy": [ + "git::https://${GITHOST}/git/spam-policy" + ] + } + ] + }, + "ec-version": "${EC_VERSION}", + "effective-time": "${TIMESTAMP}" +} +--- + +[TestFeatures/server mode health endpoints:response - 1] +{ + "status": "ok" +} +--- + +[TestFeatures/server mode invalid input:response - 1] +{ + "error": "request body is not valid JSON or YAML", + "status": 400 +} +--- diff --git a/features/validate_input_server.feature b/features/validate_input_server.feature new file mode 100644 index 000000000..1cf79e723 --- /dev/null +++ b/features/validate_input_server.feature @@ -0,0 +1,67 @@ +@server +Feature: validate input server mode + The ec command line should be able to run as a persistent HTTP server + for policy evaluation + + Background: + Given stub git daemon running + + Scenario: server mode evaluation with passing policy + Given a git repository named "happy-day-config" with + | policy.yaml | examples/happy_config.yaml | + Given a git repository named "happy-day-policy" with + | main.rego | examples/happy_day.rego | + When ec server is started with "validate input --server --policy git::https://${GITHOST}/git/happy-day-config.git" + And a POST request is sent to the server at "/v1/validate/input" with body + """ + { + "apiVersion": "tekton.dev/v1", + "kind": "Pipeline", + "metadata": {"name": "basic-build"}, + "spec": {"tasks": [{"name": "appstudio-init", "taskRef": {"name": "init", "version": "0.1"}}]} + } + """ + Then the response status should be 200 + And the response field "success" should be "true" + And the response should match the snapshot + + Scenario: server mode evaluation with violations + Given a git repository named "multiple-sources-config" with + | policy.yaml | examples/multiple_sources_config.yaml | + Given a git repository named "spam-policy" with + | main.rego | examples/spam.rego | + Given a git repository named "ham-policy" with + | main.rego | examples/ham.rego | + When ec server is started with "validate input --server --policy git::https://${GITHOST}/git/multiple-sources-config.git" + And a POST request is sent to the server at "/v1/validate/input" with body + """ + {"spam": false, "ham": "ready"} + """ + Then the response status should be 200 + And the response field "success" should be "false" + And the response should match the snapshot + + Scenario: server mode health endpoints + Given a git repository named "happy-day-config" with + | policy.yaml | examples/happy_config.yaml | + Given a git repository named "happy-day-policy" with + | main.rego | examples/happy_day.rego | + When ec server is started with "validate input --server --policy git::https://${GITHOST}/git/happy-day-config.git" + And a GET request is sent to the server at "/live" + Then the response status should be 200 + And the response should contain "ok" + And the response should match the snapshot + + Scenario: server mode invalid input + Given a git repository named "happy-day-config" with + | policy.yaml | examples/happy_config.yaml | + Given a git repository named "happy-day-policy" with + | main.rego | examples/happy_day.rego | + When ec server is started with "validate input --server --policy git::https://${GITHOST}/git/happy-day-config.git" + And a POST request is sent to the server at "/v1/validate/input" with body + """ + not valid json or yaml map + """ + Then the response status should be 400 + And the response should contain "not valid JSON or YAML" + And the response should match the snapshot From 59482cdd743042546643887fdf0de93e7dbed261 Mon Sep 17 00:00:00 2001 From: Simon Baird Date: Tue, 7 Jul 2026 11:25:20 -0400 Subject: [PATCH 08/12] Add --server-address flag, default to localhost binding The server was binding to all interfaces (0.0.0.0) by default, exposing the unauthenticated evaluation endpoint on any network interface. Default to 127.0.0.1 and let users opt in to network exposure with --server-address 0.0.0.0. (Change suggested during code review.) Ref: https://redhat.atlassian.net/browse/EC-1883 Co-Authored-By: Claude Opus 4.6 --- cmd/validate/input.go | 16 ++++++++++++---- docs/modules/ROOT/pages/ec_validate_input.adoc | 3 +++ internal/server/server.go | 4 +++- 3 files changed, 18 insertions(+), 5 deletions(-) diff --git a/cmd/validate/input.go b/cmd/validate/input.go index e3b084934..67ed74d9c 100644 --- a/cmd/validate/input.go +++ b/cmd/validate/input.go @@ -55,12 +55,14 @@ func validateInputCmd(validate InputValidationFunc) *cobra.Command { strict bool workers int serverMode bool + serverAddress string serverPort int }{ - strict: true, - workers: 5, - filterType: "include-exclude", // Default to include-exclude filter - serverPort: 8080, + strict: true, + workers: 5, + filterType: "include-exclude", // Default to include-exclude filter + serverAddress: "127.0.0.1", + serverPort: 8080, } cmd := &cobra.Command{ Use: "input [file ...] [flags]", @@ -176,6 +178,7 @@ func validateInputCmd(validate InputValidationFunc) *cobra.Command { showPolicyDocsLink, _ := cmd.Flags().GetBool("show-policy-docs-link") srv := server.New(server.Config{ + Address: data.serverAddress, Port: data.serverPort, Policy: data.policy, Info: data.info, @@ -364,6 +367,11 @@ func validateInputCmd(validate InputValidationFunc) *cobra.Command { Note: the server has no built-in authentication or rate limiting. Use a reverse proxy or network policy to restrict access in production.`)) + cmd.Flags().StringVar(&data.serverAddress, "server-address", data.serverAddress, hd.Doc(` + Address for the HTTP server to bind to when running in server mode. + Defaults to 127.0.0.1 (localhost only). Set to 0.0.0.0 to listen + on all interfaces.`)) + cmd.Flags().IntVar(&data.serverPort, "server-port", data.serverPort, hd.Doc(` Port for the HTTP server when running in server mode.`)) diff --git a/docs/modules/ROOT/pages/ec_validate_input.adoc b/docs/modules/ROOT/pages/ec_validate_input.adoc index c6b12a6cd..e5d841007 100644 --- a/docs/modules/ROOT/pages/ec_validate_input.adoc +++ b/docs/modules/ROOT/pages/ec_validate_input.adoc @@ -97,6 +97,9 @@ mark (?) sign, for example: --output text=output.txt?show-successes=false Mutually exclusive with input files. Policies are loaded once at startup. Note: the server has no built-in authentication or rate limiting. Use a reverse proxy or network policy to restrict access in production. (Default: false) +--server-address:: Address for the HTTP server to bind to when running in server mode. +Defaults to 127.0.0.1 (localhost only). Set to 0.0.0.0 to listen +on all interfaces. (Default: 127.0.0.1) --server-port:: Port for the HTTP server when running in server mode. (Default: 8080) -s, --strict:: Return non-zero status on non-successful validation (Default: true) --workers:: Number of workers to use for validation. Defaults to 5. (Default: 5) diff --git a/internal/server/server.go b/internal/server/server.go index ed00b25ba..646843e31 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -33,6 +33,7 @@ import ( ) type Config struct { + Address string Port int Policy policy.Policy Info bool @@ -65,6 +66,7 @@ func (s *Server) Start(ctx context.Context) error { log.SetFormatter(&log.JSONFormatter{}) log.WithFields(log.Fields{ + "address": s.cfg.Address, "port": s.cfg.Port, "sources": len(s.cfg.Policy.Spec().Sources), }).Info("Starting server") @@ -84,7 +86,7 @@ func (s *Server) Start(ctx context.Context) error { handler := requestLoggingMiddleware(recoveryMiddleware(mux)) httpServer := &http.Server{ - Addr: fmt.Sprintf(":%d", s.cfg.Port), + Addr: fmt.Sprintf("%s:%d", s.cfg.Address, s.cfg.Port), Handler: handler, ReadHeaderTimeout: 10 * time.Second, ReadTimeout: 30 * time.Second, From 30d74ea035539a1713a07cddf2d3060b84faec54 Mon Sep 17 00:00:00 2001 From: Simon Baird Date: Mon, 6 Jul 2026 18:17:17 -0400 Subject: [PATCH 09/12] Add evaluation timeout to server request handler A slow evaluator could hang the handler indefinitely since the request context had no deadline. Wrap the evaluation loop in a 90-second timeout and add a test that verifies the handler returns an error when exceeded. (Change suggested during code review.) Ref: https://redhat.atlassian.net/browse/EC-1883 Co-Authored-By: Claude Opus 4.6 --- internal/server/handler.go | 9 ++++++++- internal/server/server_test.go | 29 +++++++++++++++++++++++++++++ 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/internal/server/handler.go b/internal/server/handler.go index 75f30160b..e4d9f2202 100644 --- a/internal/server/handler.go +++ b/internal/server/handler.go @@ -17,11 +17,13 @@ package server import ( + "context" "encoding/json" "fmt" "io" "net/http" "os" + "time" log "github.com/sirupsen/logrus" @@ -33,6 +35,8 @@ import ( const maxRequestBodySize = 10 << 20 // 10 MB +var evaluationTimeout = 90 * time.Second + type errorResponse struct { Error string `json:"error"` Status int `json:"status"` @@ -75,9 +79,12 @@ func (s *Server) handleValidateInput(w http.ResponseWriter, r *http.Request) { } tmpFile.Close() + evalCtx, evalCancel := context.WithTimeout(ctx, evaluationTimeout) + defer evalCancel() + var allResults []evaluator.Outcome for _, e := range s.evaluators { - results, err := e.Evaluate(ctx, evaluator.EvaluationTarget{Inputs: []string{tmpPath}}) + results, err := e.Evaluate(evalCtx, evaluator.EvaluationTarget{Inputs: []string{tmpPath}}) if err != nil { log.WithField("error", err).Error("Evaluation failed") writeError(w, http.StatusInternalServerError, fmt.Sprintf("evaluation failed: %v", err)) diff --git a/internal/server/server_test.go b/internal/server/server_test.go index 0c9dbbd1a..10858c005 100644 --- a/internal/server/server_test.go +++ b/internal/server/server_test.go @@ -271,6 +271,35 @@ func TestRecoveryMiddleware(t *testing.T) { assert.Equal(t, "internal server error", resp.Error) } +type slowEvaluator struct { + mockEvaluator +} + +func (m *slowEvaluator) Evaluate(ctx context.Context, target evaluator.EvaluationTarget) ([]evaluator.Outcome, error) { + <-ctx.Done() + return nil, ctx.Err() +} + +func TestHandleValidateInput_EvaluationTimeout(t *testing.T) { + orig := evaluationTimeout + evaluationTimeout = 50 * time.Millisecond + t.Cleanup(func() { evaluationTimeout = orig }) + + s := newTestServer(&slowEvaluator{}) + + body := `{"kind": "Pipeline"}` + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/v1/validate/input", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + + s.handleValidateInput(rec, req) + + assert.Equal(t, http.StatusInternalServerError, rec.Code) + var resp errorResponse + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp)) + assert.Contains(t, resp.Error, "context deadline exceeded") +} + func TestServerLifecycle(t *testing.T) { ln, err := net.Listen("tcp", "127.0.0.1:0") require.NoError(t, err) From 9c4a20275c2033553632064069323e8754f1db78 Mon Sep 17 00:00:00 2001 From: Simon Baird Date: Mon, 6 Jul 2026 18:27:07 -0400 Subject: [PATCH 10/12] Fix gosec G107 lint errors in acceptance tests Replace http.Get/http.Post with http.NewRequestWithContext and http.DefaultClient.Do to satisfy the linter and propagate context. Ref: https://redhat.atlassian.net/browse/EC-1888 Co-Authored-By: Claude Opus 4.6 --- acceptance/cli/server.go | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/acceptance/cli/server.go b/acceptance/cli/server.go index 4bea02944..3fd62d501 100644 --- a/acceptance/cli/server.go +++ b/acceptance/cli/server.go @@ -169,7 +169,11 @@ func aGETRequestIsSentToTheServer(ctx context.Context, urlPath string) (context. } url := fmt.Sprintf("http://127.0.0.1:%d%s", state.port, urlPath) - resp, err := http.Get(url) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return ctx, fmt.Errorf("GET %s: %w", urlPath, err) + } + resp, err := http.DefaultClient.Do(req) if err != nil { return ctx, fmt.Errorf("GET %s: %w", urlPath, err) } @@ -197,7 +201,12 @@ func aPOSTRequestIsSentToTheServerWithBody(ctx context.Context, urlPath string, }) url := fmt.Sprintf("http://127.0.0.1:%d%s", state.port, urlPath) - resp, err := http.Post(url, "application/json", strings.NewReader(body)) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, strings.NewReader(body)) + if err != nil { + return ctx, fmt.Errorf("POST %s: %w", urlPath, err) + } + req.Header.Set("Content-Type", "application/json") + resp, err := http.DefaultClient.Do(req) if err != nil { return ctx, fmt.Errorf("POST %s: %w", urlPath, err) } @@ -230,7 +239,12 @@ func aPOSTRequestIsSentToTheServerWithFile(ctx context.Context, urlPath, filePat } url := fmt.Sprintf("http://127.0.0.1:%d%s", state.port, urlPath) - resp, err := http.Post(url, "application/yaml", strings.NewReader(string(data))) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, strings.NewReader(string(data))) + if err != nil { + return ctx, fmt.Errorf("POST %s: %w", urlPath, err) + } + req.Header.Set("Content-Type", "application/yaml") + resp, err := http.DefaultClient.Do(req) if err != nil { return ctx, fmt.Errorf("POST %s: %w", urlPath, err) } From 1d412a5d47aea4ffaddcef18281bd8bd5900aeb9 Mon Sep 17 00:00:00 2001 From: Simon Baird Date: Mon, 6 Jul 2026 18:36:07 -0400 Subject: [PATCH 11/12] Use signal-aware context for graceful server shutdown The root command passed context.Background() which is never cancelled, so SIGINT/SIGTERM could not propagate to the server's ctx.Done() select. Use signal.NotifyContext to tie the root context to OS signals. (Change suggested during code review.) Ref: https://redhat.atlassian.net/browse/EC-1883 Co-Authored-By: Claude Opus 4.6 --- cmd/root.go | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/cmd/root.go b/cmd/root.go index 7f174ef15..8ba312d24 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -18,6 +18,9 @@ package cmd import ( "context" + "os" + "os/signal" + "syscall" log "github.com/sirupsen/logrus" "github.com/spf13/cobra" @@ -44,7 +47,9 @@ var RootCmd = root.NewRootCmd() // Execute adds all child commands to the root command and sets flags appropriately. // This is called by main.main(). It only needs to happen once to the rootCmd. func Execute() { - if err := RootCmd.ExecuteContext(context.Background()); err != nil { + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + if err := RootCmd.ExecuteContext(ctx); err != nil { root.OnExit() log.Fatalf("error executing command: %v", err) } From fb2c2f7523d44647d125d47dd045939c1b77c21a Mon Sep 17 00:00:00 2001 From: Simon Baird Date: Mon, 6 Jul 2026 18:47:27 -0400 Subject: [PATCH 12/12] Don't leak internal error details to API clients The evaluation and report-build error paths were passing raw error text to the client via fmt.Sprintf. Return generic messages instead, with a distinct "evaluation timed out" for deadline exceeded. Full details are still logged server-side. (Change suggested during code review.) Ref: https://redhat.atlassian.net/browse/EC-1887 Co-Authored-By: Claude Opus 4.6 --- internal/server/handler.go | 11 ++++++++--- internal/server/server_test.go | 2 +- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/internal/server/handler.go b/internal/server/handler.go index e4d9f2202..340581058 100644 --- a/internal/server/handler.go +++ b/internal/server/handler.go @@ -19,7 +19,6 @@ package server import ( "context" "encoding/json" - "fmt" "io" "net/http" "os" @@ -87,7 +86,12 @@ func (s *Server) handleValidateInput(w http.ResponseWriter, r *http.Request) { results, err := e.Evaluate(evalCtx, evaluator.EvaluationTarget{Inputs: []string{tmpPath}}) if err != nil { log.WithField("error", err).Error("Evaluation failed") - writeError(w, http.StatusInternalServerError, fmt.Sprintf("evaluation failed: %v", err)) + // Avoid leaking internal details (file paths, engine errors) to API clients. + msg := "evaluation failed" + if evalCtx.Err() == context.DeadlineExceeded { + msg = "evaluation timed out" + } + writeError(w, http.StatusInternalServerError, msg) return } allResults = append(allResults, results...) @@ -124,7 +128,8 @@ func (s *Server) handleValidateInput(w http.ResponseWriter, r *http.Request) { s.cfg.ShowPolicyDocsLink, ) if err != nil { - writeError(w, http.StatusInternalServerError, fmt.Sprintf("failed to build report: %v", err)) + log.WithField("error", err).Error("Failed to build report") + writeError(w, http.StatusInternalServerError, "failed to build report") return } diff --git a/internal/server/server_test.go b/internal/server/server_test.go index 10858c005..ca43db529 100644 --- a/internal/server/server_test.go +++ b/internal/server/server_test.go @@ -297,7 +297,7 @@ func TestHandleValidateInput_EvaluationTimeout(t *testing.T) { assert.Equal(t, http.StatusInternalServerError, rec.Code) var resp errorResponse require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp)) - assert.Contains(t, resp.Error, "context deadline exceeded") + assert.Equal(t, "evaluation timed out", resp.Error) } func TestServerLifecycle(t *testing.T) {