Skip to content

Commit

Permalink
In order to improve clarity around version support for server instanc…
Browse files Browse the repository at this point in the history
…es and the CLI I've created a more in-depth version compatibility matrix
  • Loading branch information
elliotforbes authored and JulesFaucherre committed Apr 20, 2023
1 parent 7dd5d57 commit f011617
Show file tree
Hide file tree
Showing 5 changed files with 244 additions and 16 deletions.
1 change: 0 additions & 1 deletion api/rest/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,6 @@ func (c *Client) DoRequest(req *http.Request, resp interface{}) (statusCode int,
}{}
err = json.NewDecoder(httpResp.Body).Decode(&httpError)
if err != nil {
fmt.Printf("failed to decode body: %s", err.Error())
return httpResp.StatusCode, err
}
return httpResp.StatusCode, &HTTPError{Code: httpResp.StatusCode, Message: httpError.Message}
Expand Down
19 changes: 15 additions & 4 deletions config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"net/url"
"os"

"github.com/CircleCI-Public/circleci-cli/api/graphql"
"github.com/CircleCI-Public/circleci-cli/api/rest"
"github.com/CircleCI-Public/circleci-cli/settings"
"github.com/pkg/errors"
Expand All @@ -23,6 +24,9 @@ type ConfigCompiler struct {
host string
compileRestClient *rest.Client
collaboratorRestClient *rest.Client

cfg *settings.Config
legacyGraphQLClient *graphql.Client
}

func New(cfg *settings.Config) *ConfigCompiler {
Expand All @@ -31,7 +35,10 @@ func New(cfg *settings.Config) *ConfigCompiler {
host: hostValue,
compileRestClient: rest.NewFromConfig(hostValue, cfg),
collaboratorRestClient: rest.NewFromConfig(cfg.Host, cfg),
cfg: cfg,
}

configCompiler.legacyGraphQLClient = graphql.NewClient(cfg.HTTPClient, cfg.Host, cfg.Endpoint, cfg.Token, cfg.Debug)
return configCompiler
}

Expand Down Expand Up @@ -102,11 +109,15 @@ func (c *ConfigCompiler) ConfigQuery(
}

configCompilationResp := &ConfigResponse{}
statusCode, err := c.compileRestClient.DoRequest(req, configCompilationResp)
if err != nil {
if statusCode == 404 {
return nil, errors.New("this version of the CLI does not support your instance of server, please refer to https://github.com/CircleCI-Public/circleci-cli for version compatibility")
statusCode, originalErr := c.compileRestClient.DoRequest(req, configCompilationResp)
if statusCode == 404 {
legacyResponse, err := c.legacyConfigQueryByOrgID(configString, orgID, params, values, c.cfg)
if err != nil {
return nil, err
}
return legacyResponse, nil
}
if originalErr != nil {
return nil, fmt.Errorf("config compilation request returned an error: %w", err)
}

Expand Down
23 changes: 12 additions & 11 deletions config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -72,17 +72,18 @@ func TestCompiler(t *testing.T) {
assert.Contains(t, err.Error(), "Could not load config file at testdata/nonexistent.yml")
})

t.Run("handles 404 status correctly", func(t *testing.T) {
svr := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotFound)
}))
defer svr.Close()
compiler := New(&settings.Config{Host: svr.URL, HTTPClient: http.DefaultClient})

_, err := compiler.ConfigQuery("testdata/config.yml", "1234", Parameters{}, Values{})
assert.Error(t, err)
assert.Contains(t, err.Error(), "this version of the CLI does not support your instance of server")
})
// commenting this out - we have a legacy_test.go unit test that covers this behaviour
// t.Run("handles 404 status correctly", func(t *testing.T) {
// svr := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// w.WriteHeader(http.StatusNotFound)
// }))
// defer svr.Close()
// compiler := New(&settings.Config{Host: svr.URL, HTTPClient: http.DefaultClient})

// _, err := compiler.ConfigQuery("testdata/config.yml", "1234", Parameters{}, Values{})
// assert.Error(t, err)
// assert.Contains(t, err.Error(), "this version of the CLI does not support your instance of server")
// })

