diff --git a/docs/features/analysis.md b/docs/features/analysis.md index 457c78b835..7dc60134ac 100644 --- a/docs/features/analysis.md +++ b/docs/features/analysis.md @@ -837,7 +837,7 @@ data: ## Web Metrics -A webhook can be used to call out to some external service to obtain the measurement. This example makes a HTTP GET request to some URL. The webhook response must return JSON content. The result of the `jsonPath` expression will be assigned to the `result` variable that can be referenced in the `successCondition` and `failureCondition` expressions. +A webhook can be used to call out to some external service to obtain the measurement. This example makes a HTTP GET request to some URL. The webhook response must return JSON content. The result of the optional `jsonPath` expression will be assigned to the `result` variable that can be referenced in the `successCondition` and `failureCondition` expressions. If omitted, will use the entire body of the as the result variable. ```yaml metrics: @@ -853,23 +853,31 @@ A webhook can be used to call out to some external service to obtain the measure jsonPath: "{$.results.ok}" ``` -In this example, the measurement is successful if the json response returns `"true"` for the nested `ok` field. +In the following example, given the payload, the measurement will be Successful if the `data.ok` field was `true`, and the `data.successPercent` +was greater than `0.90` ```json -{ "results": { "ok": "true", "successPercent": 0.95 } } +{ + "data": { + "ok": true, + "successPercent": 0.95 + } +} ``` -For success conditions that need to evaluate a numeric return value the `asInt` or `asFloat` functions can be used to convert the result value. - ```yaml metrics: - name: webmetric - successCondition: "asFloat(result) >= 0.90" + successCondition: "result.ok && result.successPercent >= 0.90" provider: web: url: "http://my-server.com/api/v1/measurement?service={{ args.service-name }}" headers: - key: Authorization value: "Bearer {{ args.api-token }}" - jsonPath: "{$.results.successPercent}" + jsonPath: "{$.data}" ``` + +NOTE: if the result is a string, two convenience functions `asInt` and `asFloat` are provided +to convert a result value to a numeric type so that mathematical comparison operators can be used +(e.g. >, <, >=, <=). diff --git a/manifests/crds/analysis-run-crd.yaml b/manifests/crds/analysis-run-crd.yaml index 77dd4e8daf..a9717fc4d6 100644 --- a/manifests/crds/analysis-run-crd.yaml +++ b/manifests/crds/analysis-run-crd.yaml @@ -2707,6 +2707,8 @@ spec: - value type: object type: array + insecure: + type: boolean jsonPath: type: string timeoutSeconds: @@ -2714,7 +2716,6 @@ spec: url: type: string required: - - jsonPath - url type: object type: object diff --git a/manifests/crds/analysis-template-crd.yaml b/manifests/crds/analysis-template-crd.yaml index cad17a64cb..66ca9083d0 100644 --- a/manifests/crds/analysis-template-crd.yaml +++ b/manifests/crds/analysis-template-crd.yaml @@ -2701,6 +2701,8 @@ spec: - value type: object type: array + insecure: + type: boolean jsonPath: type: string timeoutSeconds: @@ -2708,7 +2710,6 @@ spec: url: type: string required: - - jsonPath - url type: object type: object diff --git a/manifests/crds/cluster-analysis-template-crd.yaml b/manifests/crds/cluster-analysis-template-crd.yaml index 9800ebce5f..5f8a19d170 100644 --- a/manifests/crds/cluster-analysis-template-crd.yaml +++ b/manifests/crds/cluster-analysis-template-crd.yaml @@ -2701,6 +2701,8 @@ spec: - value type: object type: array + insecure: + type: boolean jsonPath: type: string timeoutSeconds: @@ -2708,7 +2710,6 @@ spec: url: type: string required: - - jsonPath - url type: object type: object diff --git a/manifests/install.yaml b/manifests/install.yaml index b386a51412..411d0d5153 100644 --- a/manifests/install.yaml +++ b/manifests/install.yaml @@ -3,7 +3,7 @@ apiVersion: apiextensions.k8s.io/v1beta1 kind: CustomResourceDefinition metadata: annotations: - controller-gen.kubebuilder.io/version: v0.2.5 + controller-gen.kubebuilder.io/version: v0.3.0 name: analysisruns.argoproj.io spec: additionalPrinterColumns: @@ -2715,7 +2715,6 @@ spec: url: type: string required: - - jsonPath - url type: object type: object @@ -2814,7 +2813,7 @@ apiVersion: apiextensions.k8s.io/v1beta1 kind: CustomResourceDefinition metadata: annotations: - controller-gen.kubebuilder.io/version: v0.2.5 + controller-gen.kubebuilder.io/version: v0.3.0 name: analysistemplates.argoproj.io spec: group: argoproj.io @@ -5520,7 +5519,6 @@ spec: url: type: string required: - - jsonPath - url type: object type: object @@ -5547,7 +5545,7 @@ apiVersion: apiextensions.k8s.io/v1beta1 kind: CustomResourceDefinition metadata: annotations: - controller-gen.kubebuilder.io/version: v0.2.5 + controller-gen.kubebuilder.io/version: v0.3.0 name: clusteranalysistemplates.argoproj.io spec: group: argoproj.io @@ -8253,7 +8251,6 @@ spec: url: type: string required: - - jsonPath - url type: object type: object @@ -8280,7 +8277,7 @@ apiVersion: apiextensions.k8s.io/v1beta1 kind: CustomResourceDefinition metadata: annotations: - controller-gen.kubebuilder.io/version: v0.2.5 + controller-gen.kubebuilder.io/version: v0.3.0 name: experiments.argoproj.io spec: additionalPrinterColumns: @@ -10955,7 +10952,7 @@ apiVersion: apiextensions.k8s.io/v1beta1 kind: CustomResourceDefinition metadata: annotations: - controller-gen.kubebuilder.io/version: v0.2.5 + controller-gen.kubebuilder.io/version: v0.3.0 name: rollouts.argoproj.io spec: additionalPrinterColumns: diff --git a/manifests/namespace-install.yaml b/manifests/namespace-install.yaml index a03d20173e..3221828b74 100644 --- a/manifests/namespace-install.yaml +++ b/manifests/namespace-install.yaml @@ -3,7 +3,7 @@ apiVersion: apiextensions.k8s.io/v1beta1 kind: CustomResourceDefinition metadata: annotations: - controller-gen.kubebuilder.io/version: v0.2.5 + controller-gen.kubebuilder.io/version: v0.3.0 name: analysisruns.argoproj.io spec: additionalPrinterColumns: @@ -2715,7 +2715,6 @@ spec: url: type: string required: - - jsonPath - url type: object type: object @@ -2814,7 +2813,7 @@ apiVersion: apiextensions.k8s.io/v1beta1 kind: CustomResourceDefinition metadata: annotations: - controller-gen.kubebuilder.io/version: v0.2.5 + controller-gen.kubebuilder.io/version: v0.3.0 name: analysistemplates.argoproj.io spec: group: argoproj.io @@ -5520,7 +5519,6 @@ spec: url: type: string required: - - jsonPath - url type: object type: object @@ -5547,7 +5545,7 @@ apiVersion: apiextensions.k8s.io/v1beta1 kind: CustomResourceDefinition metadata: annotations: - controller-gen.kubebuilder.io/version: v0.2.5 + controller-gen.kubebuilder.io/version: v0.3.0 name: clusteranalysistemplates.argoproj.io spec: group: argoproj.io @@ -8253,7 +8251,6 @@ spec: url: type: string required: - - jsonPath - url type: object type: object @@ -8280,7 +8277,7 @@ apiVersion: apiextensions.k8s.io/v1beta1 kind: CustomResourceDefinition metadata: annotations: - controller-gen.kubebuilder.io/version: v0.2.5 + controller-gen.kubebuilder.io/version: v0.3.0 name: experiments.argoproj.io spec: additionalPrinterColumns: @@ -10955,7 +10952,7 @@ apiVersion: apiextensions.k8s.io/v1beta1 kind: CustomResourceDefinition metadata: annotations: - controller-gen.kubebuilder.io/version: v0.2.5 + controller-gen.kubebuilder.io/version: v0.3.0 name: rollouts.argoproj.io spec: additionalPrinterColumns: diff --git a/metricproviders/webmetric/webmetric.go b/metricproviders/webmetric/webmetric.go index 9a264c3bde..0e570fa571 100644 --- a/metricproviders/webmetric/webmetric.go +++ b/metricproviders/webmetric/webmetric.go @@ -1,12 +1,14 @@ package webmetric import ( - "bytes" + "crypto/tls" "encoding/json" + "errors" "fmt" "io/ioutil" "net/http" "net/url" + "reflect" "time" metricutil "github.com/argoproj/argo-rollouts/utils/metric" @@ -19,7 +21,7 @@ import ( ) const ( - //ProviderType indicates the provider is prometheus + // ProviderType indicates the provider is a web metric ProviderType = "WebMetric" ) @@ -96,15 +98,28 @@ func (p *Provider) parseResponse(metric v1alpha1.Metric, response *http.Response return "", v1alpha1.AnalysisPhaseError, fmt.Errorf("Could not parse JSON body: %v", err) } - buf := new(bytes.Buffer) - err = p.jsonParser.Execute(buf, data) + fullResults, err := p.jsonParser.FindResults(data) if err != nil { return "", v1alpha1.AnalysisPhaseError, fmt.Errorf("Could not find JSONPath in body: %s", err) } - out := buf.String() + val, valString, err := getValue(fullResults) + if err != nil { + return "", v1alpha1.AnalysisPhaseError, err + } - status := evaluate.EvaluateResult(out, metric, p.logCtx) - return out, status, nil + status := evaluate.EvaluateResult(val, metric, p.logCtx) + return valString, status, nil +} + +func getValue(fullResults [][]reflect.Value) (interface{}, string, error) { + for _, results := range fullResults { + for _, r := range results { + val := r.Interface() + valBytes, err := json.Marshal(val) + return val, string(valBytes), err + } + } + return nil, "", errors.New("result of web metric produced no value") } // Resume should not be used the WebMetric provider since all the work should occur in the Run method @@ -137,14 +152,22 @@ func NewWebMetricHttpClient(metric v1alpha1.Metric) *http.Client { c := &http.Client{ Timeout: timeout, } + if metric.Provider.Web.Insecure { + tr := &http.Transport{ + TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, + } + c.Transport = tr + } return c } func NewWebMetricJsonParser(metric v1alpha1.Metric) (*jsonpath.JSONPath, error) { jsonParser := jsonpath.New("metrics") - - err := jsonParser.Parse(metric.Provider.Web.JSONPath) - + jsonPath := metric.Provider.Web.JSONPath + if jsonPath == "" { + jsonPath = "{$}" + } + err := jsonParser.Parse(jsonPath) return jsonParser, err } diff --git a/metricproviders/webmetric/webmetric_test.go b/metricproviders/webmetric/webmetric_test.go index bc9bee4b47..5d663516da 100644 --- a/metricproviders/webmetric/webmetric_test.go +++ b/metricproviders/webmetric/webmetric_test.go @@ -21,10 +21,45 @@ func TestRunSuite(t *testing.T) { expectedPhase v1alpha1.AnalysisPhase expectedErrorMessage string }{ - // When_numberReturnedInJson_And_MatchesConditions_Then_Succeed + // When_noJSONPathSpecified_And_MatchesConditions_Then_Succeed { webServerStatus: 200, - webServerResponse: `{"key": [{"key2": {"value": 1}}]}`, + webServerResponse: `{"a": 1, "b": true, "c": [1, 2, 3, 4], "d": null}`, + metric: v1alpha1.Metric{ + Name: "foo", + SuccessCondition: "result.a > 0 && result.b && all(result.c, {# < 5}) && result.d == nil", + Provider: v1alpha1.MetricProvider{ + Web: &v1alpha1.WebMetric{ + // URL: server.URL, + Headers: []v1alpha1.WebMetricHeader{{Key: "key", Value: "value"}}, + }, + }, + }, + expectedValue: `{"a":1,"b":true,"c":[1,2,3,4],"d":null}`, + expectedPhase: v1alpha1.AnalysisPhaseSuccessful, + }, + // When_matchesNeitherCondition_Then_Inconclusive + { + webServerStatus: 200, + webServerResponse: `{"a": 1, "b": true, "c": [1, 2, 3, 4], "d": null}`, + metric: v1alpha1.Metric{ + Name: "foo", + SuccessCondition: "result.a >= 2 && result.b && all(result.c, {# < 5}) && result.d == nil", + FailureCondition: "result.a <= 0 && result.b && all(result.c, {# < 5}) && result.d == nil", + Provider: v1alpha1.MetricProvider{ + Web: &v1alpha1.WebMetric{ + // URL: server.URL, + Headers: []v1alpha1.WebMetricHeader{{Key: "key", Value: "value"}}, + }, + }, + }, + expectedValue: `{"a":1,"b":true,"c":[1,2,3,4],"d":null}`, + expectedPhase: v1alpha1.AnalysisPhaseInconclusive, + }, + // When_intStringReturnedInJson_And_MatchesConditions_Then_Succeed + { + webServerStatus: 200, + webServerResponse: `{"key": [{"key2": {"value": "1"}}]}`, metric: v1alpha1.Metric{ Name: "foo", SuccessCondition: "asInt(result) > 0", @@ -37,6 +72,80 @@ func TestRunSuite(t *testing.T) { }, }, }, + expectedValue: "\"1\"", + expectedPhase: v1alpha1.AnalysisPhaseSuccessful, + }, + // When_intStringReturnedInJson_And_DoesNotMatcheConditions_Then_Failure + { + webServerStatus: 200, + webServerResponse: `{"key": [{"key2": {"value": "0"}}]}`, + metric: v1alpha1.Metric{ + Name: "foo", + SuccessCondition: "asInt(result) > 0", + FailureCondition: "asInt(result) <= 0", + Provider: v1alpha1.MetricProvider{ + Web: &v1alpha1.WebMetric{ + // URL: server.URL, + JSONPath: "{$.key[0].key2.value}", + }, + }, + }, + expectedValue: "\"0\"", + expectedPhase: v1alpha1.AnalysisPhaseFailed, + }, + // When_floatStringReturnedInJson_And_MatchesConditions_Then_Succeed + { + webServerStatus: 200, + webServerResponse: `{"key": [{"key2": {"value": "1.2"}}]}`, + metric: v1alpha1.Metric{ + Name: "foo", + SuccessCondition: "asFloat(result) > 1.1", + FailureCondition: "asFloat(result) <= 0", + Provider: v1alpha1.MetricProvider{ + Web: &v1alpha1.WebMetric{ + // URL: server.URL, + JSONPath: "{$.key[0].key2.value}", + Headers: []v1alpha1.WebMetricHeader{{Key: "key", Value: "value"}}, + }, + }, + }, + expectedValue: `"1.2"`, + expectedPhase: v1alpha1.AnalysisPhaseSuccessful, + }, + // When_floatStringReturnedInJson_And_DoesNotMatcheConditions_Then_Failure + { + webServerStatus: 200, + webServerResponse: `{"key": [{"key2": {"value": "1.2"}}]}`, + metric: v1alpha1.Metric{ + Name: "foo", + SuccessCondition: "asFloat(result) > 1.1", + FailureCondition: "asFloat(result) < 1.3", + Provider: v1alpha1.MetricProvider{ + Web: &v1alpha1.WebMetric{ + // URL: server.URL, + JSONPath: "{$.key[0].key2.value}", + }, + }, + }, + expectedValue: `"1.2"`, + expectedPhase: v1alpha1.AnalysisPhaseFailed, + }, + // When_numberReturnedInJson_And_MatchesConditions_Then_Succeed + { + webServerStatus: 200, + webServerResponse: `{"key": [{"key2": {"value": 1}}]}`, + metric: v1alpha1.Metric{ + Name: "foo", + SuccessCondition: "result > 0", + FailureCondition: "result <= 0", + Provider: v1alpha1.MetricProvider{ + Web: &v1alpha1.WebMetric{ + // URL: server.URL, + JSONPath: "{$.key[0].key2.value}", + Headers: []v1alpha1.WebMetricHeader{{Key: "key", Value: "value"}}, + }, + }, + }, expectedValue: "1", expectedPhase: v1alpha1.AnalysisPhaseSuccessful, }, @@ -46,8 +155,8 @@ func TestRunSuite(t *testing.T) { webServerResponse: `{"key": [{"key2": {"value": 0}}]}`, metric: v1alpha1.Metric{ Name: "foo", - SuccessCondition: "asInt(result) > 0", - FailureCondition: "asInt(result) <= 0", + SuccessCondition: "result > 0", + FailureCondition: "result <= 0", Provider: v1alpha1.MetricProvider{ Web: &v1alpha1.WebMetric{ // URL: server.URL, @@ -64,8 +173,8 @@ func TestRunSuite(t *testing.T) { webServerResponse: `{"key": [{"key2": {"value": 1.1}}]}`, metric: v1alpha1.Metric{ Name: "foo", - SuccessCondition: "asFloat(result) > 0", - FailureCondition: "asFloat(result) <= 0", + SuccessCondition: "result > 0", + FailureCondition: "result <= 0", Provider: v1alpha1.MetricProvider{ Web: &v1alpha1.WebMetric{ // URL: server.URL, @@ -82,8 +191,8 @@ func TestRunSuite(t *testing.T) { webServerResponse: `{"key": [{"key2": {"value": -1.1}}]}`, metric: v1alpha1.Metric{ Name: "foo", - SuccessCondition: "asFloat(result) > 0", - FailureCondition: "asFloat(result) <= 0", + SuccessCondition: "result > 0", + FailureCondition: "result <= 0", Provider: v1alpha1.MetricProvider{ Web: &v1alpha1.WebMetric{ // URL: server.URL, @@ -100,8 +209,8 @@ func TestRunSuite(t *testing.T) { webServerResponse: `{"key": [{"key2": {"value": "true"}}]}`, metric: v1alpha1.Metric{ Name: "foo", - SuccessCondition: "true", - FailureCondition: "false", + SuccessCondition: `result == "true"`, + FailureCondition: `result == "false"`, Provider: v1alpha1.MetricProvider{ Web: &v1alpha1.WebMetric{ // URL: server.URL, @@ -109,7 +218,7 @@ func TestRunSuite(t *testing.T) { }, }, }, - expectedValue: "true", + expectedValue: `"true"`, expectedPhase: v1alpha1.AnalysisPhaseSuccessful, }, // When_stringReturnedInJson_And_DoesNotMatchConditions_Then_Fail @@ -118,8 +227,26 @@ func TestRunSuite(t *testing.T) { webServerResponse: `{"key": [{"key2": {"value": "true"}}]}`, metric: v1alpha1.Metric{ Name: "foo", - SuccessCondition: "true", - FailureCondition: "true", + SuccessCondition: `result == "true"`, + FailureCondition: `result == "true"`, + Provider: v1alpha1.MetricProvider{ + Web: &v1alpha1.WebMetric{ + // URL: server.URL, + JSONPath: "{$.key[0].key2.value}", + }, + }, + }, + expectedValue: `"true"`, + expectedPhase: v1alpha1.AnalysisPhaseFailed, + }, + // When_boolReturnedInJson_And_MatchesConditions_Then_Succeed + { + webServerStatus: 200, + webServerResponse: `{"key": [{"key2": {"value": true}}]}`, + metric: v1alpha1.Metric{ + Name: "foo", + SuccessCondition: "result == true", + FailureCondition: "result == false", Provider: v1alpha1.MetricProvider{ Web: &v1alpha1.WebMetric{ // URL: server.URL, @@ -128,6 +255,93 @@ func TestRunSuite(t *testing.T) { }, }, expectedValue: "true", + expectedPhase: v1alpha1.AnalysisPhaseSuccessful, + }, + // When_boolReturnedInJson_And_DoesNotMatchConditions_Then_Fail + { + webServerStatus: 200, + webServerResponse: `{"key": [{"key2": {"value": false}}]}`, + metric: v1alpha1.Metric{ + Name: "foo", + SuccessCondition: "result == true", + FailureCondition: "result == false", + Provider: v1alpha1.MetricProvider{ + Web: &v1alpha1.WebMetric{ + // URL: server.URL, + JSONPath: "{$.key[0].key2.value}", + }, + }, + }, + expectedValue: "false", + expectedPhase: v1alpha1.AnalysisPhaseFailed, + }, + // When_listReturnedInJson_And_MatchesConditions_Then_Succeed + { + webServerStatus: 200, + webServerResponse: `{"key": [1, 2, 3, 4, 5, 6]}`, + metric: v1alpha1.Metric{ + Name: "foo", + SuccessCondition: "any(result, {# > 5})", + Provider: v1alpha1.MetricProvider{ + Web: &v1alpha1.WebMetric{ + // URL: server.URL, + JSONPath: "{$.key}", + }, + }, + }, + expectedValue: "[1,2,3,4,5,6]", + expectedPhase: v1alpha1.AnalysisPhaseSuccessful, + }, + // When_listReturnedInJson_And_DoesNotMatchConditions_Then_Fail + { + webServerStatus: 200, + webServerResponse: `{"key": [1, 2, 3, 4, 5, 6]}`, + metric: v1alpha1.Metric{ + Name: "foo", + SuccessCondition: "any(result, {# > 6})", + Provider: v1alpha1.MetricProvider{ + Web: &v1alpha1.WebMetric{ + // URL: server.URL, + JSONPath: "{$.key}", + }, + }, + }, + expectedValue: "[1,2,3,4,5,6]", + expectedPhase: v1alpha1.AnalysisPhaseFailed, + }, + // When_mapReturnedInJson_And_MatchesConditions_Then_Succeed + { + webServerStatus: 200, + webServerResponse: `{"key":{"num":1.2, "bool":true, "mapfield":{"foo":"bar"}}}`, + metric: v1alpha1.Metric{ + Name: "foo", + SuccessCondition: `result.num > 1.1 && result.bool && result.mapfield.foo == "bar"`, + Provider: v1alpha1.MetricProvider{ + Web: &v1alpha1.WebMetric{ + // URL: server.URL, + JSONPath: "{$.key}", + }, + }, + }, + expectedValue: `{"bool":true,"mapfield":{"foo":"bar"},"num":1.2}`, + expectedPhase: v1alpha1.AnalysisPhaseSuccessful, + }, + // When_mapReturnedInJson_And_DoesNotMatchConditions_Then_Fail + { + webServerStatus: 200, + webServerResponse: `{"key":{"num":1.2, "bool":true, "mapfield":{"foo":"bar"}}}`, + metric: v1alpha1.Metric{ + Name: "foo", + SuccessCondition: `result.num > 1.1 && result.bool && result.mapfield.foo == "bar"`, + FailureCondition: `result.num > 1.1 && result.bool && result.mapfield.foo == "bar"`, + Provider: v1alpha1.MetricProvider{ + Web: &v1alpha1.WebMetric{ + // URL: server.URL, + JSONPath: "{$.key}", + }, + }, + }, + expectedValue: `{"bool":true,"mapfield":{"foo":"bar"},"num":1.2}`, expectedPhase: v1alpha1.AnalysisPhaseFailed, }, // When_non200_Then_Error @@ -255,7 +469,9 @@ func TestRunSuite(t *testing.T) { // Common Asserts assert.NotNil(t, measurement) - assert.Equal(t, string(test.expectedPhase), string(measurement.Phase)) + if !assert.Equal(t, string(test.expectedPhase), string(measurement.Phase)) { + assert.NotNil(t, measurement) + } // Phase specific cases switch test.expectedPhase { diff --git a/pkg/apis/rollouts/v1alpha1/analysis_types.go b/pkg/apis/rollouts/v1alpha1/analysis_types.go index a1afae3a78..fa7a168ebd 100644 --- a/pkg/apis/rollouts/v1alpha1/analysis_types.go +++ b/pkg/apis/rollouts/v1alpha1/analysis_types.go @@ -124,8 +124,10 @@ func (m *Metric) EffectiveCount() *int32 { type MetricProvider struct { // Prometheus specifies the prometheus metric to query Prometheus *PrometheusMetric `json:"prometheus,omitempty"` - Kayenta *KayentaMetric `json:"kayenta,omitempty"` - Web *WebMetric `json:"web,omitempty"` + // Kayenta specifies a Kayenta metric + Kayenta *KayentaMetric `json:"kayenta,omitempty"` + // Web specifies a generic HTTP web metric + Web *WebMetric `json:"web,omitempty"` // Wavefront specifies the wavefront metric to query Wavefront *WavefrontMetric `json:"wavefront,omitempty"` // Job specifies the job metric run @@ -330,12 +332,18 @@ type ScopeDetail struct { } type WebMetric struct { + // URL is the address of the web metric URL string `json:"url"` // +patchMergeKey=key // +patchStrategy=merge - Headers []WebMetricHeader `json:"headers,omitempty" patchStrategy:"merge" patchMergeKey:"key"` - TimeoutSeconds int `json:"timeoutSeconds,omitempty"` - JSONPath string `json:"jsonPath"` + // Headers are optional HTTP headers to use in the request + Headers []WebMetricHeader `json:"headers,omitempty" patchStrategy:"merge" patchMergeKey:"key"` + // TimeoutSeconds is the timeout for the request in seconds (default: 10) + TimeoutSeconds int `json:"timeoutSeconds,omitempty"` + // JSONPath is a JSON Path to use as the result variable (default: "{$}") + JSONPath string `json:"jsonPath,omitempty"` + // Insecure skips host TLS verification + Insecure bool `json:"insecure,omitempty"` } type WebMetricHeader struct { diff --git a/pkg/apis/rollouts/v1alpha1/openapi_generated.go b/pkg/apis/rollouts/v1alpha1/openapi_generated.go index 6fe3ed790a..489220084f 100644 --- a/pkg/apis/rollouts/v1alpha1/openapi_generated.go +++ b/pkg/apis/rollouts/v1alpha1/openapi_generated.go @@ -89,6 +89,7 @@ func GetOpenAPIDefinitions(ref common.ReferenceCallback) map[string]common.OpenA "github.com/argoproj/argo-rollouts/pkg/apis/rollouts/v1alpha1.SMITrafficRouting": schema_pkg_apis_rollouts_v1alpha1_SMITrafficRouting(ref), "github.com/argoproj/argo-rollouts/pkg/apis/rollouts/v1alpha1.ScopeDetail": schema_pkg_apis_rollouts_v1alpha1_ScopeDetail(ref), "github.com/argoproj/argo-rollouts/pkg/apis/rollouts/v1alpha1.SecretKeyRef": schema_pkg_apis_rollouts_v1alpha1_SecretKeyRef(ref), + "github.com/argoproj/argo-rollouts/pkg/apis/rollouts/v1alpha1.SetCanaryScale": schema_pkg_apis_rollouts_v1alpha1_SetCanaryScale(ref), "github.com/argoproj/argo-rollouts/pkg/apis/rollouts/v1alpha1.TemplateSpec": schema_pkg_apis_rollouts_v1alpha1_TemplateSpec(ref), "github.com/argoproj/argo-rollouts/pkg/apis/rollouts/v1alpha1.TemplateStatus": schema_pkg_apis_rollouts_v1alpha1_TemplateStatus(ref), "github.com/argoproj/argo-rollouts/pkg/apis/rollouts/v1alpha1.ValueFrom": schema_pkg_apis_rollouts_v1alpha1_ValueFrom(ref), @@ -845,11 +846,17 @@ func schema_pkg_apis_rollouts_v1alpha1_CanaryStep(ref common.ReferenceCallback) Ref: ref("github.com/argoproj/argo-rollouts/pkg/apis/rollouts/v1alpha1.RolloutAnalysis"), }, }, + "setCanaryScale": { + SchemaProps: spec.SchemaProps{ + Description: "SetCanaryScale defines how to scale the newRS without chainging traffic weight", + Ref: ref("github.com/argoproj/argo-rollouts/pkg/apis/rollouts/v1alpha1.SetCanaryScale"), + }, + }, }, }, }, Dependencies: []string{ - "github.com/argoproj/argo-rollouts/pkg/apis/rollouts/v1alpha1.RolloutAnalysis", "github.com/argoproj/argo-rollouts/pkg/apis/rollouts/v1alpha1.RolloutExperimentStep", "github.com/argoproj/argo-rollouts/pkg/apis/rollouts/v1alpha1.RolloutPause"}, + "github.com/argoproj/argo-rollouts/pkg/apis/rollouts/v1alpha1.RolloutAnalysis", "github.com/argoproj/argo-rollouts/pkg/apis/rollouts/v1alpha1.RolloutExperimentStep", "github.com/argoproj/argo-rollouts/pkg/apis/rollouts/v1alpha1.RolloutPause", "github.com/argoproj/argo-rollouts/pkg/apis/rollouts/v1alpha1.SetCanaryScale"}, } } @@ -1789,12 +1796,14 @@ func schema_pkg_apis_rollouts_v1alpha1_MetricProvider(ref common.ReferenceCallba }, "kayenta": { SchemaProps: spec.SchemaProps{ - Ref: ref("github.com/argoproj/argo-rollouts/pkg/apis/rollouts/v1alpha1.KayentaMetric"), + Description: "Kayenta specifies a Kayenta metric", + Ref: ref("github.com/argoproj/argo-rollouts/pkg/apis/rollouts/v1alpha1.KayentaMetric"), }, }, "web": { SchemaProps: spec.SchemaProps{ - Ref: ref("github.com/argoproj/argo-rollouts/pkg/apis/rollouts/v1alpha1.WebMetric"), + Description: "Web specifies a generic HTTP web metric", + Ref: ref("github.com/argoproj/argo-rollouts/pkg/apis/rollouts/v1alpha1.WebMetric"), }, }, "wavefront": { @@ -2999,6 +3008,40 @@ func schema_pkg_apis_rollouts_v1alpha1_SecretKeyRef(ref common.ReferenceCallback } } +func schema_pkg_apis_rollouts_v1alpha1_SetCanaryScale(ref common.ReferenceCallback) common.OpenAPIDefinition { + return common.OpenAPIDefinition{ + Schema: spec.Schema{ + SchemaProps: spec.SchemaProps{ + Description: "SetCanaryScale defines how to scale the newRS without chainging traffic weight", + Type: []string{"object"}, + Properties: map[string]spec.Schema{ + "weight": { + SchemaProps: spec.SchemaProps{ + Description: "Weight sets the percentage of replicas the newRS should have", + Type: []string{"integer"}, + Format: "int32", + }, + }, + "replicas": { + SchemaProps: spec.SchemaProps{ + Description: "Replicas sets the number of replicas the newRS should have", + Type: []string{"integer"}, + Format: "int32", + }, + }, + "matchTrafficWeight": { + SchemaProps: spec.SchemaProps{ + Description: "MatchTrafficWeight cancels out previously set Replicas or Weight, effectively activating SetWeight", + Type: []string{"boolean"}, + Format: "", + }, + }, + }, + }, + }, + } +} + func schema_pkg_apis_rollouts_v1alpha1_TemplateSpec(ref common.ReferenceCallback) common.OpenAPIDefinition { return common.OpenAPIDefinition{ Schema: spec.Schema{ @@ -3180,8 +3223,9 @@ func schema_pkg_apis_rollouts_v1alpha1_WebMetric(ref common.ReferenceCallback) c Properties: map[string]spec.Schema{ "url": { SchemaProps: spec.SchemaProps{ - Type: []string{"string"}, - Format: "", + Description: "URL is the address of the web metric", + Type: []string{"string"}, + Format: "", }, }, "headers": { @@ -3192,7 +3236,8 @@ func schema_pkg_apis_rollouts_v1alpha1_WebMetric(ref common.ReferenceCallback) c }, }, SchemaProps: spec.SchemaProps{ - Type: []string{"array"}, + Description: "Headers are optional HTTP headers to use in the request", + Type: []string{"array"}, Items: &spec.SchemaOrArray{ Schema: &spec.Schema{ SchemaProps: spec.SchemaProps{ @@ -3204,18 +3249,27 @@ func schema_pkg_apis_rollouts_v1alpha1_WebMetric(ref common.ReferenceCallback) c }, "timeoutSeconds": { SchemaProps: spec.SchemaProps{ - Type: []string{"integer"}, - Format: "int32", + Description: "TimeoutSeconds is the timeout for the request in seconds (default: 10)", + Type: []string{"integer"}, + Format: "int32", }, }, "jsonPath": { SchemaProps: spec.SchemaProps{ - Type: []string{"string"}, - Format: "", + Description: "JSONPath is a JSON Path to use as the result variable (default: \"{$}\")", + Type: []string{"string"}, + Format: "", + }, + }, + "insecure": { + SchemaProps: spec.SchemaProps{ + Description: "Insecure skips host TLS verification", + Type: []string{"boolean"}, + Format: "", }, }, }, - Required: []string{"url", "jsonPath"}, + Required: []string{"url"}, }, }, Dependencies: []string{ diff --git a/test/e2e/functional/analysistemplate-web-background.yaml b/test/e2e/functional/analysistemplate-web-background.yaml index 0b1692d319..f76a96f801 100644 --- a/test/e2e/functional/analysistemplate-web-background.yaml +++ b/test/e2e/functional/analysistemplate-web-background.yaml @@ -1,3 +1,4 @@ +# A dummy web metric which uses the kubernetes version endpoint as a metric provider apiVersion: argoproj.io/v1alpha1 kind: AnalysisTemplate metadata: @@ -9,5 +10,5 @@ spec: successCondition: result.major == '1' provider: web: - url: http://kubernetes.default.svc/version - jsonPath: "{$.}" + url: https://kubernetes.default.svc/version + insecure: true diff --git a/test/fixtures/given.go b/test/fixtures/given.go index 5664a97bc1..90808693dc 100644 --- a/test/fixtures/given.go +++ b/test/fixtures/given.go @@ -1,7 +1,6 @@ package fixtures import ( - "fmt" "io/ioutil" "strconv" "strings" @@ -28,8 +27,7 @@ func (g *Given) RolloutObjects(text string) *Given { // Some E2E AnalysisTemplates use http://kubernetes.default.svc/version as a fake metric provider. // This doesn't work outside the cluster, so the following replaces it with the host from the // rest config. - newKubernetesURL := fmt.Sprintf("%s/version", g.kubernetesHost) - yamlString := strings.ReplaceAll(string(yamlBytes), "http://kubernetes.default.svc/version", newKubernetesURL) + yamlString := strings.ReplaceAll(string(yamlBytes), "https://kubernetes.default.svc", g.kubernetesHost) objs, err := unstructuredutil.SplitYAML(yamlString) g.CheckError(err) diff --git a/utils/evaluate/evaluate.go b/utils/evaluate/evaluate.go index 05450b41bc..2b48abbe30 100644 --- a/utils/evaluate/evaluate.go +++ b/utils/evaluate/evaluate.go @@ -2,6 +2,7 @@ package evaluate import ( "fmt" + "reflect" "strconv" "github.com/antonmedv/expr" @@ -85,18 +86,74 @@ func EvalCondition(resultValue interface{}, condition string) (bool, error) { return output.(bool), err } -func asInt(in string) int64 { - inAsInt, err := strconv.ParseInt(in, 10, 64) - if err == nil { - return inAsInt +func asInt(in interface{}) int64 { + switch i := in.(type) { + case float64: + return int64(i) + case float32: + return int64(i) + case int64: + return i + case int32: + return int64(i) + case int16: + return int64(i) + case int8: + return int64(i) + case int: + return int64(i) + case uint64: + return int64(i) + case uint32: + return int64(i) + case uint16: + return int64(i) + case uint8: + return int64(i) + case uint: + return int64(i) + case string: + inAsInt, err := strconv.ParseInt(i, 10, 64) + if err == nil { + return inAsInt + } + panic(err) } - panic(err) + panic(fmt.Sprintf("asInt() not supported on %v %v", reflect.TypeOf(in), in)) } -func asFloat(in string) float64 { - inAsFloat, err := strconv.ParseFloat(in, 64) - if err == nil { - return inAsFloat +func asFloat(in interface{}) float64 { + switch i := in.(type) { + case float64: + return i + case float32: + return float64(i) + case int64: + return float64(i) + case int32: + return float64(i) + case int16: + return float64(i) + case int8: + return float64(i) + case int: + return float64(i) + case uint64: + return float64(i) + case uint32: + return float64(i) + case uint16: + return float64(i) + case uint8: + return float64(i) + case uint: + return float64(i) + case string: + inAsFloat, err := strconv.ParseFloat(i, 64) + if err == nil { + return inAsFloat + } + panic(err) } - panic(err) + panic(fmt.Sprintf("asFloat() not supported on %v %v", reflect.TypeOf(in), in)) } diff --git a/utils/evaluate/evaluate_test.go b/utils/evaluate/evaluate_test.go index cb43b708f7..b58cf5f618 100644 --- a/utils/evaluate/evaluate_test.go +++ b/utils/evaluate/evaluate_test.go @@ -149,21 +149,58 @@ func TestEvaluateAsIntPanic(t *testing.T) { } func TestEvaluateAsInt(t *testing.T) { - b, err := EvalCondition("1", "asInt(result) == 1") - assert.NoError(t, err) - assert.True(t, b) + tests := []struct { + input interface{} + expression string + expectation bool + }{ + {"1", "asInt(result) == 1", true}, + {1, "asInt(result) == 1", true}, + {1.123, "asInt(result) == 1", true}, + } + for _, test := range tests { + b, err := EvalCondition(test.input, test.expression) + assert.NoError(t, err) + assert.Equal(t, test.expectation, b) + } } -func TestEvaluateAsFloatPanic(t *testing.T) { - b, err := EvalCondition("NotANum", "asFloat(result) == 1.1") - assert.Errorf(t, err, "got expected error: %v", err) - assert.False(t, b) +func TestEvaluateAsFloatError(t *testing.T) { + tests := []struct { + input interface{} + expression string + errRegexp string + }{ + {"NotANum", "asFloat(result) == 1.1", `strconv.ParseFloat: parsing "NotANum": invalid syntax`}, + {"1.1", "asFloat(result) == \"1.1\"", `invalid operation: == \(mismatched types float64 and string\)`}, + } + for _, test := range tests { + b, err := EvalCondition(test.input, test.expression) + assert.Error(t, err) + assert.False(t, b) + assert.Regexp(t, test.errRegexp, err.Error()) + } } func TestEvaluateAsFloat(t *testing.T) { - b, err := EvalCondition("1.1", "asFloat(result) == 1.1") - assert.NoError(t, err) - assert.True(t, b) + tests := []struct { + input interface{} + expression string + expectation bool + }{ + {"1.1", "asFloat(result) == 1.1", true}, + {"1.1", "asFloat(result) >= 1.1", true}, + {"1.1", "asFloat(result) <= 1.1", true}, + {1.1, "asFloat(result) == 1.1", true}, + {1, "asFloat(result) == 1", true}, + {1, "asFloat(result) >= 1", true}, + {1, "asFloat(result) >= 1", true}, + } + for _, test := range tests { + b, err := EvalCondition(test.input, test.expression) + assert.NoError(t, err) + assert.Equal(t, test.expectation, b) + } } func TestAsInt(t *testing.T) {