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

feat: implement custom state check #61

Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -292,7 +292,7 @@ jobs:
BUILD_ARGS: "--load"

- name: Publish Artifacts to GitHub
uses: actions/upload-artifact@v2
uses: actions/upload-artifact@50769540e7f4bd5e21e526ee35c689e35e0d6874 # v4
with:
name: output
path: _output/**
Expand Down
35 changes: 31 additions & 4 deletions apis/request/v1alpha2/request_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,11 @@ import (
xpv1 "github.com/crossplane/crossplane-runtime/apis/common/v1"
)

const (
ExpectedResponseCheckTypeDefault = "DEFAULT"
ExpectedResponseCheckTypeCustom = "CUSTOM"
)

// RequestParameters are the configurable fields of a Request.
type RequestParameters struct {
// Mappings defines the HTTP mappings for different methods.
Expand All @@ -44,19 +49,41 @@ type RequestParameters struct {

// SecretInjectionConfig specifies the secrets receiving patches for response data.
SecretInjectionConfigs []SecretInjectionConfig `json:"secretInjectionConfigs,omitempty"`

// ExpectedResponseCheck specifies the mechanism to validate the GET response against expected value.
ExpectedResponseCheck ExpectedResponseCheck `json:"expectedResponseCheck,omitempty"`
}

type Mapping struct {
// +kubebuilder:validation:Enum=POST;GET;PUT;DELETE
Method string `json:"method"`
Body string `json:"body,omitempty"`
URL string `json:"url"`
// Method specifies the HTTP method for the request.
Method string `json:"method"`

// Body specifies the body of the request.
Body string `json:"body,omitempty"`

// URL specifies the URL for the request.
URL string `json:"url"`

// Headers specifies the headers for the request.
Headers map[string][]string `json:"headers,omitempty"`
}

type ExpectedResponseCheck struct {
// Type specifies the type of the expected response check.
// +kubebuilder:validation:Enum=DEFAULT;CUSTOM
Type string `json:"type,omitempty"`

// Logic specifies the custom logic for the expected response check.
Logic string `json:"logic,omitempty"`
}

type Payload struct {
// BaseUrl specifies the base URL for the request.
BaseUrl string `json:"baseUrl,omitempty"`
Body string `json:"body,omitempty"`

// Body specifies data to be used in the request body.
Body string `json:"body,omitempty"`
}

// A RequestSpec defines the desired state of a Request.
Expand Down
16 changes: 16 additions & 0 deletions apis/request/v1alpha2/zz_generated.deepcopy.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

16 changes: 16 additions & 0 deletions examples/sample/request.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,22 @@ spec:
- method: "DELETE"
url: (.payload.baseUrl + "/" + (.response.body.id|tostring))

# expectedResponseCheck is optional. If not specified or if the type is "DEFAULT",
# the resource is considered up to date if the GET response matches the PUT body.
# If specified, the JQ logic determines if the resource is up to date:
# - If the JQ query is false, a PUT request is sent to update the resource.
# - If true, the resource is considered up to date.
expectedResponseCheck:
type: CUSTOM
logic: |
if .response.body.password == .payload.body.password
and .response.body.age == 30
and .response.headers."Content-Type" == ["application/json"]
and .response.headers."X-Secret-Header"[0] == "{{ response-secret:default:extracted-header-data }}"
then true
else false
end

# Secrets receiving patches from response data
secretInjectionConfigs:
- secretRef:
Expand Down
7 changes: 4 additions & 3 deletions internal/controller/disposablerequest/disposablerequest.go
Original file line number Diff line number Diff line change
Expand Up @@ -172,7 +172,7 @@ func (c *external) Observe(ctx context.Context, mg resource.Managed) (managed.Ex
}

func (c *external) deployAction(ctx context.Context, cr *v1alpha2.DisposableRequest) error {
sensitiveBody, err := datapatcher.PatchSecretsIntoBody(ctx, c.localKube, cr.Spec.ForProvider.Body, c.logger)
sensitiveBody, err := datapatcher.PatchSecretsIntoString(ctx, c.localKube, cr.Spec.ForProvider.Body, c.logger)
if err != nil {
return err
}
Expand All @@ -195,22 +195,22 @@ func (c *external) deployAction(ctx context.Context, cr *v1alpha2.DisposableRequ
HttpRequest: details.HttpRequest,
}

c.patchResponseToSecret(ctx, cr, &resource.HttpResponse)

// Get the latest version of the resource before updating
if err := c.localKube.Get(ctx, types.NamespacedName{Name: cr.Name, Namespace: cr.Namespace}, cr); err != nil {
return errors.Wrap(err, errGetLatestVersion)
}

if err != nil {
setErr := resource.SetError(err)
c.patchResponseToSecret(ctx, cr, &resource.HttpResponse)
if settingError := utils.SetRequestResourceStatus(*resource, setErr, resource.SetLastReconcileTime(), resource.SetRequestDetails()); settingError != nil {
return errors.Wrap(settingError, utils.ErrFailedToSetStatus)
}
return err
}

if utils.IsHTTPError(resource.HttpResponse.StatusCode) {
c.patchResponseToSecret(ctx, cr, &resource.HttpResponse)
if settingError := utils.SetRequestResourceStatus(*resource, resource.SetStatusCode(), resource.SetLastReconcileTime(), resource.SetHeaders(), resource.SetBody(), resource.SetRequestDetails(), resource.SetError(nil)); settingError != nil {
return errors.Wrap(settingError, utils.ErrFailedToSetStatus)
}
Expand All @@ -223,6 +223,7 @@ func (c *external) deployAction(ctx context.Context, cr *v1alpha2.DisposableRequ
return err
}

c.patchResponseToSecret(ctx, cr, &resource.HttpResponse)
if !isExpectedResponse {
limit := utils.GetRollbackRetriesLimit(cr.Spec.ForProvider.RollbackRetriesLimit)
return utils.SetRequestResourceStatus(*resource, resource.SetStatusCode(), resource.SetLastReconcileTime(), resource.SetHeaders(), resource.SetBody(),
Expand Down
66 changes: 15 additions & 51 deletions internal/controller/request/observe.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,21 +2,21 @@ package request

import (
"context"
"fmt"
"net/http"
"strings"

"github.com/crossplane-contrib/provider-http/apis/request/v1alpha2"
httpClient "github.com/crossplane-contrib/provider-http/internal/clients/http"
"github.com/crossplane-contrib/provider-http/internal/controller/request/requestgen"
"github.com/crossplane-contrib/provider-http/internal/json"
"github.com/crossplane-contrib/provider-http/internal/utils"
"github.com/pkg/errors"
)

const (
errObjectNotFound = "object wasn't found"
errNotValidJSON = "%s is not a valid JSON string: %s"
errObjectNotFound = "object wasn't found"
errNotValidJSON = "%s is not a valid JSON string: %s"
errConvertResToMap = "failed to convert response to map"
ErrExpectedFormat = "expectedResponseCheck.Logic JQ filter should return a boolean, but returned error: %s"
errExpectedResponseCheckType = "expectedResponseCheck.Type should be either DEFAULT, CUSTOM or empty"
)

type ObserveRequestDetails struct {
Expand Down Expand Up @@ -60,56 +60,26 @@ func (c *external) isUpToDate(ctx context.Context, cr *v1alpha2.Request) (Observ
}

c.patchResponseToSecret(ctx, cr, &details.HttpResponse)
desiredState, err := c.desiredState(ctx, cr)
if err != nil {
if isErrorMappingNotFound(err) {
// Since there is no PUT mapping, we skip the check for its presence in the GET response.
return NewObserve(details, responseErr, true), nil
}
return c.determineResponseCheck(ctx, cr, details, responseErr)
}

// For any other error, we return a failed observation.
return FailedObserve(), err
// determineResponseCheck determines the response check based on the expectedResponseCheck.Type
func (c *external) determineResponseCheck(ctx context.Context, cr *v1alpha2.Request, details httpClient.HttpDetails, responseErr error) (ObserveRequestDetails, error) {
responseChecker := c.getResponseCheck(cr)
if responseChecker == nil {
return FailedObserve(), errors.New(errExpectedResponseCheckType)
}

return c.compareResponseAndDesiredState(details, responseErr, desiredState)
return responseChecker.Check(ctx, cr, details, responseErr)
}

// isObjectValidForObservation checks if the object is valid for observation
func (c *external) isObjectValidForObservation(cr *v1alpha2.Request) bool {
return cr.Status.Response.Body != "" &&
!(cr.Status.RequestDetails.Method == http.MethodPost && utils.IsHTTPError(cr.Status.Response.StatusCode))
}

func (c *external) compareResponseAndDesiredState(details httpClient.HttpDetails, err error, desiredState string) (ObserveRequestDetails, error) {
observeRequestDetails := NewObserve(details, err, false)

if json.IsJSONString(details.HttpResponse.Body) && json.IsJSONString(desiredState) {
responseBodyMap := json.JsonStringToMap(details.HttpResponse.Body)
desiredStateMap := json.JsonStringToMap(desiredState)
observeRequestDetails.Synced = json.Contains(responseBodyMap, desiredStateMap) && utils.IsHTTPSuccess(details.HttpResponse.StatusCode)
return observeRequestDetails, nil
}

if !json.IsJSONString(details.HttpResponse.Body) && json.IsJSONString(desiredState) {
return FailedObserve(), errors.Errorf(errNotValidJSON, "response body", details.HttpResponse.Body)
}

if json.IsJSONString(details.HttpResponse.Body) && !json.IsJSONString(desiredState) {
return FailedObserve(), errors.Errorf(errNotValidJSON, "PUT mapping result", desiredState)
}

observeRequestDetails.Synced = strings.Contains(details.HttpResponse.Body, desiredState) && utils.IsHTTPSuccess(details.HttpResponse.StatusCode)
return observeRequestDetails, nil
}

func (c *external) desiredState(ctx context.Context, cr *v1alpha2.Request) (string, error) {
requestDetails, err := c.requestDetails(ctx, cr, http.MethodPut)
if err != nil {
return "", err
}

return requestDetails.Body.Encrypted.(string), nil
}

// requestDetails generates the request details for a given request
func (c *external) requestDetails(ctx context.Context, cr *v1alpha2.Request, method string) (requestgen.RequestDetails, error) {
mapping, ok := getMappingByMethod(&cr.Spec.ForProvider, method)
if !ok {
Expand All @@ -118,9 +88,3 @@ func (c *external) requestDetails(ctx context.Context, cr *v1alpha2.Request, met

return c.generateValidRequestDetails(ctx, cr, mapping)
}

// isErrorMappingNotFound checks if the provided error indicates that the
// mapping for an HTTP PUT request is not found.
func isErrorMappingNotFound(err error) bool {
return errors.Cause(err).Error() == fmt.Sprintf(errMappingNotFound, http.MethodPut)
}
Loading
Loading