From 049e9e2764bdacb02aa696ace84d711aee55b663 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dan=20Gr=C3=B8ndahl?= Date: Mon, 5 Oct 2026 17:32:27 +0200 Subject: [PATCH 1/5] refactor(kosli): add GetTrail --- internal/kosli/trails.go | 18 ++++++++++++++++++ internal/kosli/trails_test.go | 30 ++++++++++++++++++++++++++++++ 2 files changed, 48 insertions(+) create mode 100644 internal/kosli/trails.go create mode 100644 internal/kosli/trails_test.go diff --git a/internal/kosli/trails.go b/internal/kosli/trails.go new file mode 100644 index 000000000..26b838bb9 --- /dev/null +++ b/internal/kosli/trails.go @@ -0,0 +1,18 @@ +package kosli + +import ( + "context" + "net/http" + + "github.com/kosli-dev/cli/internal/requests" +) + +// GetTrail returns the Trail called name in flow, undecoded: its callers +// either print the body or reshape it as arbitrary JSON. +func (c *Client) GetTrail(ctx context.Context, flow, name string) (*Result, error) { + endpoint, err := c.endpoint("trails", c.org, flow, name) + if err != nil { + return nil, err + } + return c.send(ctx, &requests.RequestParams{Method: http.MethodGet, URL: endpoint}) +} diff --git a/internal/kosli/trails_test.go b/internal/kosli/trails_test.go new file mode 100644 index 000000000..20a1984b0 --- /dev/null +++ b/internal/kosli/trails_test.go @@ -0,0 +1,30 @@ +package kosli + +import ( + "context" + "net/http" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestGetTrailRequestsTheTrailInTheOrgAndReturnsTheRawBody(t *testing.T) { + server, seen := newFakeServer(t, http.StatusOK, `{"name":"trail 1","compliance_status":{}}`) + client := newTestClient(t, server.URL) + + result, err := client.GetTrail(context.Background(), "flow-1", "trail 1") + + require.NoError(t, err) + require.Equal(t, http.MethodGet, seen.method) + require.Equal(t, "/api/v2/trails/test-org/flow-1/trail%201", seen.rawPath) + require.JSONEq(t, `{"name":"trail 1","compliance_status":{}}`, string(result.Raw)) +} + +func TestGetTrailOfAMissingTrailIsNotFound(t *testing.T) { + server, _ := newFakeServer(t, http.StatusNotFound, `{"message":"Trail not found"}`) + client := newTestClient(t, server.URL) + + _, err := client.GetTrail(context.Background(), "flow-1", "missing") + + require.True(t, IsNotFound(err)) +} From c9552d914da9d33461f882bb7e62e6ed47aae08d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dan=20Gr=C3=B8ndahl?= Date: Mon, 5 Oct 2026 17:35:29 +0200 Subject: [PATCH 2/5] refactor(get trail, evaluate): fetch the trail through the Kosli API client --- cmd/kosli/evaluateHelpers.go | 17 ++++------------- cmd/kosli/evaluateTrail.go | 3 ++- cmd/kosli/evaluateTrails.go | 3 ++- cmd/kosli/getTrail.go | 19 +++++-------------- cmd/kosli/main.go | 12 ++++++++++++ 5 files changed, 25 insertions(+), 29 deletions(-) diff --git a/cmd/kosli/evaluateHelpers.go b/cmd/kosli/evaluateHelpers.go index 5bba23e2a..0fc69fc8c 100644 --- a/cmd/kosli/evaluateHelpers.go +++ b/cmd/kosli/evaluateHelpers.go @@ -56,6 +56,7 @@ const serverPolicyMaxBytes = 1 << 20 // 1 MiB var serverSideWaitOptions = evaluations.WaitOptions{} type commonEvaluateOptions struct { + ctx context.Context flowName string policyRef string output string @@ -101,24 +102,14 @@ func (o *commonEvaluateOptions) assertOnDeny() bool { return !o.noAssert } -func fetchAndEnrichTrail(flowName, trailName string, attestations []string) (any, error) { - trailURL, err := url.JoinPath(global.Host, "api/v2/trails", global.Org, flowName, trailName) - if err != nil { - return nil, err - } - - reqParams := &requests.RequestParams{ - Method: http.MethodGet, - URL: trailURL, - Token: global.ApiToken, - } - response, err := kosliClient.Do(reqParams) +func fetchAndEnrichTrail(ctx context.Context, flowName, trailName string, attestations []string) (any, error) { + trail, err := kosliAPI().GetTrail(ctx, flowName, trailName) if err != nil { return nil, err } var trailData any - err = json.Unmarshal([]byte(response.Body), &trailData) + err = json.Unmarshal(trail.Raw, &trailData) if err != nil { return nil, fmt.Errorf("failed to parse trail response: %v", err) } diff --git a/cmd/kosli/evaluateTrail.go b/cmd/kosli/evaluateTrail.go index fcaf9a66d..1ba1adc68 100644 --- a/cmd/kosli/evaluateTrail.go +++ b/cmd/kosli/evaluateTrail.go @@ -103,6 +103,7 @@ func newEvaluateTrailCmd(out io.Writer) *cobra.Command { return nil }, RunE: func(cmd *cobra.Command, args []string) error { + o.ctx = cmd.Context() return o.run(out, args) }, } @@ -124,7 +125,7 @@ func (o *evaluateTrailOptions) run(out io.Writer, args []string) error { []evaluations.TrailRef{{Flow: o.flowName, Trail: args[0]}}) } - trailData, err := fetchAndEnrichTrail(o.flowName, args[0], o.attestations) + trailData, err := fetchAndEnrichTrail(o.ctx, o.flowName, args[0], o.attestations) if err != nil { return err } diff --git a/cmd/kosli/evaluateTrails.go b/cmd/kosli/evaluateTrails.go index 11e6eb907..c8c263470 100644 --- a/cmd/kosli/evaluateTrails.go +++ b/cmd/kosli/evaluateTrails.go @@ -96,6 +96,7 @@ func newEvaluateTrailsCmd(out io.Writer) *cobra.Command { return nil }, RunE: func(cmd *cobra.Command, args []string) error { + o.ctx = cmd.Context() return o.run(out, args) }, } @@ -122,7 +123,7 @@ func (o *evaluateTrailsOptions) run(out io.Writer, args []string) error { var trails []any for _, trailName := range args { - trailData, err := fetchAndEnrichTrail(o.flowName, trailName, o.attestations) + trailData, err := fetchAndEnrichTrail(o.ctx, o.flowName, trailName, o.attestations) if err != nil { return err } diff --git a/cmd/kosli/getTrail.go b/cmd/kosli/getTrail.go index ec3b2e322..c697d476d 100644 --- a/cmd/kosli/getTrail.go +++ b/cmd/kosli/getTrail.go @@ -1,22 +1,22 @@ package main import ( + "context" "encoding/json" "fmt" "io" - "net/http" "net/url" "sort" "strings" "github.com/kosli-dev/cli/internal/output" - "github.com/kosli-dev/cli/internal/requests" "github.com/spf13/cobra" ) const getTrailDesc = `Get the metadata of a specific trail.` type getTrailOptions struct { + ctx context.Context flowName string output string } @@ -36,6 +36,7 @@ func newGetTrailCmd(out io.Writer) *cobra.Command { return nil }, RunE: func(cmd *cobra.Command, args []string) error { + o.ctx = cmd.Context() return o.run(out, args) }, } @@ -52,22 +53,12 @@ func newGetTrailCmd(out io.Writer) *cobra.Command { } func (o *getTrailOptions) run(out io.Writer, args []string) error { - url, err := url.JoinPath(global.Host, "api/v2/trails", global.Org, o.flowName, args[0]) + trail, err := kosliAPI().GetTrail(o.ctx, o.flowName, args[0]) if err != nil { return err } - reqParams := &requests.RequestParams{ - Method: http.MethodGet, - URL: url, - Token: global.ApiToken, - } - response, err := kosliClient.Do(reqParams) - if err != nil { - return err - } - - return output.FormattedPrint(response.Body, o.output, out, 0, + return output.FormattedPrint(string(trail.Raw), o.output, out, 0, map[string]output.FormatOutputFunc{ "table": printTrailAsTable, "json": output.PrintJson, diff --git a/cmd/kosli/main.go b/cmd/kosli/main.go index b4f56fb33..ca72bd255 100644 --- a/cmd/kosli/main.go +++ b/cmd/kosli/main.go @@ -7,6 +7,7 @@ import ( "strconv" "strings" + "github.com/kosli-dev/cli/internal/kosli" log "github.com/kosli-dev/cli/internal/logger" "github.com/kosli-dev/cli/internal/requests" "github.com/kosli-dev/cli/internal/version" @@ -25,6 +26,17 @@ func init() { kosliClient, _ = requests.NewKosliClient("", 3, false, logger) } +// kosliAPI returns a Kosli API client for the global flags as they are now. +// It is built on every call rather than once, so that multi-host runs and +// tests that set global directly each get a client for their own values. +func kosliAPI() *kosli.Client { + var sender kosli.Sender = kosliClient + if global.DryRun { + sender = kosli.DryRun(sender) + } + return kosli.New(sender, global.Host, global.Org, global.ApiToken) +} + func main() { var err error if isMultiHost() { From 25854d06170e037498db017c53582115b9ab78b3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dan=20Gr=C3=B8ndahl?= Date: Thu, 15 Oct 2026 14:47:44 +0200 Subject: [PATCH 3/5] refactor(get trail, evaluate): pass the context to run instead of storing it on the options --- cmd/kosli/evaluateHelpers.go | 1 - cmd/kosli/evaluateTrail.go | 8 ++++---- cmd/kosli/evaluateTrails.go | 8 ++++---- cmd/kosli/getTrail.go | 8 +++----- 4 files changed, 11 insertions(+), 14 deletions(-) diff --git a/cmd/kosli/evaluateHelpers.go b/cmd/kosli/evaluateHelpers.go index 0fc69fc8c..dfab5f178 100644 --- a/cmd/kosli/evaluateHelpers.go +++ b/cmd/kosli/evaluateHelpers.go @@ -56,7 +56,6 @@ const serverPolicyMaxBytes = 1 << 20 // 1 MiB var serverSideWaitOptions = evaluations.WaitOptions{} type commonEvaluateOptions struct { - ctx context.Context flowName string policyRef string output string diff --git a/cmd/kosli/evaluateTrail.go b/cmd/kosli/evaluateTrail.go index 1ba1adc68..0c07e250c 100644 --- a/cmd/kosli/evaluateTrail.go +++ b/cmd/kosli/evaluateTrail.go @@ -1,6 +1,7 @@ package main import ( + "context" "io" "github.com/kosli-dev/cli/internal/evaluations" @@ -103,8 +104,7 @@ func newEvaluateTrailCmd(out io.Writer) *cobra.Command { return nil }, RunE: func(cmd *cobra.Command, args []string) error { - o.ctx = cmd.Context() - return o.run(out, args) + return o.run(cmd.Context(), out, args) }, } @@ -119,13 +119,13 @@ func newEvaluateTrailCmd(out io.Writer) *cobra.Command { return cmd } -func (o *evaluateTrailOptions) run(out io.Writer, args []string) error { +func (o *evaluateTrailOptions) run(ctx context.Context, out io.Writer, args []string) error { if o.serverSide { return evaluateServerSide(out, &o.commonEvaluateOptions, []evaluations.TrailRef{{Flow: o.flowName, Trail: args[0]}}) } - trailData, err := fetchAndEnrichTrail(o.ctx, o.flowName, args[0], o.attestations) + trailData, err := fetchAndEnrichTrail(ctx, o.flowName, args[0], o.attestations) if err != nil { return err } diff --git a/cmd/kosli/evaluateTrails.go b/cmd/kosli/evaluateTrails.go index c8c263470..b83daf956 100644 --- a/cmd/kosli/evaluateTrails.go +++ b/cmd/kosli/evaluateTrails.go @@ -1,6 +1,7 @@ package main import ( + "context" "io" "github.com/kosli-dev/cli/internal/evaluations" @@ -96,8 +97,7 @@ func newEvaluateTrailsCmd(out io.Writer) *cobra.Command { return nil }, RunE: func(cmd *cobra.Command, args []string) error { - o.ctx = cmd.Context() - return o.run(out, args) + return o.run(cmd.Context(), out, args) }, } @@ -112,7 +112,7 @@ func newEvaluateTrailsCmd(out io.Writer) *cobra.Command { return cmd } -func (o *evaluateTrailsOptions) run(out io.Writer, args []string) error { +func (o *evaluateTrailsOptions) run(ctx context.Context, out io.Writer, args []string) error { if o.serverSide { refs := make([]evaluations.TrailRef, 0, len(args)) for _, trailName := range args { @@ -123,7 +123,7 @@ func (o *evaluateTrailsOptions) run(out io.Writer, args []string) error { var trails []any for _, trailName := range args { - trailData, err := fetchAndEnrichTrail(o.ctx, o.flowName, trailName, o.attestations) + trailData, err := fetchAndEnrichTrail(ctx, o.flowName, trailName, o.attestations) if err != nil { return err } diff --git a/cmd/kosli/getTrail.go b/cmd/kosli/getTrail.go index c697d476d..1f313aa5f 100644 --- a/cmd/kosli/getTrail.go +++ b/cmd/kosli/getTrail.go @@ -16,7 +16,6 @@ import ( const getTrailDesc = `Get the metadata of a specific trail.` type getTrailOptions struct { - ctx context.Context flowName string output string } @@ -36,8 +35,7 @@ func newGetTrailCmd(out io.Writer) *cobra.Command { return nil }, RunE: func(cmd *cobra.Command, args []string) error { - o.ctx = cmd.Context() - return o.run(out, args) + return o.run(cmd.Context(), out, args) }, } @@ -52,8 +50,8 @@ func newGetTrailCmd(out io.Writer) *cobra.Command { return cmd } -func (o *getTrailOptions) run(out io.Writer, args []string) error { - trail, err := kosliAPI().GetTrail(o.ctx, o.flowName, args[0]) +func (o *getTrailOptions) run(ctx context.Context, out io.Writer, args []string) error { + trail, err := kosliAPI().GetTrail(ctx, o.flowName, args[0]) if err != nil { return err } From 3bece4681303eb8ecf2f15c855be6df98920e2da Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dan=20Gr=C3=B8ndahl?= Date: Thu, 15 Oct 2026 14:55:47 +0200 Subject: [PATCH 4/5] test(kosli): pin that GetTrail still reads the trail in a dry run --- internal/kosli/trails_test.go | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/internal/kosli/trails_test.go b/internal/kosli/trails_test.go index 20a1984b0..62fdb0133 100644 --- a/internal/kosli/trails_test.go +++ b/internal/kosli/trails_test.go @@ -28,3 +28,14 @@ func TestGetTrailOfAMissingTrailIsNotFound(t *testing.T) { require.True(t, IsNotFound(err)) } + +func TestGetTrailInADryRunStillReadsTheTrail(t *testing.T) { + server, seen := newFakeServer(t, http.StatusOK, `{"name":"trail-1"}`) + client := New(DryRun(newHTTPClient(t)), server.URL, "test-org", "test-token") + + result, err := client.GetTrail(context.Background(), "flow-1", "trail-1") + + require.NoError(t, err) + require.Equal(t, 1, seen.hits) + require.JSONEq(t, `{"name":"trail-1"}`, string(result.Raw)) +} From 2d1afdcde949e17797dcd051315167c67fff9d54 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dan=20Gr=C3=B8ndahl?= Date: Thu, 15 Oct 2026 16:01:26 +0200 Subject: [PATCH 5/5] docs(kosli): name the spec operationId in each endpoint method's godoc --- internal/kosli/client.go | 3 ++- internal/kosli/trails.go | 4 ++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/internal/kosli/client.go b/internal/kosli/client.go index c51a380da..2771afbc4 100644 --- a/internal/kosli/client.go +++ b/internal/kosli/client.go @@ -1,5 +1,6 @@ // Package kosli is a client for the Kosli API, with one file per tag of the -// API's OpenAPI spec. +// API's OpenAPI spec. Each endpoint method's godoc names the operationId it +// calls, so the hand-written methods can be matched against the spec. package kosli import ( diff --git a/internal/kosli/trails.go b/internal/kosli/trails.go index 26b838bb9..60629b7ae 100644 --- a/internal/kosli/trails.go +++ b/internal/kosli/trails.go @@ -7,8 +7,8 @@ import ( "github.com/kosli-dev/cli/internal/requests" ) -// GetTrail returns the Trail called name in flow, undecoded: its callers -// either print the body or reshape it as arbitrary JSON. +// GetTrail calls get_trail. It returns the Trail called name in flow, +// undecoded: its callers either print the body or reshape it as arbitrary JSON. func (c *Client) GetTrail(ctx context.Context, flow, name string) (*Result, error) { endpoint, err := c.endpoint("trails", c.org, flow, name) if err != nil {