Skip to content
Open
Show file tree
Hide file tree
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 Jul 6, 2026
091c491
Implement policy eval endpoint with error handling
simonbaird Jul 6, 2026
223b874
Add structured logging for server mode
simonbaird Jul 6, 2026
af8058d
Add unit tests for server mode
simonbaird Jul 6, 2026
e9801f8
Document server mode for ec validate input
simonbaird Jul 6, 2026
f00abb9
Add acceptance tests for server mode
simonbaird Jul 6, 2026
2db860e
Remove unused Workers field from server.Config
simonbaird Jul 6, 2026
55b428a
Add evaluation timeout to server request handler
simonbaird Jul 6, 2026
11c2941
Validate YAML body for explicit YAML content types
simonbaird Jul 6, 2026
165501d
Add read, write, and idle timeouts to HTTP server
simonbaird Jul 6, 2026
2f7aded
Fix gosec G107 lint errors in acceptance tests
simonbaird Jul 6, 2026
f51a725
Use signal-aware context for graceful server shutdown
simonbaird Jul 6, 2026
a9bc15b
Don't leak internal error details to API clients
simonbaird Jul 6, 2026
859c3bc
Add unit tests for server mode in validate input command
simonbaird Jul 7, 2026
18d308b
Add --server-address flag, default to localhost binding
simonbaird Jul 7, 2026
cb2ddc8
Clarify that GetPolicy calls in Evaluate are cached
simonbaird Jul 7, 2026
ab639b1
Document concurrency safety of shared evaluators
simonbaird Jul 7, 2026
e417241
Note that server mode mutates the global logger
simonbaird Jul 7, 2026
80637dc
Document concurrency requirement on Evaluator interface
simonbaird Jul 7, 2026
5973317
Note lack of auth/rate limiting in --server help text
simonbaird Jul 7, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions acceptance/acceptance_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
354 changes: 354 additions & 0 deletions acceptance/cli/server.go
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)
Comment thread
simonbaird marked this conversation as resolved.
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
})
}
7 changes: 6 additions & 1 deletion cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@ package cmd

import (
"context"
"os"
"os/signal"
"syscall"

log "github.com/sirupsen/logrus"
"github.com/spf13/cobra"
Expand All @@ -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)
}
Expand Down
Loading
Loading