Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Release #917

Merged
merged 8 commits into from
Apr 20, 2023
6 changes: 0 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -179,9 +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

As of version `0.1.24705` - we no longer support Server 3.x instances. In order to upgrade the CLI to the latest version, you'll need to update your instance of server to 4.x.

`0.1.23845` is the last version to support Server 3.x and 2.x.
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)
})
}