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. 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..3fd62d501 --- /dev/null +++ b/acceptance/cli/server.go @@ -0,0 +1,354 @@ +// 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) + 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) + } + 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) + 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) + } + 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) + 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) + } + 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/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) } diff --git a/cmd/validate/input.go b/cmd/validate/input.go index f6b6df7f1..67ed74d9c 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,15 @@ func validateInputCmd(validate InputValidationFunc) *cobra.Command { policyConfiguration string strict bool workers int + serverMode bool + serverAddress string + serverPort int }{ - strict: true, - workers: 5, - filterType: "include-exclude", // Default to include-exclude filter + 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]", @@ -69,6 +75,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 @@ -95,6 +112,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 @@ -116,7 +146,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 +172,23 @@ 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{ + Address: data.serverAddress, + 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 +361,20 @@ 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().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.`)) + if err := cmd.MarkFlagRequired("policy"); err != nil { panic(err) } 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/docs/modules/ROOT/pages/ec_validate_input.adoc b/docs/modules/ROOT/pages/ec_validate_input.adoc index 873b861ed..e5d841007 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 @@ -69,6 +93,14 @@ 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-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/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 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..340581058 --- /dev/null +++ b/internal/server/handler.go @@ -0,0 +1,189 @@ +// 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" + "encoding/json" + "io" + "net/http" + "os" + "time" + + 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 + +var evaluationTimeout = 90 * time.Second + +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() + + evalCtx, evalCancel := context.WithTimeout(ctx, evaluationTimeout) + defer evalCancel() + + var allResults []evaluator.Outcome + for _, e := range s.evaluators { + results, err := e.Evaluate(evalCtx, evaluator.EvaluationTarget{Inputs: []string{tmpPath}}) + if err != nil { + log.WithField("error", err).Error("Evaluation failed") + // 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...) + } + + 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 { + log.WithField("error", err).Error("Failed to build report") + writeError(w, http.StatusInternalServerError, "failed to build report") + 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/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 new file mode 100644 index 000000000..646843e31 --- /dev/null +++ b/internal/server/server.go @@ -0,0 +1,157 @@ +// 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 { + Address string + 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 { + // 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{ + "address": s.cfg.Address, + "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) + } + 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) + + handler := requestLoggingMiddleware(recoveryMiddleware(mux)) + + httpServer := &http.Server{ + Addr: fmt.Sprintf("%s:%d", s.cfg.Address, s.cfg.Port), + Handler: handler, + 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) + mux.HandleFunc("POST /v1/validate/input", s.handleValidateInput) +} + +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() + } +} diff --git a/internal/server/server_test.go b/internal/server/server_test.go new file mode 100644 index 000000000..ca43db529 --- /dev/null +++ b/internal/server/server_test.go @@ -0,0 +1,356 @@ +// 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) +} + +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.Equal(t, "evaluation timed out", 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") + } +}