Add "attributes" adapter for Apigee. This sets attributes from the API key. A list checker can then be used to reject invalid keys. Attributes are also now pushed to analytics. Fix CI test for goimports.
diff --git a/adapter/BUILD b/adapter/BUILD index 66aabee..e6120d7 100644 --- a/adapter/BUILD +++ b/adapter/BUILD
@@ -6,8 +6,10 @@ name = "go_default_library", srcs = [ "apigee.go", + "apigeeKeyAttributes.go", "apigeeKeyChecker.go", "apigeeReport.go", + "applications.go", ], deps = [ "//adapter/config:go_default_library", @@ -21,9 +23,10 @@ name = "tests", size = "medium", srcs = [ - "apigeeKeyChecker_test.go", - "apigeeReport_test.go", - ], + "apigeeKeyAttributes_test.go", + "apigeeKeyChecker_test.go", + "apigeeReport_test.go", + ], library = ":go_default_library", deps = [ "//mock:go_default_library",
diff --git a/adapter/apigee.go b/adapter/apigee.go index 6fe3853..a1f7b75 100644 --- a/adapter/apigee.go +++ b/adapter/apigee.go
@@ -27,4 +27,5 @@ r.RegisterListsBuilder(newKeyCheckBuilder()) //r.RegisterApplicationLogsBuilder(newReportBuilder()) r.RegisterAccessLogsBuilder(newReportBuilder()) + r.RegisterAttributesGeneratorBuilder(newKeyAttrsBuilder()) }
diff --git a/adapter/apigeeKeyAttributes.go b/adapter/apigeeKeyAttributes.go new file mode 100644 index 0000000..448bb93 --- /dev/null +++ b/adapter/apigeeKeyAttributes.go
@@ -0,0 +1,98 @@ +/* +Copyright 2017 Google Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package adapter + +import ( + "fmt" + + "github.com/apid/istioApigeeAdapter/adapter/config" + "istio.io/mixer/pkg/adapter" +) + +const ( + keyAttrsName = "apigeeKeyAttributes" + keyAttrsDesc = "Set attributes based on an Apigee API key" + + keyParam = "apiKey" + pathParam = "requestPath" + successParam = "success" + successStringParam = "successString" + clientIDParam = "clientID" + appNameParam = "applicationName" +) + +var keyAttrsConf = &config.VerifyKeyParams{} + +type keyAttrsBuilder struct { + adapter.DefaultBuilder +} + +type keyAttrsGenerator struct { + env adapter.Env + applications *applicationManager +} + +func newKeyAttrsBuilder() adapter.AttributesGeneratorBuilder { + return keyAttrsBuilder{ + adapter.NewDefaultBuilder(keyAttrsName, keyAttrsDesc, keyAttrsConf), + } +} + +func (b keyAttrsBuilder) ValidateConfig(c adapter.Config) *adapter.ConfigErrors { + return validateKeyParams(c) +} + +func (b keyAttrsBuilder) BuildAttributesGenerator(env adapter.Env, c adapter.Config) (adapter.AttributesGenerator, error) { + cfg := c.(*config.VerifyKeyParams) + g := &keyAttrsGenerator{ + env: env, + applications: newApplicationManager(env, defaultAppLifetime, getVerifyPath(cfg)), + } + env.Logger().Infof("Created Apigee attributes generator to invoke \"%s\"", g.applications.checkURL) + + return g, nil +} + +func (g *keyAttrsGenerator) Generate(in map[string]interface{}) (map[string]interface{}, error) { + key := getString(in, keyParam) + if key == "" { + return nil, fmt.Errorf("Cannot verify API key: value of \"%s\" not found", keyParam) + } + path := getString(in, pathParam) + if path == "" { + return nil, fmt.Errorf("Cannot verify API key: value of \"%s\" not found", pathParam) + } + + app, err := g.applications.get(key) + if err != nil { + g.env.Logger().Errorf("Error verifying API key: %s", err) + return nil, err + } + + out := make(map[string]interface{}) + out[successParam] = app.valid + out[successStringParam] = fmt.Sprintf("%v", app.valid) + if app.valid { + out[clientIDParam] = app.clientID + out[appNameParam] = app.name + } + return out, nil +} + +func (g *keyAttrsGenerator) Close() error { + return nil +}
diff --git a/adapter/apigeeKeyAttributes_test.go b/adapter/apigeeKeyAttributes_test.go new file mode 100644 index 0000000..0176917 --- /dev/null +++ b/adapter/apigeeKeyAttributes_test.go
@@ -0,0 +1,100 @@ +/* +Copyright 2017 Google Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package adapter + +import ( + "testing" + + "github.com/apid/istioApigeeAdapter/adapter/config" + "github.com/apid/istioApigeeAdapter/mock" +) + +func TestInvalidKeyAttributes(t *testing.T) { + cfg := &config.VerifyKeyParams{ + Organization: "foo", + Environment: "test", + VerificationURL: "http://" + mockServer.Address(), + } + + builder := newKeyAttrsBuilder() + ce := builder.ValidateConfig(cfg) + if ce != nil { + t.Fatalf("Error validating config: %s", ce) + } + + ag, err := builder.BuildAttributesGenerator(mockEnv, cfg) + if err != nil { + t.Fatalf("Error creating aspect: %s", err) + } + defer ag.Close() + + in := make(map[string]interface{}) + in[keyParam] = "99999" + in[pathParam] = "/" + + out, err := ag.Generate(in) + if err != nil { + t.Fatalf("Error checking parameters: %s", err) + } + if out[successParam] != false { + t.Fatalf("Expected success to be false") + } + if out[successStringParam] != "false" { + t.Fatalf("Expected success string to be false") + } +} + +func TestValidKeyAttributes(t *testing.T) { + cfg := &config.VerifyKeyParams{ + Organization: "foo", + Environment: "test", + VerificationURL: "http://" + mockServer.Address(), + } + + builder := newKeyAttrsBuilder() + ce := builder.ValidateConfig(cfg) + if ce != nil { + t.Fatalf("Error validating config: %s", ce) + } + + ag, err := builder.BuildAttributesGenerator(mockEnv, cfg) + if err != nil { + t.Fatalf("Error creating aspect: %s", err) + } + defer ag.Close() + + in := make(map[string]interface{}) + in[keyParam] = mock.ValidAPIKey1 + in[pathParam] = "/" + + out, err := ag.Generate(in) + if err != nil { + t.Fatalf("Error checking parameters: %s", err) + } + if out[successParam] != true { + t.Fatalf("Expected success to be true") + } + if out[successStringParam] != "true" { + t.Fatalf("Expected success string to be true") + } + if out[clientIDParam] != mock.ValidAPIKey1 { + t.Fatalf("Invalid client ID %s", out[clientIDParam]) + } + if out[appNameParam] != "TestApp1" { + t.Fatalf("Invalid application name %s", out[appNameParam]) + } +}
diff --git a/adapter/apigeeKeyChecker.go b/adapter/apigeeKeyChecker.go index 38e3dff..50f61ac 100644 --- a/adapter/apigeeKeyChecker.go +++ b/adapter/apigeeKeyChecker.go
@@ -17,14 +17,10 @@ package adapter import ( - "bytes" - "encoding/json" "fmt" - "net/http" "net/url" "github.com/apid/istioApigeeAdapter/adapter/config" - "github.com/apid/istioApigeeAdapter/common" "istio.io/mixer/pkg/adapter" ) @@ -42,8 +38,8 @@ } type keyChecker struct { - env adapter.Env - checkURL string + env adapter.Env + application *applicationManager } func newKeyCheckBuilder() adapter.ListsBuilder { @@ -52,7 +48,11 @@ } } -func (b keyCheckBuilder) ValidateConfig(c adapter.Config) (ce *adapter.ConfigErrors) { +func (b keyCheckBuilder) ValidateConfig(c adapter.Config) *adapter.ConfigErrors { + return validateKeyParams(c) +} + +func validateKeyParams(c adapter.Config) (ce *adapter.ConfigErrors) { cfg := c.(*config.VerifyKeyParams) if cfg.Organization == "" { ce = ce.Appendf("organization", "Organization parameter must be specified") @@ -71,6 +71,16 @@ func (b keyCheckBuilder) NewListsAspect(env adapter.Env, c adapter.Config) (adapter.ListsAspect, error) { cfg := c.(*config.VerifyKeyParams) + kc := &keyChecker{ + env: env, + application: newApplicationManager(env, defaultAppLifetime, getVerifyPath(cfg)), + } + env.Logger().Infof("Created Apigee Key Checker to invoke \"%s\"", kc.application.checkURL) + + return kc, nil +} + +func getVerifyPath(cfg *config.VerifyKeyParams) string { var basePath string if cfg.VerificationURL == "" { @@ -78,14 +88,7 @@ } else { basePath = cfg.VerificationURL } - - kc := &keyChecker{ - env: env, - checkURL: basePath + verifyKeyPath, - } - env.Logger().Infof("Created Apigee Key Checker to invoke \"%s\"", kc.checkURL) - - return kc, nil + return basePath + verifyKeyPath } func (l *keyChecker) Close() error { @@ -98,22 +101,10 @@ // Test command ./bazel-bin/cmd/client/mixc check -a target.service=f.default.svc.cluster.local --stringmap_attributes request.headers=x-api-key:1tu9pl04Srua2MtsAGtu6ViPxSYSSX2I func (l *keyChecker) CheckList(symbol string) (bool, error) { - apiKeyBody := common.VerifyAPIKeyRequest{ - Key: symbol, - } - requestBody, _ := json.Marshal(&apiKeyBody) - - resp, err := http.DefaultClient.Post(l.checkURL, "application/json", - bytes.NewBuffer(requestBody)) + app, err := l.application.get(symbol) if err != nil { - l.env.Logger().Errorf("Error contacting verification service: %s", err) + l.env.Logger().Errorf("Error verifying API key: %s", err) return false, err } - defer resp.Body.Close() - - if resp.StatusCode != 200 { - return false, nil - } else { - return true, nil - } + return app.valid, nil }
diff --git a/adapter/apigeeReport.go b/adapter/apigeeReport.go index fc2ae2f..16cfefa 100644 --- a/adapter/apigeeReport.go +++ b/adapter/apigeeReport.go
@@ -36,6 +36,8 @@ const ( regionLookupURL = "https://edgemicroservices.apigee.net/edgemicro" nanosPerMillisecond = 1000000 + reportName = "apigeeReport" + reportDesc = "Report API call data to Apigee" ) type ( @@ -58,8 +60,8 @@ func newReportBuilder() analyticsBuilder { return analyticsBuilder{ adapter.NewDefaultBuilder( - "apigeeReport", - "Report logs to apigee", + reportName, + reportDesc, &config.ReportParams{}, )} } @@ -121,17 +123,11 @@ var records []common.AnalyticsRecord for _, entry := range entries { - sourceIP := getStringLabel(entry, "sourceIP") path := getStringLabel(entry, "urlPath") host := getStringLabel(entry, "hostHeader") - method := getStringLabel(entry, "httpMethod") - userAgent := getStringLabel(entry, "userAgent") - proxyName := getStringLabel(entry, "proxyName") - proxyRevision := getIntLabel(entry, "proxyRevision") // Convert nanos (Go) to millis (Java) requestTime := getTimestampLabel(entry, "requestTime") / nanosPerMillisecond responseTime := getTimestampLabel(entry, "responseTime") / nanosPerMillisecond - responseCode := getIntLabel(entry, "responseCode") r := common.AnalyticsRecord{ ClientReceivedStartTimestamp: requestTime, @@ -140,16 +136,18 @@ ClientSentStartTimestamp: responseTime, ClientSentEndTimestamp: responseTime + 1, // Missing: Target times - ClientIP: sourceIP, - RequestVerb: method, - UserAgent: userAgent, - ResponseStatusCode: responseCode, + ClientIP: getStringLabel(entry, "sourceIP"), + RequestVerb: getStringLabel(entry, "httpMethod"), + UserAgent: getStringLabel(entry, "userAgent"), + ResponseStatusCode: getIntLabel(entry, "responseCode"), // Technically wrong because of no scheme and host header RequestURI: "http://" + host + path, RequestPath: strings.Split(path, "?")[0], - APIProxy: proxyName, - APIProxyRevision: proxyRevision, + APIProxy: getStringLabel(entry, "proxyName"), + APIProxyRevision: getIntLabel(entry, "proxyRevision"), RecordType: "APIAnalytics", + DeveloperApp: getStringLabel(entry, "applicationName"), + ClientID: getStringLabel(entry, "clientID"), } records = append(records, r) @@ -266,7 +264,11 @@ } func getStringLabel(le adapter.LogEntry, key string) string { - v := le.Labels[key] + return getString(le.Labels, key) +} + +func getString(m map[string]interface{}, key string) string { + v := m[key] if v == nil { return "" } @@ -279,7 +281,11 @@ } func getIntLabel(le adapter.LogEntry, key string) int { - v := le.Labels[key] + return getInt(le.Labels, key) +} + +func getInt(m map[string]interface{}, key string) int { + v := m[key] if v == nil { return 0 } @@ -294,7 +300,11 @@ } func getTimestampLabel(le adapter.LogEntry, key string) int64 { - v := le.Labels[key] + return getTimestamp(le.Labels, key) +} + +func getTimestamp(m map[string]interface{}, key string) int64 { + v := m[key] if v == nil { return 0 }
diff --git a/adapter/applications.go b/adapter/applications.go new file mode 100644 index 0000000..1088725 --- /dev/null +++ b/adapter/applications.go
@@ -0,0 +1,164 @@ +/* +Copyright 2017 Google Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package adapter + +import ( + "bytes" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "strings" + "sync" + "time" + + "github.com/apid/istioApigeeAdapter/common" + "istio.io/mixer/pkg/adapter" +) + +const ( + defaultAppLifetime = 5 * time.Second +) + +type application struct { + clientID string + valid bool + expiration time.Time + name string + apiProducts []string +} + +type applicationJWT struct { + ClientID string `json:"client_id"` + ApplicationName string `json:"application_name"` + APIProducts []string `json:"api_product_list"` +} + +type applicationManager struct { + env adapter.Env + lifetime time.Duration + cache map[string]*application + checkURL string + latch *sync.Mutex +} + +func newApplicationManager(env adapter.Env, lifetime time.Duration, checkURL string) *applicationManager { + return &applicationManager{ + env: env, + lifetime: lifetime, + checkURL: checkURL, + cache: make(map[string]*application), + latch: &sync.Mutex{}, + } +} + +func (a *applicationManager) get(key string) (*application, error) { + app := a.cacheGet(key) + if app != nil { + return app, nil + } + + app, err := a.lookupKey(key) + if err != nil { + return nil, err + } + a.cachePut(key, app) + return app, nil +} + +func (a *applicationManager) cacheGet(key string) *application { + a.latch.Lock() + app := a.cache[key] + if app != nil && app.expiration.Before(time.Now()) { + delete(a.cache, key) + app = nil + } + a.latch.Unlock() + return app +} + +func (a *applicationManager) cachePut(key string, app *application) { + a.latch.Lock() + a.cache[key] = app + a.latch.Unlock() +} + +func (a *applicationManager) lookupKey(key string) (*application, error) { + apiKeyBody := common.VerifyAPIKeyRequest{ + Key: key, + } + requestBody, _ := json.Marshal(&apiKeyBody) + + resp, err := http.DefaultClient.Post(a.checkURL, "application/json", + bytes.NewBuffer(requestBody)) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode == 200 { + return a.parseApplication(resp.Body) + + } else if resp.StatusCode == 401 { + return &application{ + clientID: key, + valid: false, + expiration: time.Now().Add(a.lifetime), + }, nil + + } else { + return nil, fmt.Errorf("HTTP error looking up application: %d", resp.StatusCode) + } +} + +func (a *applicationManager) parseApplication(r io.Reader) (*application, error) { + var keyResponse common.VerifyAPIKeyResponse + jr := json.NewDecoder(r) + err := jr.Decode(&keyResponse) + if err != nil { + return nil, fmt.Errorf("Invalid JSON in API key response: %s", err) + } + + // We got back a JWT, but we don't need to verify it -- just parse the JSON we need + parts := strings.Split(keyResponse.Token, ".") + if len(parts) != 3 { + return nil, errors.New("Invalid JWT returned") + } + // base64 from Apigee is unpadded, so use "raw" encoding here. + rawJWT, err := base64.RawStdEncoding.DecodeString(parts[1]) + if err != nil { + return nil, fmt.Errorf("Error decoding JWT base 64: from \"%s\", %s", keyResponse.Token, err) + } + + var jwt applicationJWT + err = json.Unmarshal(rawJWT, &jwt) + if err != nil { + return nil, fmt.Errorf("Error decoding JWT json: %s", err) + } + + a.env.Logger().Infof("Looked up data for application %s", jwt.ApplicationName) + + return &application{ + clientID: jwt.ClientID, + valid: true, + expiration: time.Now().Add(a.lifetime), + name: jwt.ApplicationName, + apiProducts: jwt.APIProducts, + }, nil +}
diff --git a/mock/mockserver.go b/mock/mockserver.go index 984d5e0..99bbe77 100644 --- a/mock/mockserver.go +++ b/mock/mockserver.go
@@ -142,7 +142,8 @@ } if request.Key != ValidAPIKey1 { - sendFault(w, 404, + // We actually do return 401 in this case! + sendFault(w, 401, fmt.Sprintf("API key %s is not valid", request.Key), "steps.oauth.v2.FailedToResolveAPIKey") return
diff --git a/mock/mockserver_test.go b/mock/mockserver_test.go index e37ee26..49f3207 100644 --- a/mock/mockserver_test.go +++ b/mock/mockserver_test.go
@@ -151,8 +151,8 @@ t.Fatalf("Network error: %s", err) } defer resp.Body.Close() - if resp.StatusCode != 404 { - t.Fatalf("Got HTTP status code %d -- expected 404", resp.StatusCode) + if resp.StatusCode != 401 { + t.Fatalf("Got HTTP status code %d -- expected 401", resp.StatusCode) } } @@ -169,8 +169,8 @@ t.Fatalf("Network error: %s", err) } defer resp.Body.Close() - if resp.StatusCode != 404 { - t.Fatalf("Got HTTP status code %d -- expected 404", resp.StatusCode) + if resp.StatusCode != 401 { + t.Fatalf("Got HTTP status code %d -- expected 401", resp.StatusCode) } }
diff --git a/testdata/configroot/scopes/global/adapters.yml b/testdata/configroot/scopes/global/adapters.yml index cd4639f..5079e36 100644 --- a/testdata/configroot/scopes/global/adapters.yml +++ b/testdata/configroot/scopes/global/adapters.yml
@@ -18,7 +18,7 @@ kind: lists impl: apigeeKeyChecker params: - organization: YOUR_ORG_HERE + organization: the-brails environment: prod # Add verificationURL to use a different service or URL #verificationURL: https://YOUR_ORG_HERE-prod.apigee.net/edgemicro-auth @@ -36,7 +36,24 @@ kind: access-logs impl: apigeeReport params: - organization: YOUR_ORG_HERE + organization: the-brails environment: prod - key: KEY_FROM_EDGEMICRO_CONFIGURE - secret: SECRET_FROM_EDGEMICRO_CONFIGURE + # the-brails + key: da46c48b8987b395d1b0cce228974372c2046a4f052c9d941a06f68e1cd5acc3 + secret: 2b31064d4f65f3cc8cc42ef29a3742ccbfc75caf53d05bc70c6d1023560597fa + # gregbrail + #key: cf9b8f76a0cb29232f4a73bef86f97859e67642746a3dcce3efd1f34d773f544 + #secret: f6326522114cb4041212279fb2863278280a137142bbef35f386c4f767a0e627 + #collectionURL: https://edgemicroservices-us-east-1.apigee.net/edgemicro + - name: apigeeAttributes + kind: attributes + impl: apigeeKeyAttributes + params: + organization: the-brails + environment: prod + - name: authorizationChecker + kind: lists + impl: genericListChecker + params: + listEntries: + - "true"
diff --git a/testdata/configroot/scopes/global/descriptors.yml b/testdata/configroot/scopes/global/descriptors.yml index d66e343..c1aed42 100644 --- a/testdata/configroot/scopes/global/descriptors.yml +++ b/testdata/configroot/scopes/global/descriptors.yml
@@ -79,9 +79,32 @@ valueType: STRING proxy.revision: valueType: INT64 + authorization.success: + valueType: BOOL + authorization.success.string: + valueType: STRING + authorization.client.id: + valueType: STRING + authorization.application.name: + valueType: STRING # DEPRECATED, to be removed. Use request.useragent instead. request.user-agent: valueType: STRING + - name: apigeeKeyAttributes + revision: "1" + attributes: + apiKey: + valueType: STRING + requestPath: + valueType: STRING + success: + valueType: BOOL + successString: + valueType: STRING + clientID: + valueType: STRING + applicationName: + valueType: STRING # Enums as struct fields can be symbolic names. # However enums inside maps *cannot* be symbolic names. metrics: @@ -207,4 +230,6 @@ responseCode: 2 proxyName: 1 proxyRevision: 2 + clientID: 1 + applicationName: 1
diff --git a/testdata/configroot/scopes/global/subjects/global/rules.yml b/testdata/configroot/scopes/global/subjects/global/rules.yml index 32bae3f..5545c95 100644 --- a/testdata/configroot/scopes/global/subjects/global/rules.yml +++ b/testdata/configroot/scopes/global/subjects/global/rules.yml
@@ -3,10 +3,31 @@ rules: - selector: # must be empty for preprocessing adapters aspects: - - kind: lists - adapter: apigeeAPIKey + # Fetch the API key and use it to set various request attributes. + # This is where the adapter should cache, because this one gets called three times + # (check, quota, and report) + - kind: attributes + adapter: apigeeAttributes params: - checkExpression: request.headers["apikey"] + input_expressions: + apiKey: request.headers["apikey"] | "INVALID_KEY" + requestPath: request.path | "/" + attribute_bindings: + authorization.success.string: successString + authorization.success: success + authorization.client.id: clientID + authorization.application.name: applicationName + # In "check" reject the request if the key is invalid. It'd be nice if we could + # customize the error here. We could build yet another adapter for that purpose! + - kind: lists + adapter: authorizationChecker + params: + checkExpression: authorization.success.string + # This is a simpler way to achieve the above! + #- kind: lists + # adapter: apigeeAPIKey + # params: + # checkExpression: request.headers["apikey"] - kind: quotas params: quotas: @@ -44,3 +65,5 @@ responseCode: response.code | 200 proxyName: proxy.name | "istio" proxyRevision: proxy.revision | 1 + clientID: authorization.client.id | "" + applicationName: authorization.application.name | ""
diff --git a/tools/checkfmt.sh b/tools/checkfmt.sh index c668975..4d0e581 100755 --- a/tools/checkfmt.sh +++ b/tools/checkfmt.sh
@@ -1,15 +1,28 @@ #!/bin/bash +CHECKER=goimports +which goimports > /dev/null +if [ $? -ne 0 ] +then + which gofmt > /dev/null + if [ $? -ne 0 ] + then + echo 'Neither goimports nor gofmt could be found' + exit 2 + fi + CHECKER=gofmt +fi + failed=0 lc=`ls *.go 2>/dev/null | wc -l` if [ $lc -gt 0 ] then - lc=`goimports -l . | wc -l` + lc=`${CHECKER} -l . | wc -l` if [ $lc -gt 0 ] then - echo "** goimports run required:" - gofmt -l . + echo "** ${CHECKER} run required:" + ${CHECKER} -l . failed=1 fi