diff --git a/docs/analysis/web.md b/docs/analysis/web.md index 7e559b59d0..e3e133cec0 100644 --- a/docs/analysis/web.md +++ b/docs/analysis/web.md @@ -1,6 +1,6 @@ # Web Metrics -A HTTP request can be performed against some external service to obtain the measurement. This example +An HTTP request can be performed against 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 @@ -49,3 +49,26 @@ NOTE: if the result is a string, two convenience functions `asInt` and `asFloat` to convert a result value to a numeric type so that mathematical comparison operators can be used (e.g. >, <, >=, <=). +### Optional web methods +It is possible to use a POST or PUT requests, by specifying the `method` and `body` fields + +```yaml + metrics: + - name: webmetric + successCondition: result == true + provider: + web: + method: POST # valid values are GET|POST|PUT, defaults to GET + url: "http://my-server.com/api/v1/measurement?service={{ args.service-name }}" + timeoutSeconds: 20 # defaults to 10 seconds + headers: + - key: Authorization + value: "Bearer {{ args.api-token }}" + - key: Content-Type # if body is a json, it is recommended to set the Content-Type + value: "application/json" + body: "{\"key\": \"string value\"}" + jsonPath: "{$.data.ok}" +``` + !!! tip + In order to send in JSON, you have to encode it yourself, and send the correct Content-Type as well. + Setting a `body` field for a `GET` request will result in an error. diff --git a/manifests/crds/analysis-run-crd.yaml b/manifests/crds/analysis-run-crd.yaml index c86adee24f..0f1c848527 100644 --- a/manifests/crds/analysis-run-crd.yaml +++ b/manifests/crds/analysis-run-crd.yaml @@ -2447,6 +2447,8 @@ spec: type: object web: properties: + body: + type: string headers: items: properties: @@ -2463,6 +2465,8 @@ spec: type: boolean jsonPath: type: string + method: + type: string timeoutSeconds: format: int64 type: integer diff --git a/manifests/crds/analysis-template-crd.yaml b/manifests/crds/analysis-template-crd.yaml index 6762c935ef..b9131eba93 100644 --- a/manifests/crds/analysis-template-crd.yaml +++ b/manifests/crds/analysis-template-crd.yaml @@ -2442,6 +2442,8 @@ spec: type: object web: properties: + body: + type: string headers: items: properties: @@ -2458,6 +2460,8 @@ spec: type: boolean jsonPath: type: string + method: + type: string timeoutSeconds: format: int64 type: integer diff --git a/manifests/crds/cluster-analysis-template-crd.yaml b/manifests/crds/cluster-analysis-template-crd.yaml index 10f9982e08..f74108a0bf 100644 --- a/manifests/crds/cluster-analysis-template-crd.yaml +++ b/manifests/crds/cluster-analysis-template-crd.yaml @@ -2442,6 +2442,8 @@ spec: type: object web: properties: + body: + type: string headers: items: properties: @@ -2458,6 +2460,8 @@ spec: type: boolean jsonPath: type: string + method: + type: string timeoutSeconds: format: int64 type: integer diff --git a/manifests/install.yaml b/manifests/install.yaml index 605ff46a94..b472c918fb 100644 --- a/manifests/install.yaml +++ b/manifests/install.yaml @@ -2448,6 +2448,8 @@ spec: type: object web: properties: + body: + type: string headers: items: properties: @@ -2464,6 +2466,8 @@ spec: type: boolean jsonPath: type: string + method: + type: string timeoutSeconds: format: int64 type: integer @@ -5006,6 +5010,8 @@ spec: type: object web: properties: + body: + type: string headers: items: properties: @@ -5022,6 +5028,8 @@ spec: type: boolean jsonPath: type: string + method: + type: string timeoutSeconds: format: int64 type: integer @@ -7491,6 +7499,8 @@ spec: type: object web: properties: + body: + type: string headers: items: properties: @@ -7507,6 +7517,8 @@ spec: type: boolean jsonPath: type: string + method: + type: string timeoutSeconds: format: int64 type: integer diff --git a/manifests/namespace-install.yaml b/manifests/namespace-install.yaml index 3bb79b320a..1fbc495f42 100644 --- a/manifests/namespace-install.yaml +++ b/manifests/namespace-install.yaml @@ -2448,6 +2448,8 @@ spec: type: object web: properties: + body: + type: string headers: items: properties: @@ -2464,6 +2466,8 @@ spec: type: boolean jsonPath: type: string + method: + type: string timeoutSeconds: format: int64 type: integer @@ -5006,6 +5010,8 @@ spec: type: object web: properties: + body: + type: string headers: items: properties: @@ -5022,6 +5028,8 @@ spec: type: boolean jsonPath: type: string + method: + type: string timeoutSeconds: format: int64 type: integer @@ -7491,6 +7499,8 @@ spec: type: object web: properties: + body: + type: string headers: items: properties: @@ -7507,6 +7517,8 @@ spec: type: boolean jsonPath: type: string + method: + type: string timeoutSeconds: format: int64 type: integer diff --git a/metricproviders/webmetric/webmetric.go b/metricproviders/webmetric/webmetric.go index 82c577d57c..1def6a94b1 100644 --- a/metricproviders/webmetric/webmetric.go +++ b/metricproviders/webmetric/webmetric.go @@ -5,10 +5,11 @@ import ( "encoding/json" "errors" "fmt" + "io" "io/ioutil" "net/http" - "net/url" "reflect" + "strings" "time" metricutil "github.com/argoproj/argo-rollouts/utils/metric" @@ -46,18 +47,29 @@ func (p *Provider) Run(run *v1alpha1.AnalysisRun, metric v1alpha1.Metric) v1alph StartedAt: &startTime, } - // Create request - request := &http.Request{ - Method: "GET", // TODO maybe make this configurable....also implies we will need body templates + method := v1alpha1.WebMetricMethodGet + if metric.Provider.Web.Method != "" { + method = metric.Provider.Web.Method } - url, err := url.Parse(metric.Provider.Web.URL) + url := metric.Provider.Web.URL + + var body io.Reader + + if metric.Provider.Web.Body != "" { + if method == v1alpha1.WebMetricMethodGet { + return metricutil.MarkMeasurementError(measurement, fmt.Errorf("Body can only be used with POST or PUT WebMetric Method types")) + } + + body = strings.NewReader(metric.Provider.Web.Body) + } + + // Create request + request, err := http.NewRequest(string(method), url, body) if err != nil { return metricutil.MarkMeasurementError(measurement, err) } - request.URL = url - request.Header = make(http.Header) for _, header := range metric.Provider.Web.Headers { diff --git a/metricproviders/webmetric/webmetric_test.go b/metricproviders/webmetric/webmetric_test.go index 148123e9dd..a6107e9c00 100644 --- a/metricproviders/webmetric/webmetric_test.go +++ b/metricproviders/webmetric/webmetric_test.go @@ -1,6 +1,7 @@ package webmetric import ( + "bytes" "io" "net/http" "net/http/httptest" @@ -17,6 +18,8 @@ func TestRunSuite(t *testing.T) { webServerStatus int webServerResponse string metric v1alpha1.Metric + expectedMethod string + expectedBody string expectedValue string expectedPhase v1alpha1.AnalysisPhase expectedErrorMessage string @@ -433,7 +436,6 @@ func TestRunSuite(t *testing.T) { expectedPhase: v1alpha1.AnalysisPhaseError, expectedErrorMessage: "Could not find JSONPath in body", }, - // When_200Response_And_NilBody_Then_Succeed { webServerStatus: 200, @@ -477,6 +479,101 @@ func TestRunSuite(t *testing.T) { expectedPhase: v1alpha1.AnalysisPhaseError, expectedErrorMessage: "", }, + // When_methodEmpty_Then_server_gets_GET + { + webServerStatus: 200, + 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"}}, + }, + }, + }, + expectedMethod: "GET", + expectedValue: `{"a":1,"b":true,"c":[1,2,3,4],"d":null}`, + expectedPhase: v1alpha1.AnalysisPhaseSuccessful, + }, + // When_methodGET_Then_server_gets_GET + { + webServerStatus: 200, + 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{ + Method: v1alpha1.WebMetricMethodGet, + // URL: server.URL, + Headers: []v1alpha1.WebMetricHeader{{Key: "key", Value: "value"}}, + }, + }, + }, + expectedMethod: "GET", + expectedValue: `{"a":1,"b":true,"c":[1,2,3,4],"d":null}`, + expectedPhase: v1alpha1.AnalysisPhaseSuccessful, + }, + // When_methodPOST_Then_server_gets_body + { + webServerStatus: 200, + 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{ + Method: v1alpha1.WebMetricMethodPost, + // URL: server.URL, + Headers: []v1alpha1.WebMetricHeader{{Key: "key", Value: "value"}}, + Body: "some body", + }, + }, + }, + expectedMethod: "POST", + expectedBody: "some body", + expectedValue: `{"a":1,"b":true,"c":[1,2,3,4],"d":null}`, + expectedPhase: v1alpha1.AnalysisPhaseSuccessful, + }, + // When_methodPUT_Then_server_gets_body + { + webServerStatus: 200, + 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{ + Method: v1alpha1.WebMetricMethodPut, + // URL: server.URL, + Headers: []v1alpha1.WebMetricHeader{{Key: "key", Value: "value"}}, + Body: "some body", + }, + }, + }, + expectedMethod: "PUT", + expectedBody: "some body", + expectedValue: `{"a":1,"b":true,"c":[1,2,3,4],"d":null}`, + expectedPhase: v1alpha1.AnalysisPhaseSuccessful, + }, + // When_sendingBodyWithGet_Then_Failure + { + 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"}}, + Body: "some body", + }, + }, + }, + expectedValue: "Body can only be used with POST or PUT WebMetric Method types", + expectedPhase: v1alpha1.AnalysisPhaseError, + }, } // Run @@ -484,6 +581,16 @@ func TestRunSuite(t *testing.T) { for _, test := range tests { // Server setup with response server := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { + if test.expectedMethod != "" { + assert.Equal(t, test.expectedMethod, req.Method) + } + + if test.expectedBody != "" { + buf := new(bytes.Buffer) + buf.ReadFrom(req.Body) + assert.Equal(t, test.expectedBody, buf.String()) + } + if test.webServerStatus < 200 || test.webServerStatus >= 300 { http.Error(rw, http.StatusText(test.webServerStatus), test.webServerStatus) } else { diff --git a/pkg/apis/rollouts/v1alpha1/analysis_types.go b/pkg/apis/rollouts/v1alpha1/analysis_types.go index a8bb968de2..c088f4fca1 100644 --- a/pkg/apis/rollouts/v1alpha1/analysis_types.go +++ b/pkg/apis/rollouts/v1alpha1/analysis_types.go @@ -397,20 +397,34 @@ type ScopeDetail struct { } type WebMetric struct { + // Method is the method of the web metric (empty defaults to GET) + Method WebMetricMethod `json:"method,omitempty" protobuf:"bytes,1,opt,name=method"` // URL is the address of the web metric - URL string `json:"url" protobuf:"bytes,1,opt,name=url"` + URL string `json:"url" protobuf:"bytes,2,opt,name=url"` // +patchMergeKey=key // +patchStrategy=merge // Headers are optional HTTP headers to use in the request - Headers []WebMetricHeader `json:"headers,omitempty" patchStrategy:"merge" patchMergeKey:"key" protobuf:"bytes,2,rep,name=headers"` + Headers []WebMetricHeader `json:"headers,omitempty" patchStrategy:"merge" patchMergeKey:"key" protobuf:"bytes,3,rep,name=headers"` + // Body is the body of the we metric (must be POST/PUT) + Body string `json:"body,omitempty" protobuf:"bytes,4,opt,name=body"` // TimeoutSeconds is the timeout for the request in seconds (default: 10) - TimeoutSeconds int64 `json:"timeoutSeconds,omitempty" protobuf:"varint,3,opt,name=timeoutSeconds"` + TimeoutSeconds int64 `json:"timeoutSeconds,omitempty" protobuf:"varint,5,opt,name=timeoutSeconds"` // JSONPath is a JSON Path to use as the result variable (default: "{$}") - JSONPath string `json:"jsonPath,omitempty" protobuf:"bytes,4,opt,name=jsonPath"` + JSONPath string `json:"jsonPath,omitempty" protobuf:"bytes,6,opt,name=jsonPath"` // Insecure skips host TLS verification - Insecure bool `json:"insecure,omitempty" protobuf:"varint,5,opt,name=insecure"` + Insecure bool `json:"insecure,omitempty" protobuf:"varint,7,opt,name=insecure"` } +// WebMetricMethod is the available HTTP methods +type WebMetricMethod string + +// Possible HTTP method values +const ( + WebMetricMethodGet WebMetricMethod = "GET" + WebMetricMethodPost WebMetricMethod = "POST" + WebMetricMethodPut WebMetricMethod = "PUT" +) + type WebMetricHeader struct { Key string `json:"key" protobuf:"bytes,1,opt,name=key"` Value string `json:"value" protobuf:"bytes,2,opt,name=value"`