Skip to content

Commit

Permalink
feat: implement custom state check
Browse files Browse the repository at this point in the history
Signed-off-by: Ariel Septon <arielsepton@Ariels-MBP.lan>
  • Loading branch information
Ariel Septon authored and Ariel Septon committed Oct 1, 2024
1 parent 0aa30d5 commit ba412a9
Show file tree
Hide file tree
Showing 22 changed files with 1,139 additions and 114 deletions.
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.

18 changes: 17 additions & 1 deletion examples/sample/request.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ spec:
Authorization:
- ("Bearer {{ auth:default:token }}")
payload:
baseUrl: http://flask-api.default.svc.cluster.local/v1/users
baseUrl: http://localhost:9000/v1/users
body: |
{
"username": "mock_user",
Expand Down 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

0 comments on commit ba412a9

Please sign in to comment.