Skip to content

Commit

Permalink
Merge pull request #915 from CircleCI-Public/adding-fallback-config-c…
Browse files Browse the repository at this point in the history
…ompilation-and-validation-option

First attempt at adding a fallback option which queries graphQL should the http request fail with status 404
  • Loading branch information
elliotforbes authored Apr 20, 2023
2 parents 7dd5d57 + 7d09d97 commit bb4b53d
Show file tree
Hide file tree
Showing 6 changed files with 280 additions and 38 deletions.
22 changes: 0 additions & 22 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -179,25 +179,3 @@ Development instructions for the CircleCI CLI can be found in [HACKING.md](HACKI

Please see the [documentation](https://circleci-public.github.io/circleci-cli) or `circleci help` for more.


## Version Compatibility

| Server Release | CircleCI Supported Version |
| ----------- | ----------- |
| 4.2.0 | 0.1.24705 |
| 4.1.2 | 0.1.23845 |
| 4.1.1 | 0.1.23845 |
| 4.1.0 | 0.1.23845 |
| 4.0.4 | 0.1.23845 |
| 4.0.3 | 0.1.23845 |
| 4.0.2 | 0.1.23845 |
| 4.0.1 | 0.1.23845 |
| 4.0.0 | 0.1.23845 |
| 3.4.6 | 0.1.23845 |
| 3.4.5 | 0.1.23845 |
| 3.4.4 | 0.1.23845 |
| 3.4.3 | 0.1.23845 |
| 3.x | 0.1.23845 |
| 2.x | 0.1.23845 |


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
20 changes: 16 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,16 @@ 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 {
fmt.Fprintf(os.Stderr, "You are using a old version of CircleCI Server, please consider updating")
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"`
}
111 changes: 111 additions & 0 deletions config/legacy_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
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, err.Error(), "failed to validate")
})

t.Run("tests that the compiler fails out completely when a non-404 is returned from the http endpoint", func(t *testing.T) {
mux := http.NewServeMux()
gqlHitCounter := 0

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

})

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"}]}}}`)
gqlHitCounter++
})

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, err.Error(), "config compilation request returned an error:")
assert.Equal(t, 0, gqlHitCounter)
})
}

0 comments on commit bb4b53d

Please sign in to comment.