diff --git a/cmd/kosli/assertArtifact.go b/cmd/kosli/assertArtifact.go index 343bf865d..876db5cc8 100644 --- a/cmd/kosli/assertArtifact.go +++ b/cmd/kosli/assertArtifact.go @@ -1,14 +1,13 @@ package main import ( + "context" "encoding/json" "fmt" "io" - "net/http" - "net/url" + "github.com/kosli-dev/cli/internal/kosli" "github.com/kosli-dev/cli/internal/output" - "github.com/kosli-dev/cli/internal/requests" "github.com/spf13/cobra" ) @@ -150,7 +149,7 @@ func newAssertArtifactCmd(out io.Writer) *cobra.Command { return ValidateRegistryFlags(cmd, o.fingerprintOptions) }, RunE: func(cmd *cobra.Command, args []string) error { - return o.run(out, args) + return o.run(cmd.Context(), out, args) }, } @@ -167,7 +166,7 @@ func newAssertArtifactCmd(out io.Writer) *cobra.Command { return cmd } -func (o *assertArtifactOptions) run(out io.Writer, args []string) error { +func (o *assertArtifactOptions) run(ctx context.Context, out io.Writer, args []string) error { var err error if o.fingerprint == "" { o.fingerprint, err = GetSha256Digest(args[0], o.fingerprintOptions, logger) @@ -176,42 +175,16 @@ func (o *assertArtifactOptions) run(out io.Writer, args []string) error { } } - baseURL, err := url.JoinPath(global.Host, "api/v2/asserts", global.Org, "fingerprint", o.fingerprint) + assertion, err := kosliAPI().AssertArtifact(ctx, o.fingerprint, kosli.AssertArtifactFilter{ + Flow: o.flowName, + Environment: o.envName, + Policies: o.policyNames, + }) if err != nil { return err } - params := url.Values{} - if o.flowName != "" { - params.Add("flow_name", o.flowName) - } - - if o.envName != "" { - params.Add("environment_name", o.envName) - } - - if len(o.policyNames) > 0 { - for _, policy := range o.policyNames { - params.Add("policy_name", policy) - } - } - - fullURL := baseURL - if len(params) > 0 { - fullURL += "?" + params.Encode() - } - - reqParams := &requests.RequestParams{ - Method: http.MethodGet, - URL: fullURL, - Token: global.ApiToken, - } - response, err := kosliClient.Do(reqParams) - if err != nil { - return err - } - - err = output.FormattedPrint(response.Body, o.output, out, 0, + err = output.FormattedPrint(string(assertion.Raw), o.output, out, 0, map[string]output.FormatOutputFunc{ "table": printAssertAsTable, "json": output.PrintJson, @@ -220,7 +193,7 @@ func (o *assertArtifactOptions) run(out io.Writer, args []string) error { return err } - return assertCompliant(response.Body) + return assertCompliant(string(assertion.Raw)) } // assertCompliant decodes only the compliant field, so the exit code CI gates diff --git a/cmd/kosli/getArtifact.go b/cmd/kosli/getArtifact.go index aa76795e4..af8d67ecb 100644 --- a/cmd/kosli/getArtifact.go +++ b/cmd/kosli/getArtifact.go @@ -1,16 +1,14 @@ package main import ( - "encoding/json" + "context" "fmt" "io" - "net/http" - neturl "net/url" "sort" "strings" + "github.com/kosli-dev/cli/internal/kosli" "github.com/kosli-dev/cli/internal/output" - "github.com/kosli-dev/cli/internal/requests" "github.com/spf13/cobra" ) @@ -62,33 +60,6 @@ type getArtifactOptions struct { trail string } -type artifactResponse struct { - Filename string `json:"filename"` - FlowName string `json:"flow_name"` - TrailName *string `json:"trail_name"` - TemplateReferenceName *string `json:"template_reference_name"` - Fingerprint string `json:"fingerprint"` - CreatedAt json.Number `json:"created_at"` - GitCommit string `json:"git_commit"` - CommitURL string `json:"commit_url"` - BuildURL string `json:"build_url"` - HTMLURL string `json:"html_url"` - State string `json:"state"` - Running []environmentRef `json:"running"` - Exited []environmentRef `json:"exited"` - History []artifactEvent `json:"history"` -} - -type environmentRef struct { - EnvironmentName string `json:"environment_name"` - SnapshotIndex float64 `json:"snapshot_index"` -} - -type artifactEvent struct { - Event string `json:"event"` - Timestamp json.Number `json:"timestamp"` -} - func newGetArtifactCmd(out io.Writer) *cobra.Command { o := new(getArtifactOptions) cmd := &cobra.Command{ @@ -105,7 +76,7 @@ func newGetArtifactCmd(out io.Writer) *cobra.Command { return nil }, RunE: func(cmd *cobra.Command, args []string) error { - return o.run(out, args) + return o.run(cmd.Context(), out, args) }, } @@ -115,64 +86,46 @@ func newGetArtifactCmd(out io.Writer) *cobra.Command { return cmd } -func (o *getArtifactOptions) run(out io.Writer, args []string) error { +func (o *getArtifactOptions) run(ctx context.Context, out io.Writer, args []string) error { flowName, id, separator, err := handleArtifactExpression(args[0]) if err != nil { return err } - var idType string + + var raw []byte + var artifacts func() ([]kosli.Artifact, error) if separator == "@" { - idType = "fingerprint" + artifact, err := kosliAPI().GetArtifactByFingerprint(ctx, flowName, id, o.trail) + if err != nil { + return err + } + raw = artifact.Raw + artifacts = func() ([]kosli.Artifact, error) { + value, err := artifact.Value() + return []kosli.Artifact{value}, err + } } else { - idType = "commit_sha" - } - - url, err := neturl.JoinPath(global.Host, "api/v2/artifacts", global.Org, flowName, idType, id) - if err != nil { - return err - } - if o.trail != "" { - q := neturl.Values{} - q.Set("trail", o.trail) - url += "?" + q.Encode() - } - reqParams := &requests.RequestParams{ - Method: http.MethodGet, - URL: url, - Token: global.ApiToken, - } - - response, err := kosliClient.Do(reqParams) - if err != nil { - return err + listed, err := kosliAPI().ListArtifactsByCommitSHA(ctx, flowName, id, o.trail) + if err != nil { + return err + } + raw, artifacts = listed.Raw, listed.Value } - return output.FormattedPrint(response.Body, o.output, out, 0, + return output.FormattedPrint(string(raw), o.output, out, 0, map[string]output.FormatOutputFunc{ - "table": printArtifactAsTableWrapper, - "json": output.PrintJson, + "table": func(_ string, out io.Writer, pageNumber int) error { + value, err := artifacts() + if err != nil { + return err + } + return printArtifactsAsTable(value, out, pageNumber) + }, + "json": output.PrintJson, }) } -func printArtifactAsTableWrapper(artifactRaw string, out io.Writer, pageNumber int) error { - // TODO: we have this function for backward compatibility with API. - // API returns array when querying with commit and returns single map for sha256. - // In the future, the return json should always be an array - if artifactRaw[0] != '[' { - artifactRaw = "[" + artifactRaw + "]" - } - return printArtifactsAsTable(artifactRaw, out, pageNumber) -} - -func printArtifactsAsTable(artifactRaw string, out io.Writer, pageNumber int) error { - var artifacts []artifactResponse - if err := json.Unmarshal([]byte(artifactRaw), &artifacts); err != nil { - return err - } - return printArtifactsJsonAsTable(artifacts, out, pageNumber) -} - -func printArtifactsJsonAsTable(artifacts []artifactResponse, out io.Writer, pageNumber int) error { +func printArtifactsAsTable(artifacts []kosli.Artifact, out io.Writer, pageNumber int) error { separator := "" for _, artifact := range artifacts { rows, err := artifactTableRows(artifact) @@ -186,7 +139,7 @@ func printArtifactsJsonAsTable(artifacts []artifactResponse, out io.Writer, page return nil } -func artifactTableRows(artifact artifactResponse) ([]string, error) { +func artifactTableRows(artifact kosli.Artifact) ([]string, error) { rows := []string{ fmt.Sprintf("Name:\t%s", artifact.Filename), fmt.Sprintf("Flow:\t%s", artifact.FlowName), @@ -234,7 +187,7 @@ func artifactTableRows(artifact artifactResponse) ([]string, error) { } // environmentRefNames renders each reference as name#snapshot, e.g. "prod#12". -func environmentRefNames(refs []environmentRef) []string { +func environmentRefNames(refs []kosli.EnvironmentRef) []string { names := make([]string, 0, len(refs)) for _, ref := range refs { names = append(names, fmt.Sprintf("%s#%.0f", ref.EnvironmentName, ref.SnapshotIndex)) diff --git a/cmd/kosli/getArtifact_test.go b/cmd/kosli/getArtifact_test.go index 5c01a09fd..b6cf5a4ab 100644 --- a/cmd/kosli/getArtifact_test.go +++ b/cmd/kosli/getArtifact_test.go @@ -3,8 +3,12 @@ package main import ( "bytes" "fmt" + "net/http" + "net/http/httptest" "testing" + "github.com/kosli-dev/cli/internal/kosli" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/stretchr/testify/suite" ) @@ -107,13 +111,13 @@ func TestGetArtifactCommandTestSuite(t *testing.T) { } func TestPrintArtifactsAsTableToleratesMissingCollections(t *testing.T) { - // a server response without created_at, running, exited or history must - // render N/A and omit those sections rather than panic - raw := `[{"filename":"arti","flow_name":"flow-1","fingerprint":"abc","git_commit":"0fc1ba9", - "commit_url":"https://c","build_url":"https://b","html_url":"https://h","state":"COMPLIANT"}]` + // an artifact without created_at, running, exited or history must render + // N/A and omit those sections rather than panic + artifacts := []kosli.Artifact{{Filename: "arti", FlowName: "flow-1", Fingerprint: "abc", GitCommit: "0fc1ba9", + CommitURL: "https://c", BuildURL: "https://b", HTMLURL: "https://h", State: "COMPLIANT"}} var buf bytes.Buffer require.NotPanics(t, func() { - require.NoError(t, printArtifactsAsTable(raw, &buf, 0)) + require.NoError(t, printArtifactsAsTable(artifacts, &buf, 0)) }) out := buf.String() require.Regexp(t, `Created on:\s+N/A\n`, out) @@ -122,12 +126,11 @@ func TestPrintArtifactsAsTableToleratesMissingCollections(t *testing.T) { require.NotContains(t, out, "Trail:") } -func TestPrintArtifactsAsTableAcceptsStringTimestamps(t *testing.T) { - // the artifacts API returns created_at as a numeric string, not a number - raw := `[{"filename":"arti","flow_name":"flow-1","fingerprint":"abc","created_at":"1452902400.25", - "history":[{"event":"cli reported","timestamp":"1452902400"}]}]` +func TestPrintArtifactsAsTablePrintsFractionalTimestamps(t *testing.T) { + artifacts := []kosli.Artifact{{Filename: "arti", CreatedAt: "1452902400.25", + History: []kosli.ArtifactEvent{{Event: "cli reported", Timestamp: "1452902400"}}}} var buf bytes.Buffer - require.NoError(t, printArtifactsAsTable(raw, &buf, 0)) + require.NoError(t, printArtifactsAsTable(artifacts, &buf, 0)) out := buf.String() require.NotRegexp(t, `Created on:\s+N/A`, out) require.Contains(t, out, "History:\n") @@ -137,11 +140,30 @@ func TestPrintArtifactsAsTableAcceptsStringTimestamps(t *testing.T) { func TestPrintArtifactsAsTablePrintsPresentButEmptyTrailRows(t *testing.T) { // a key the server sends prints its row even when empty; only an absent // or null key leaves the row out - raw := `[{"filename":"arti","flow_name":"flow-1","fingerprint":"abc", - "trail_name":"","template_reference_name":""}]` + empty := "" + artifacts := []kosli.Artifact{{Filename: "arti", TrailName: &empty, TemplateReferenceName: &empty}} var buf bytes.Buffer - require.NoError(t, printArtifactsAsTable(raw, &buf, 0)) + require.NoError(t, printArtifactsAsTable(artifacts, &buf, 0)) out := buf.String() require.Regexp(t, `(?m)^Trail:\s*$`, out) require.Regexp(t, `(?m)^Name in template:\s*$`, out) } + +func TestGetArtifactPrintsJSONThatTheTableCannotDecode(t *testing.T) { + // -o json prints what the server sent, so a field the typed table cannot + // decode must not fail it; only the table reports the decode error + body := `{"filename":"arti","created_at":""}` + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, err := w.Write([]byte(body)) + assert.NoError(t, err) + })) + defer server.Close() + flags := fmt.Sprintf("--host %s --org test-org --api-token test-token", server.URL) + + _, _, stdout, _, err := executeCommandC("get artifact flow-1@abc --output json " + flags) + require.NoError(t, err) + require.JSONEq(t, body, stdout) + + _, _, _, _, err = executeCommandC("get artifact flow-1@abc " + flags) + require.ErrorContains(t, err, "failed to decode the Kosli API response") +} diff --git a/internal/kosli/artifacts.go b/internal/kosli/artifacts.go new file mode 100644 index 000000000..fbdb34cc9 --- /dev/null +++ b/internal/kosli/artifacts.go @@ -0,0 +1,77 @@ +package kosli + +import ( + "context" + "encoding/json" + "net/http" + "net/url" + + "github.com/kosli-dev/cli/internal/requests" +) + +// Artifact is an Artifact as a Flow records it. +type Artifact struct { + Filename string `json:"filename"` + FlowName string `json:"flow_name"` + TrailName *string `json:"trail_name"` + TemplateReferenceName *string `json:"template_reference_name"` + Fingerprint string `json:"fingerprint"` + CreatedAt json.Number `json:"created_at"` + GitCommit string `json:"git_commit"` + CommitURL string `json:"commit_url"` + BuildURL string `json:"build_url"` + HTMLURL string `json:"html_url"` + State string `json:"state"` + Running []EnvironmentRef `json:"running"` + Exited []EnvironmentRef `json:"exited"` + History []ArtifactEvent `json:"history"` +} + +// EnvironmentRef names the Snapshot of an Environment an Artifact ran in. +type EnvironmentRef struct { + EnvironmentName string `json:"environment_name"` + SnapshotIndex float64 `json:"snapshot_index"` +} + +// ArtifactEvent is one entry in an Artifact's history. +type ArtifactEvent struct { + Event string `json:"event"` + Timestamp json.Number `json:"timestamp"` +} + +// GetArtifactByFingerprint calls get_artifact_by_fingerprint. It returns the +// Artifact with fingerprint in flow, the latest in trail when trail is not empty. +func (c *Client) GetArtifactByFingerprint(ctx context.Context, flow, fingerprint, trail string) (*Response[Artifact], error) { + endpoint, err := c.endpoint("artifacts", c.org, flow, "fingerprint", fingerprint) + if err != nil { + return nil, err + } + result, err := c.send(ctx, &requests.RequestParams{Method: http.MethodGet, URL: withQuery(endpoint, trailQuery(trail))}) + if err != nil { + return nil, err + } + return &Response[Artifact]{Result: *result}, nil +} + +// ListArtifactsByCommitSHA calls list_artifacts_by_commit_sha. It returns the +// Artifacts built from commitSHA in flow, only those in trail when trail is not +// empty. +func (c *Client) ListArtifactsByCommitSHA(ctx context.Context, flow, commitSHA, trail string) (*Response[[]Artifact], error) { + endpoint, err := c.endpoint("artifacts", c.org, flow, "commit_sha", commitSHA) + if err != nil { + return nil, err + } + result, err := c.send(ctx, &requests.RequestParams{Method: http.MethodGet, URL: withQuery(endpoint, trailQuery(trail))}) + if err != nil { + return nil, err + } + return &Response[[]Artifact]{Result: *result}, nil +} + +func trailQuery(trail string) url.Values { + query := url.Values{} + if trail != "" { + query.Set("trail", trail) + } + return query +} diff --git a/internal/kosli/artifacts_test.go b/internal/kosli/artifacts_test.go new file mode 100644 index 000000000..9197a9aa9 --- /dev/null +++ b/internal/kosli/artifacts_test.go @@ -0,0 +1,80 @@ +package kosli + +import ( + "context" + "net/http" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestGetArtifactByFingerprintRequestsTheArtifactAndDecodesIt(t *testing.T) { + server, seen := newFakeServer(t, http.StatusOK, `{"filename":"app","fingerprint":"abc"}`) + client := newTestClient(t, server.URL) + + artifact, err := client.GetArtifactByFingerprint(context.Background(), "flow-1", "abc", "") + + require.NoError(t, err) + require.Equal(t, http.MethodGet, seen.method) + require.Equal(t, "/api/v2/artifacts/test-org/flow-1/fingerprint/abc", seen.rawPath) + value, err := artifact.Value() + require.NoError(t, err) + require.Equal(t, "app", value.Filename) + require.JSONEq(t, `{"filename":"app","fingerprint":"abc"}`, string(artifact.Raw)) +} + +func TestGetArtifactByFingerprintFiltersByTrailOnlyWhenOneIsGiven(t *testing.T) { + server, seen := newFakeServer(t, http.StatusOK, `{}`) + client := newTestClient(t, server.URL) + + _, err := client.GetArtifactByFingerprint(context.Background(), "flow-1", "abc", "trail 1") + require.NoError(t, err) + require.Equal(t, "trail=trail+1", seen.rawQuery) + + _, err = client.GetArtifactByFingerprint(context.Background(), "flow-1", "abc", "") + require.NoError(t, err) + require.Empty(t, seen.rawQuery) +} + +func TestListArtifactsByCommitSHARequestsTheCommitsArtifactsAndDecodesThem(t *testing.T) { + server, seen := newFakeServer(t, http.StatusOK, `[{"filename":"a"},{"filename":"b"}]`) + client := newTestClient(t, server.URL) + + artifacts, err := client.ListArtifactsByCommitSHA(context.Background(), "flow-1", "0fc1ba9", "trail-1") + + require.NoError(t, err) + require.Equal(t, "/api/v2/artifacts/test-org/flow-1/commit_sha/0fc1ba9", seen.rawPath) + require.Equal(t, "trail=trail-1", seen.rawQuery) + value, err := artifacts.Value() + require.NoError(t, err) + require.Len(t, value, 2) + require.Equal(t, "b", value[1].Filename) +} + +func TestArtifactDecodesStringTimestamps(t *testing.T) { + // the artifacts API returns created_at as a numeric string, not a number + server, _ := newFakeServer(t, http.StatusOK, + `{"created_at":"1452902400.25","history":[{"event":"cli reported","timestamp":"1452902400"}]}`) + client := newTestClient(t, server.URL) + + artifact, err := client.GetArtifactByFingerprint(context.Background(), "flow-1", "abc", "") + + require.NoError(t, err) + value, err := artifact.Value() + require.NoError(t, err) + require.Equal(t, "1452902400.25", value.CreatedAt.String()) + require.Equal(t, "1452902400", value.History[0].Timestamp.String()) +} + +func TestArtifactTellsAPresentButEmptyTrailFromAnAbsentOne(t *testing.T) { + server, _ := newFakeServer(t, http.StatusOK, `{"trail_name":""}`) + client := newTestClient(t, server.URL) + + artifact, err := client.GetArtifactByFingerprint(context.Background(), "flow-1", "abc", "") + + require.NoError(t, err) + value, err := artifact.Value() + require.NoError(t, err) + require.NotNil(t, value.TrailName) + require.Nil(t, value.TemplateReferenceName) +} diff --git a/internal/kosli/asserts.go b/internal/kosli/asserts.go new file mode 100644 index 000000000..1914b42c8 --- /dev/null +++ b/internal/kosli/asserts.go @@ -0,0 +1,39 @@ +package kosli + +import ( + "context" + "net/http" + "net/url" + + "github.com/kosli-dev/cli/internal/requests" +) + +// AssertArtifactFilter chooses what an Artifact is asserted against. Environment +// and Policies are mutually exclusive; with neither, the Artifact is asserted +// against the templates of the Flows it is in. An empty Flow means every Flow. +type AssertArtifactFilter struct { + Flow string + Environment string + Policies []string +} + +// AssertArtifact calls assert_artifact. It returns the assertion of the Artifact +// with fingerprint undecoded, so that a caller gating on compliance decodes only +// that field and is not failed by a change in the rest of the body. +func (c *Client) AssertArtifact(ctx context.Context, fingerprint string, filter AssertArtifactFilter) (*Result, error) { + endpoint, err := c.endpoint("asserts", c.org, "fingerprint", fingerprint) + if err != nil { + return nil, err + } + query := url.Values{} + if filter.Flow != "" { + query.Set("flow_name", filter.Flow) + } + if filter.Environment != "" { + query.Set("environment_name", filter.Environment) + } + for _, policy := range filter.Policies { + query.Add("policy_name", policy) + } + return c.send(ctx, &requests.RequestParams{Method: http.MethodGet, URL: withQuery(endpoint, query)}) +} diff --git a/internal/kosli/asserts_test.go b/internal/kosli/asserts_test.go new file mode 100644 index 000000000..399f95bb4 --- /dev/null +++ b/internal/kosli/asserts_test.go @@ -0,0 +1,36 @@ +package kosli + +import ( + "context" + "net/http" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestAssertArtifactRequestsTheAssertionAndReturnsTheRawBody(t *testing.T) { + server, seen := newFakeServer(t, http.StatusOK, `{"compliant":true}`) + client := newTestClient(t, server.URL) + + result, err := client.AssertArtifact(context.Background(), "abc", AssertArtifactFilter{ + Flow: "flow-1", + Environment: "prod", + Policies: []string{"p1", "p2"}, + }) + + require.NoError(t, err) + require.Equal(t, http.MethodGet, seen.method) + require.Equal(t, "/api/v2/asserts/test-org/fingerprint/abc", seen.rawPath) + require.Equal(t, "environment_name=prod&flow_name=flow-1&policy_name=p1&policy_name=p2", seen.rawQuery) + require.JSONEq(t, `{"compliant":true}`, string(result.Raw)) +} + +func TestAssertArtifactWithoutAFilterSendsNoQuery(t *testing.T) { + server, seen := newFakeServer(t, http.StatusOK, `{"compliant":true}`) + client := newTestClient(t, server.URL) + + _, err := client.AssertArtifact(context.Background(), "abc", AssertArtifactFilter{}) + + require.NoError(t, err) + require.Empty(t, seen.rawQuery) +} diff --git a/internal/kosli/client.go b/internal/kosli/client.go index 2771afbc4..c7e8628db 100644 --- a/internal/kosli/client.go +++ b/internal/kosli/client.go @@ -64,6 +64,14 @@ func (c *Client) endpoint(segments ...string) (string, error) { return url.JoinPath(c.host, append([]string{"api/v2"}, segments...)...) } +// withQuery appends query to endpoint, leaving it bare when query is empty. +func withQuery(endpoint string, query url.Values) string { + if len(query) == 0 { + return endpoint + } + return endpoint + "?" + query.Encode() +} + func (c *Client) send(ctx context.Context, params *requests.RequestParams) (*Result, error) { params.Context = ctx params.Token = c.token diff --git a/internal/kosli/client_test.go b/internal/kosli/client_test.go index 5a32ae7b9..962c47e81 100644 --- a/internal/kosli/client_test.go +++ b/internal/kosli/client_test.go @@ -20,6 +20,7 @@ type received struct { hits int method string rawPath string + rawQuery string authHeader string } @@ -30,6 +31,7 @@ func newFakeServer(t *testing.T, status int, responseBody string) (*httptest.Ser seen.hits++ seen.method = r.Method seen.rawPath = r.URL.EscapedPath() + seen.rawQuery = r.URL.RawQuery seen.authHeader = r.Header.Get("Authorization") w.WriteHeader(status) _, err := w.Write([]byte(responseBody))