t.Run("handles non-200 status correctly", func(t *testing.T) {
svr := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
Expand Down
141 changes: 141 additions & 0 deletions config/legacy.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
package config

import (
"encoding/json"
"fmt"
"sort"
"strings"

"github.com/CircleCI-Public/circleci-cli/api/graphql"
"github.com/CircleCI-Public/circleci-cli/settings"
"github.com/pkg/errors"
)

// GQLErrorsCollection is a slice of errors returned by the GraphQL server.
// Each error is made up of a GQLResponseError type.
type GQLErrorsCollection []GQLResponseError

// BuildConfigResponse wraps the GQL result of the ConfigQuery
type BuildConfigResponse struct {
BuildConfig struct {
LegacyConfigResponse
}
}

// Error turns a GQLErrorsCollection into an acceptable error string that can be printed to the user.
func (errs GQLErrorsCollection) Error() string {
messages := []string{}

for i := range errs {
messages = append(messages, errs[i].Message)
}

return strings.Join(messages, "\n")
}

// LegacyConfigResponse is a structure that matches the result of the GQL
// query, so that we can use mapstructure to convert from
// nested maps to a strongly typed struct.
type LegacyConfigResponse struct {
Valid bool
SourceYaml string
OutputYaml string

Errors GQLErrorsCollection
}

// GQLResponseError is a mapping of the data returned by the GraphQL server of key-value pairs.
// Typically used with the structure "Message: string", but other response errors provide additional fields.
type GQLResponseError struct {
Message string
Value string
AllowedValues []string
EnumType string
Type string
}

// PrepareForGraphQL takes a golang homogenous map, and transforms it into a list of keyval pairs, since GraphQL does not support homogenous maps.
func PrepareForGraphQL(kvMap Values) []KeyVal {
// we need to create the slice of KeyVals in a deterministic order for testing purposes
keys := make([]string, 0, len(kvMap))
for k := range kvMap {
keys = append(keys, k)
}
sort.Strings(keys)

kvs := make([]KeyVal, 0, len(kvMap))
for _, k := range keys {
kvs = append(kvs, KeyVal{Key: k, Val: kvMap[k]})
}
return kvs
}

func (c *ConfigCompiler) legacyConfigQueryByOrgID(
configString string,
orgID string,
params Parameters,
values Values,
cfg *settings.Config,
) (*ConfigResponse, error) {
var response BuildConfigResponse
// GraphQL isn't forwards-compatible, so we are unusually selective here about
// passing only non-empty fields on to the API, to minimize user impact if the
// backend is out of date.
var fieldAddendums string
if orgID != "" {
fieldAddendums += ", orgId: $orgId"
}
if len(params) > 0 {
fieldAddendums += ", pipelineParametersJson: $pipelineParametersJson"
}
query := fmt.Sprintf(
`query ValidateConfig ($config: String!, $pipelineParametersJson: String, $pipelineValues: [StringKeyVal!], $orgSlug: String) {
buildConfig(configYaml: $config, pipelineValues: $pipelineValues%s) {
valid,
errors { message },
sourceYaml,
outputYaml
}
}`,
fieldAddendums,
)

request := graphql.NewRequest(query)
request.SetToken(cfg.Token)
request.Var("config", configString)

if values != nil {
request.Var("pipelineValues", PrepareForGraphQL(values))
}
if params != nil {
pipelineParameters, err := json.Marshal(params)
if err != nil {
return nil, fmt.Errorf("unable to serialize pipeline values: %s", err.Error())
}
request.Var("pipelineParametersJson", string(pipelineParameters))
}

if orgID != "" {
request.Var("orgId", orgID)
}

err := c.legacyGraphQLClient.Run(request, &response)
if err != nil {
return nil, errors.Wrap(err, "Unable to validate config")
}
if len(response.BuildConfig.LegacyConfigResponse.Errors) > 0 {
return nil, &response.BuildConfig.LegacyConfigResponse.Errors
}

return &ConfigResponse{
Valid: response.BuildConfig.LegacyConfigResponse.Valid,
SourceYaml: response.BuildConfig.LegacyConfigResponse.SourceYaml,
OutputYaml: response.BuildConfig.LegacyConfigResponse.OutputYaml,
}, nil
}

// KeyVal is a data structure specifically for passing pipeline data to GraphQL which doesn't support free-form maps.
type KeyVal struct {
Key string `json:"key"`
Val interface{} `json:"val"`
}
76 changes: 76 additions & 0 deletions config/legacy_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
package config

import (
"fmt"
"net/http"
"net/http/httptest"
"testing"

"github.com/CircleCI-Public/circleci-cli/settings"
"github.com/stretchr/testify/assert"
)

func TestLegacyFlow(t *testing.T) {
t.Run("tests that the compiler defaults to the graphQL resolver should the original API request fail with 404", func(t *testing.T) {
mux := http.NewServeMux()

mux.HandleFunc("/compile-config-with-defaults", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotFound)
})

mux.HandleFunc("/me/collaborations", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
fmt.Fprintf(w, `[{"vcs_type":"circleci","slug":"gh/test","id":"2345"}]`)
})

mux.HandleFunc("/graphql-unstable", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
fmt.Fprintf(w, `{"data":{"buildConfig": {"valid":true,"sourceYaml":"%s","outputYaml":"%s","errors":[]}}}`, testYaml, testYaml)
})

svr := httptest.NewServer(mux)
defer svr.Close()

compiler := New(&settings.Config{
Host: svr.URL,
Endpoint: "/graphql-unstable",
HTTPClient: http.DefaultClient,
Token: "",
})
resp, err := compiler.ConfigQuery("testdata/config.yml", "1234", Parameters{}, Values{})

assert.Equal(t, true, resp.Valid)
assert.NoError(t, err)
})

t.Run("tests that the compiler handles errors properly when returned from the graphQL endpoint", func(t *testing.T) {
mux := http.NewServeMux()

mux.HandleFunc("/compile-config-with-defaults", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotFound)
})

mux.HandleFunc("/me/collaborations", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
fmt.Fprintf(w, `[{"vcs_type":"circleci","slug":"gh/test","id":"2345"}]`)
})

mux.HandleFunc("/graphql-unstable", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
fmt.Fprintf(w, `{"data":{"buildConfig":{"errors":[{"message": "failed to validate"}]}}}`)
})

svr := httptest.NewServer(mux)
defer svr.Close()

compiler := New(&settings.Config{
Host: svr.URL,
Endpoint: "/graphql-unstable",
HTTPClient: http.DefaultClient,
Token: "",
})
_, err := compiler.ConfigQuery("testdata/config.yml", "1234", Parameters{}, Values{})
assert.Error(t, err)
assert.Contains(t, "failed to validate", err.Error())
})
}

0 comments on commit f011617

Please sign in to comment.