Skip to content

Commit 6ee20fd

Browse files
authored
Merge pull request #917 from CircleCI-Public/develop
Release
2 parents cbe4c31 + bb4b53d commit 6ee20fd

File tree

6 files changed

+280
-22
lines changed

6 files changed

+280
-22
lines changed

README.md

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -179,9 +179,3 @@ Development instructions for the CircleCI CLI can be found in [HACKING.md](HACKI
179179

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

182-
183-
## Version Compatibility
184-
185-
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.
186-
187-
`0.1.23845` is the last version to support Server 3.x and 2.x.

api/rest/client.go

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,6 @@ func (c *Client) DoRequest(req *http.Request, resp interface{}) (statusCode int,
9898
}{}
9999
err = json.NewDecoder(httpResp.Body).Decode(&httpError)
100100
if err != nil {
101-
fmt.Printf("failed to decode body: %s", err.Error())
102101
return httpResp.StatusCode, err
103102
}
104103
return httpResp.StatusCode, &HTTPError{Code: httpResp.StatusCode, Message: httpError.Message}

config/config.go

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
"net/url"
77
"os"
88

9+
"github.com/CircleCI-Public/circleci-cli/api/graphql"
910
"github.com/CircleCI-Public/circleci-cli/api/rest"
1011
"github.com/CircleCI-Public/circleci-cli/settings"
1112
"github.com/pkg/errors"
@@ -23,6 +24,9 @@ type ConfigCompiler struct {
2324
host string
2425
compileRestClient *rest.Client
2526
collaboratorRestClient *rest.Client
27+
28+
cfg *settings.Config
29+
legacyGraphQLClient *graphql.Client
2630
}
2731

2832
func New(cfg *settings.Config) *ConfigCompiler {
@@ -31,7 +35,10 @@ func New(cfg *settings.Config) *ConfigCompiler {
3135
host: hostValue,
3236
compileRestClient: rest.NewFromConfig(hostValue, cfg),
3337
collaboratorRestClient: rest.NewFromConfig(cfg.Host, cfg),
38+
cfg: cfg,
3439
}
40+
41+
configCompiler.legacyGraphQLClient = graphql.NewClient(cfg.HTTPClient, cfg.Host, cfg.Endpoint, cfg.Token, cfg.Debug)
3542
return configCompiler
3643
}
3744

@@ -102,11 +109,16 @@ func (c *ConfigCompiler) ConfigQuery(
102109
}
103110

104111
configCompilationResp := &ConfigResponse{}
105-
statusCode, err := c.compileRestClient.DoRequest(req, configCompilationResp)
106-
if err != nil {
107-
if statusCode == 404 {
108-
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")
112+
statusCode, originalErr := c.compileRestClient.DoRequest(req, configCompilationResp)
113+
if statusCode == 404 {
114+
fmt.Fprintf(os.Stderr, "You are using a old version of CircleCI Server, please consider updating")
115+
legacyResponse, err := c.legacyConfigQueryByOrgID(configString, orgID, params, values, c.cfg)
116+
if err != nil {
117+
return nil, err
109118
}
119+
return legacyResponse, nil
120+
}
121+
if originalErr != nil {
110122
return nil, fmt.Errorf("config compilation request returned an error: %w", err)
111123
}
112124

config/config_test.go

Lines changed: 12 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -72,17 +72,18 @@ func TestCompiler(t *testing.T) {
7272
assert.Contains(t, err.Error(), "Could not load config file at testdata/nonexistent.yml")
7373
})
7474

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

8788
t.Run("handles non-200 status correctly", func(t *testing.T) {
8889
svr := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {

config/legacy.go

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
package config
2+
3+
import (
4+
"encoding/json"
5+
"fmt"
6+
"sort"
7+
"strings"
8+
9+
"github.com/CircleCI-Public/circleci-cli/api/graphql"
10+
"github.com/CircleCI-Public/circleci-cli/settings"
11+
"github.com/pkg/errors"
12+
)
13+
14+
// GQLErrorsCollection is a slice of errors returned by the GraphQL server.
15+
// Each error is made up of a GQLResponseError type.
16+
type GQLErrorsCollection []GQLResponseError
17+
18+
// BuildConfigResponse wraps the GQL result of the ConfigQuery
19+
type BuildConfigResponse struct {
20+
BuildConfig struct {
21+
LegacyConfigResponse
22+
}
23+
}
24+
25+
// Error turns a GQLErrorsCollection into an acceptable error string that can be printed to the user.
26+
func (errs GQLErrorsCollection) Error() string {
27+
messages := []string{}
28+
29+
for i := range errs {
30+
messages = append(messages, errs[i].Message)
31+
}
32+
33+
return strings.Join(messages, "\n")
34+
}
35+
36+
// LegacyConfigResponse is a structure that matches the result of the GQL
37+
// query, so that we can use mapstructure to convert from
38+
// nested maps to a strongly typed struct.
39+
type LegacyConfigResponse struct {
40+
Valid bool
41+
SourceYaml string
42+
OutputYaml string
43+
44+
Errors GQLErrorsCollection
45+
}
46+
47+
// GQLResponseError is a mapping of the data returned by the GraphQL server of key-value pairs.
48+
// Typically used with the structure "Message: string", but other response errors provide additional fields.
49+
type GQLResponseError struct {
50+
Message string
51+
Value string
52+
AllowedValues []string
53+
EnumType string
54+
Type string
55+
}
56+
57+
// PrepareForGraphQL takes a golang homogenous map, and transforms it into a list of keyval pairs, since GraphQL does not support homogenous maps.
58+
func PrepareForGraphQL(kvMap Values) []KeyVal {
59+
// we need to create the slice of KeyVals in a deterministic order for testing purposes
60+
keys := make([]string, 0, len(kvMap))
61+
for k := range kvMap {
62+
keys = append(keys, k)
63+
}
64+
sort.Strings(keys)
65+
66+
kvs := make([]KeyVal, 0, len(kvMap))
67+
for _, k := range keys {
68+
kvs = append(kvs, KeyVal{Key: k, Val: kvMap[k]})
69+
}
70+
return kvs
71+
}
72+
73+
func (c *ConfigCompiler) legacyConfigQueryByOrgID(
74+
configString string,
75+
orgID string,
76+
params Parameters,
77+
values Values,
78+
cfg *settings.Config,
79+
) (*ConfigResponse, error) {
80+
var response BuildConfigResponse
81+
// GraphQL isn't forwards-compatible, so we are unusually selective here about
82+
// passing only non-empty fields on to the API, to minimize user impact if the
83+
// backend is out of date.
84+
var fieldAddendums string
85+
if orgID != "" {
86+
fieldAddendums += ", orgId: $orgId"
87+
}
88+
if len(params) > 0 {
89+
fieldAddendums += ", pipelineParametersJson: $pipelineParametersJson"
90+
}
91+
query := fmt.Sprintf(
92+
`query ValidateConfig ($config: String!, $pipelineParametersJson: String, $pipelineValues: [StringKeyVal!], $orgSlug: String) {
93+
buildConfig(configYaml: $config, pipelineValues: $pipelineValues%s) {
94+
valid,
95+
errors { message },
96+
sourceYaml,
97+
outputYaml
98+
}
99+
}`,
100+
fieldAddendums,
101+
)
102+
103+
request := graphql.NewRequest(query)
104+
request.SetToken(cfg.Token)
105+
request.Var("config", configString)
106+
107+
if values != nil {
108+
request.Var("pipelineValues", PrepareForGraphQL(values))
109+
}
110+
if params != nil {
111+
pipelineParameters, err := json.Marshal(params)
112+
if err != nil {
113+
return nil, fmt.Errorf("unable to serialize pipeline values: %s", err.Error())
114+
}
115+
request.Var("pipelineParametersJson", string(pipelineParameters))
116+
}
117+
118+
if orgID != "" {
119+
request.Var("orgId", orgID)
120+
}
121+
122+
err := c.legacyGraphQLClient.Run(request, &response)
123+
if err != nil {
124+
return nil, errors.Wrap(err, "Unable to validate config")
125+
}
126+
if len(response.BuildConfig.LegacyConfigResponse.Errors) > 0 {
127+
return nil, &response.BuildConfig.LegacyConfigResponse.Errors
128+
}
129+
130+
return &ConfigResponse{
131+
Valid: response.BuildConfig.LegacyConfigResponse.Valid,
132+
SourceYaml: response.BuildConfig.LegacyConfigResponse.SourceYaml,
133+
OutputYaml: response.BuildConfig.LegacyConfigResponse.OutputYaml,
134+
}, nil
135+
}
136+
137+
// KeyVal is a data structure specifically for passing pipeline data to GraphQL which doesn't support free-form maps.
138+
type KeyVal struct {
139+
Key string `json:"key"`
140+
Val interface{} `json:"val"`
141+
}

config/legacy_test.go

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
package config
2+
3+
import (
4+
"fmt"
5+
"net/http"
6+
"net/http/httptest"
7+
"testing"
8+
9+
"github.com/CircleCI-Public/circleci-cli/settings"
10+
"github.com/stretchr/testify/assert"
11+
)
12+
13+
func TestLegacyFlow(t *testing.T) {
14+
t.Run("tests that the compiler defaults to the graphQL resolver should the original API request fail with 404", func(t *testing.T) {
15+
mux := http.NewServeMux()
16+
17+
mux.HandleFunc("/compile-config-with-defaults", func(w http.ResponseWriter, r *http.Request) {
18+
w.WriteHeader(http.StatusNotFound)
19+
})
20+
21+
mux.HandleFunc("/me/collaborations", func(w http.ResponseWriter, r *http.Request) {
22+
w.Header().Set("Content-Type", "application/json")
23+
fmt.Fprintf(w, `[{"vcs_type":"circleci","slug":"gh/test","id":"2345"}]`)
24+
})
25+
26+
mux.HandleFunc("/graphql-unstable", func(w http.ResponseWriter, r *http.Request) {
27+
w.Header().Set("Content-Type", "application/json")
28+
fmt.Fprintf(w, `{"data":{"buildConfig": {"valid":true,"sourceYaml":"%s","outputYaml":"%s","errors":[]}}}`, testYaml, testYaml)
29+
})
30+
31+
svr := httptest.NewServer(mux)
32+
defer svr.Close()
33+
34+
compiler := New(&settings.Config{
35+
Host: svr.URL,
36+
Endpoint: "/graphql-unstable",
37+
HTTPClient: http.DefaultClient,
38+
Token: "",
39+
})
40+
resp, err := compiler.ConfigQuery("testdata/config.yml", "1234", Parameters{}, Values{})
41+
42+
assert.Equal(t, true, resp.Valid)
43+
assert.NoError(t, err)
44+
})
45+
46+
t.Run("tests that the compiler handles errors properly when returned from the graphQL endpoint", func(t *testing.T) {
47+
mux := http.NewServeMux()
48+
49+
mux.HandleFunc("/compile-config-with-defaults", func(w http.ResponseWriter, r *http.Request) {
50+
w.WriteHeader(http.StatusNotFound)
51+
})
52+
53+
mux.HandleFunc("/me/collaborations", func(w http.ResponseWriter, r *http.Request) {
54+
w.Header().Set("Content-Type", "application/json")
55+
fmt.Fprintf(w, `[{"vcs_type":"circleci","slug":"gh/test","id":"2345"}]`)
56+
})
57+
58+
mux.HandleFunc("/graphql-unstable", func(w http.ResponseWriter, r *http.Request) {
59+
w.Header().Set("Content-Type", "application/json")
60+
fmt.Fprintf(w, `{"data":{"buildConfig":{"errors":[{"message": "failed to validate"}]}}}`)
61+
})
62+
63+
svr := httptest.NewServer(mux)
64+
defer svr.Close()
65+
66+
compiler := New(&settings.Config{
67+
Host: svr.URL,
68+
Endpoint: "/graphql-unstable",
69+
HTTPClient: http.DefaultClient,
70+
Token: "",
71+
})
72+
_, err := compiler.ConfigQuery("testdata/config.yml", "1234", Parameters{}, Values{})
73+
assert.Error(t, err)
74+
assert.Contains(t, err.Error(), "failed to validate")
75+
})
76+
77+
t.Run("tests that the compiler fails out completely when a non-404 is returned from the http endpoint", func(t *testing.T) {
78+
mux := http.NewServeMux()
79+
gqlHitCounter := 0
80+
81+
mux.HandleFunc("/compile-config-with-defaults", func(w http.ResponseWriter, r *http.Request) {
82+
w.WriteHeader(http.StatusInternalServerError)
83+
84+
})
85+
86+
mux.HandleFunc("/me/collaborations", func(w http.ResponseWriter, r *http.Request) {
87+
w.Header().Set("Content-Type", "application/json")
88+
fmt.Fprintf(w, `[{"vcs_type":"circleci","slug":"gh/test","id":"2345"}]`)
89+
})
90+
91+
mux.HandleFunc("/graphql-unstable", func(w http.ResponseWriter, r *http.Request) {
92+
w.Header().Set("Content-Type", "application/json")
93+
fmt.Fprintf(w, `{"data":{"buildConfig":{"errors":[{"message": "failed to validate"}]}}}`)
94+
gqlHitCounter++
95+
})
96+
97+
svr := httptest.NewServer(mux)
98+
defer svr.Close()
99+
100+
compiler := New(&settings.Config{
101+
Host: svr.URL,
102+
Endpoint: "/graphql-unstable",
103+
HTTPClient: http.DefaultClient,
104+
Token: "",
105+
})
106+
_, err := compiler.ConfigQuery("testdata/config.yml", "1234", Parameters{}, Values{})
107+
assert.Error(t, err)
108+
assert.Contains(t, err.Error(), "config compilation request returned an error:")
109+
assert.Equal(t, 0, gqlHitCounter)
110+
})
111+
}

0 commit comments

Comments
 (0)