-
Notifications
You must be signed in to change notification settings - Fork 59
Add validate input --server flag for persistent HTTP/REST #3386
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
simonbaird
wants to merge
20
commits into
conforma:main
Choose a base branch
from
simonbaird:EC-1882-server-mode
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
20 commits
Select commit
Hold shift + click to select a range
62104fc
Add --server flag, and health endpoints
simonbaird 091c491
Implement policy eval endpoint with error handling
simonbaird 223b874
Add structured logging for server mode
simonbaird af8058d
Add unit tests for server mode
simonbaird e9801f8
Document server mode for ec validate input
simonbaird f00abb9
Add acceptance tests for server mode
simonbaird 2db860e
Remove unused Workers field from server.Config
simonbaird 55b428a
Add evaluation timeout to server request handler
simonbaird 11c2941
Validate YAML body for explicit YAML content types
simonbaird 165501d
Add read, write, and idle timeouts to HTTP server
simonbaird 2f7aded
Fix gosec G107 lint errors in acceptance tests
simonbaird f51a725
Use signal-aware context for graceful server shutdown
simonbaird a9bc15b
Don't leak internal error details to API clients
simonbaird 859c3bc
Add unit tests for server mode in validate input command
simonbaird 18d308b
Add --server-address flag, default to localhost binding
simonbaird cb2ddc8
Clarify that GetPolicy calls in Evaluate are cached
simonbaird ab639b1
Document concurrency safety of shared evaluators
simonbaird e417241
Note that server mode mutates the global logger
simonbaird 80637dc
Document concurrency requirement on Evaluator interface
simonbaird 5973317
Note lack of auth/rate limiting in --server help text
simonbaird File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 | ||
| }) | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.