From 3a1e7d39bfe46a2ad88b32ba433f53a84ec68986 Mon Sep 17 00:00:00 2001 From: Vemund Gaukstad Date: Wed, 15 Jan 2025 19:48:33 +0100 Subject: [PATCH] feat(apim): crds and controller for configuring apis in apim (#1175) * feat: api and apiversion reconcile logic Still some work to be done with the tests. Committing to not loose work * feat: add base64 encoded string of content add base64-encoded string of api spec (content) to status. refactor internal/utils after new methods are added. fix bug in long_running_operations checker * rewrite api controller tests * rewrite all controller tests using manager instead of simulating reconciliation * remove commented code * fix linting errors * add test for long_running_operations * fixes after coderabbit review * fixes after coderabbit review * fixes after coderabbit review * fixes after coderabbit review * added utils.go test for status code !200 * fixes after coderabbit review * replace Fprintln with Fprint * Update services/dis-apim-operator/internal/controller/apiversion_controller.go Co-authored-by: Sebastian Duran * fix: handle non-not-found policyErr --------- Co-authored-by: tjololo <1145298+tjololo@users.noreply.github.com> Co-authored-by: Sebastian Duran --- services/dis-apim-operator/.gitignore | 2 + .../api/v1alpha1/api_types.go | 93 +++- .../api/v1alpha1/apiversion_enums.go | 157 ++++++ .../api/v1alpha1/apiversion_types.go | 212 +++++++- .../api/v1alpha1/backend_types.go | 7 +- .../api/v1alpha1/zz_generated.deepcopy.go | 213 +++++++- services/dis-apim-operator/cmd/main.go | 16 +- .../crd/bases/apim.dis.altinn.cloud_apis.yaml | 272 ++++++++++ .../apim.dis.altinn.cloud_apiversions.yaml | 229 +++++++++ .../bases/apim.dis.altinn.cloud_backends.yaml | 9 +- .../dis-apim-operator/config/rbac/role.yaml | 6 + .../config/samples/apim_v1alpha1_api.yaml | 45 +- .../samples/apim_v1alpha1_backend.ignore.yaml | 12 - .../config/samples/apim_v1alpha1_backend.yaml | 4 +- .../internal/azure/long_running_operations.go | 42 ++ .../azure/long_running_operations_test.go | 141 ++++++ .../internal/controller/api_controller.go | 267 +++++++++- .../controller/api_controller_test.go | 138 +++-- .../controller/apiversion_controller.go | 239 ++++++++- .../controller/apiversion_controller_test.go | 67 --- .../internal/controller/backend_controller.go | 14 +- .../controller/backend_controller_test.go | 476 ++---------------- .../internal/controller/suite_test.go | 67 ++- .../internal/utils/base64.go | 33 ++ .../internal/utils/base64_test.go | 60 +++ .../internal/utils/consts.go | 4 + .../internal/utils/policytemplate.go | 23 + .../internal/utils/policytemplate_test.go | 77 +++ .../dis-apim-operator/internal/utils/sha.go | 37 ++ .../internal/utils/sha_test.go | 60 +++ .../dis-apim-operator/internal/utils/utils.go | 50 ++ .../internal/utils/utils_suite_test.go | 26 + .../internal/utils/utils_test.go | 72 +++ .../test/utils/azure_apim_api_fake.go | 283 +++++++++++ .../test/utils/azure_fake.go | 81 --- 35 files changed, 2862 insertions(+), 672 deletions(-) create mode 100644 services/dis-apim-operator/api/v1alpha1/apiversion_enums.go create mode 100644 services/dis-apim-operator/config/crd/bases/apim.dis.altinn.cloud_apis.yaml create mode 100644 services/dis-apim-operator/config/crd/bases/apim.dis.altinn.cloud_apiversions.yaml delete mode 100644 services/dis-apim-operator/config/samples/apim_v1alpha1_backend.ignore.yaml create mode 100644 services/dis-apim-operator/internal/azure/long_running_operations.go create mode 100644 services/dis-apim-operator/internal/azure/long_running_operations_test.go create mode 100644 services/dis-apim-operator/internal/utils/base64.go create mode 100644 services/dis-apim-operator/internal/utils/base64_test.go create mode 100644 services/dis-apim-operator/internal/utils/consts.go create mode 100644 services/dis-apim-operator/internal/utils/policytemplate.go create mode 100644 services/dis-apim-operator/internal/utils/policytemplate_test.go create mode 100644 services/dis-apim-operator/internal/utils/sha.go create mode 100644 services/dis-apim-operator/internal/utils/sha_test.go create mode 100644 services/dis-apim-operator/internal/utils/utils.go create mode 100644 services/dis-apim-operator/internal/utils/utils_suite_test.go create mode 100644 services/dis-apim-operator/internal/utils/utils_test.go create mode 100644 services/dis-apim-operator/test/utils/azure_apim_api_fake.go diff --git a/services/dis-apim-operator/.gitignore b/services/dis-apim-operator/.gitignore index ada68ff0..3f0935da 100644 --- a/services/dis-apim-operator/.gitignore +++ b/services/dis-apim-operator/.gitignore @@ -25,3 +25,5 @@ go.work *.swp *.swo *~ + +*.ignore.* \ No newline at end of file diff --git a/services/dis-apim-operator/api/v1alpha1/api_types.go b/services/dis-apim-operator/api/v1alpha1/api_types.go index 8e469515..58caf208 100644 --- a/services/dis-apim-operator/api/v1alpha1/api_types.go +++ b/services/dis-apim-operator/api/v1alpha1/api_types.go @@ -17,6 +17,10 @@ limitations under the License. package v1alpha1 import ( + "fmt" + + "github.com/Altinn/altinn-platform/services/dis-apim-operator/internal/utils" + apim "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/apimanagement/armapimanagement/v2" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) @@ -28,18 +32,52 @@ type ApiSpec struct { // INSERT ADDITIONAL SPEC FIELDS - desired state of cluster // Important: Run "make" to regenerate code after modifying this file - // Foo is an example field of Api. Edit api_types.go to remove/update - Foo string `json:"foo,omitempty"` + // DisplayName - The display name of the API. This name is used by the developer portal as the API name. + // +kubebuilder:validation:Required + DisplayName string `json:"displayName"` + // Description - Description of the API. May include its purpose, where to get more information, and other relevant information. + // +kubebuilder:validation:Optional + Description *string `json:"description,omitempty"` + // VersioningScheme - Indicates the versioning scheme used for the API. Possible values include, but are not limited to, "Segment", "Query", "Header". Default value is "Segment". + // +kubebuilder:validation:Optional + // +kubebuilder:default:="Segment" + // +kubebuilder:validation:Enum:=Header;Query;Segment + VersioningScheme APIVersionScheme `json:"versioningScheme,omitempty"` + // Path - API prefix. The value is combined with the API version to form the URL of the API endpoint. + // +kubebuilder:validation:Required + Path string `json:"path"` + // ApiType - Type of API. + // +kubebuilder:validation:Optional + // +kubebuilder:default:="http" + // +default:value:"http" + // +kubebuilder:validation:Enum:=graphql;http;websocket + ApiType *APIType `json:"apiType,omitempty"` + // Contact - Contact details of the API owner. + // +kubebuilder:validation:Optional + Contact *APIContactInformation `json:"contact,omitempty"` + // Versions - A list of API versions associated with the API. If the API is specified using the OpenAPI definition, then the API version is set by the version field of the OpenAPI definition. + // +kubebuilder:validation:Required + Versions []ApiVersionSubSpec `json:"versions"` } // ApiStatus defines the observed state of Api. type ApiStatus struct { - // INSERT ADDITIONAL STATUS FIELD - define observed state of cluster - // Important: Run "make" to regenerate code after modifying this file + // ProvisioningState - The provisioning state of the API. Possible values are: Succeeded, Failed, Updating, Deleting. + // +kubebuilder:validation:Optional + // +kubebuilder:validation:enum:=Succeeded;Failed;Updating;Deleting + ProvisioningState ProvisioningState `json:"provisioningState,omitempty"` + // ApiVersionSetID - The identifier of the API Version Set. + // +kubebuilder:validation:Optional + ApiVersionSetID string `json:"apiVersionSetID,omitempty"` + // VersionStates - A list of API Version deployed in the API Management service and the current state of the API Version. + // +kubebuilder:validation:Optional + VersionStates map[string]ApiVersionStatus `json:"versionStates,omitempty"` } // +kubebuilder:object:root=true // +kubebuilder:subresource:status +// +kubebuilder:printcolumn:name="State",type=string,JSONPath=`.status.provisioningState` +// +kubebuilder:printcolumn:name="Age",type="date",JSONPath=".metadata.creationTimestamp" // Api is the Schema for the apis API. type Api struct { @@ -62,3 +100,50 @@ type ApiList struct { func init() { SchemeBuilder.Register(&Api{}, &ApiList{}) } + +// GetApiAzureFullName returns the name of the Azure resource. +func (a *Api) GetApiAzureFullName() string { + if a == nil { + return "" + } + return fmt.Sprintf("%s-%s", a.Namespace, a.Name) +} + +// ToAzureApiVersionSet returns an APIVersionSetContract object. +func (a *Api) ToAzureApiVersionSet() apim.APIVersionSetContract { + if a == nil { + return apim.APIVersionSetContract{} + } + return apim.APIVersionSetContract{ + Properties: &apim.APIVersionSetContractProperties{ + DisplayName: &a.Spec.DisplayName, + VersioningScheme: a.Spec.VersioningScheme.AzureAPIVersionScheme(), + Description: a.Spec.Description, + }, + Name: utils.ToPointer(a.GetApiAzureFullName()), + } +} + +// ToApiVersions returns a map of ApiVersion type. +func (a *Api) ToApiVersions() map[string]ApiVersion { + apiVersions := make(map[string]ApiVersion) + for _, version := range a.Spec.Versions { + versionFullName := version.GetApiVersionFullName(a.Name) + apiVersion := ApiVersion{ + TypeMeta: metav1.TypeMeta{}, + ObjectMeta: metav1.ObjectMeta{ + Name: versionFullName, + Namespace: a.Namespace, + }, + Spec: ApiVersionSpec{ + ApiVersionSetId: a.Status.ApiVersionSetID, + ApiVersionScheme: a.Spec.VersioningScheme, + Path: a.Spec.Path, + ApiType: a.Spec.ApiType, + ApiVersionSubSpec: version, + }, + } + apiVersions[version.GetApiVersionSpecifier()] = apiVersion + } + return apiVersions +} diff --git a/services/dis-apim-operator/api/v1alpha1/apiversion_enums.go b/services/dis-apim-operator/api/v1alpha1/apiversion_enums.go new file mode 100644 index 00000000..47b0b242 --- /dev/null +++ b/services/dis-apim-operator/api/v1alpha1/apiversion_enums.go @@ -0,0 +1,157 @@ +package v1alpha1 + +import ( + "github.com/Altinn/altinn-platform/services/dis-apim-operator/internal/utils" + apim "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/apimanagement/armapimanagement/v2" +) + +// INSERT ADDITIONAL TYPES +// Important: Run "make" to regenerate code after modifying this file + +// ContentFormat - Format of the Content in which the API is getting imported. +type ContentFormat string + +const ( + // ContentFormatGraphqlLink - The GraphQL API endpoint hosted on a publicly accessible internet address. + ContentFormatGraphqlLink ContentFormat = "graphql-link" + // ContentFormatOpenapi - The contents are inline and Content Type is a OpenAPI 3.0 YAML Document. + ContentFormatOpenapi ContentFormat = "openapi" + // ContentFormatOpenapiJSON - The contents are inline and Content Type is a OpenAPI 3.0 JSON Document. + ContentFormatOpenapiJSON ContentFormat = "openapi+json" + // ContentFormatOpenapiJSONLink - The OpenAPI 3.0 JSON document is hosted on a publicly accessible internet address. + ContentFormatOpenapiJSONLink ContentFormat = "openapi+json-link" + // ContentFormatOpenapiLink - The OpenAPI 3.0 YAML document is hosted on a publicly accessible internet address. + ContentFormatOpenapiLink ContentFormat = "openapi-link" + // ContentFormatSwaggerJSON - The contents are inline and Content Type is a OpenAPI 2.0 JSON Document. + ContentFormatSwaggerJSON ContentFormat = "swagger-json" + // ContentFormatSwaggerLinkJSON - The OpenAPI 2.0 JSON document is hosted on a publicly accessible internet address. + ContentFormatSwaggerLinkJSON ContentFormat = "swagger-link-json" + // ContentFormatWadlLinkJSON - The WADL document is hosted on a publicly accessible internet address. + ContentFormatWadlLinkJSON ContentFormat = "wadl-link-json" + // ContentFormatWadlXML - The contents are inline and Content type is a WADL document. + ContentFormatWadlXML ContentFormat = "wadl-xml" +) + +func (c ContentFormat) AzureContentFormat() *apim.ContentFormat { + contentFormat := apim.ContentFormat(c) + return &contentFormat +} + +type APIContactInformation struct { + // The email address of the contact person/organization. MUST be in the format of an email address + Email *string `json:"email,omitempty"` + + // The identifying name of the contact person/organization + Name *string `json:"name,omitempty"` + + // The URL pointing to the contact information. MUST be in the format of a URL + URL *string `json:"url,omitempty"` +} + +func (a *APIContactInformation) AzureAPIContactInformation() *apim.APIContactInformation { + if a == nil { + return nil + } + return &apim.APIContactInformation{ + Email: a.Email, + Name: a.Name, + URL: a.URL, + } +} + +type APIVersionScheme string + +const ( + // APIVersionSetContractDetailsVersioningSchemeHeader - The API Version is passed in a HTTP header. + APIVersionSetContractDetailsVersioningSchemeHeader APIVersionScheme = "Header" + // APIVersionSetContractDetailsVersioningSchemeQuery - The API Version is passed in a query parameter. + APIVersionSetContractDetailsVersioningSchemeQuery APIVersionScheme = "Query" + // APIVersionSetContractDetailsVersioningSchemeSegment - The API Version is passed in a path segment. + APIVersionSetContractDetailsVersioningSchemeSegment APIVersionScheme = "Segment" +) + +func (a *APIVersionScheme) AzureAPIVersionScheme() *apim.VersioningScheme { + if a == nil { + return nil + } + apiVersionScheme := apim.VersioningScheme(*a) + return &apiVersionScheme +} + +func (a *APIVersionScheme) AzureAPIVersionSetContractDetailsVersioningScheme() *apim.APIVersionSetContractDetailsVersioningScheme { + if a == nil { + return nil + } + apiVersionScheme := apim.APIVersionSetContractDetailsVersioningScheme(*a) + return &apiVersionScheme +} + +type Protocol string + +const ( + ProtocolHTTP Protocol = "http" + ProtocolHTTPS Protocol = "https" + ProtocolWs Protocol = "ws" + ProtocolWss Protocol = "wss" +) + +func (p *Protocol) AzureProtocol() *apim.Protocol { + if p == nil { + return nil + } + protocol := apim.Protocol(*p) + return &protocol +} + +func ToApimProtocolSlice(protocols []Protocol) []*apim.Protocol { + apimProtocols := make([]*apim.Protocol, len(protocols)) + for i, protocol := range protocols { + apimProtocols[i] = utils.ToPointer(apim.Protocol(protocol)) + } + return apimProtocols +} + +type PolicyFormat string + +const ( + // PolicyContentFormatRawxml - The contents are inline and Content type is a non XML encoded policy document. + PolicyContentFormatRawxml PolicyFormat = "rawxml" + // PolicyContentFormatRawxmlLink - The policy document is not XML encoded and is hosted on a HTTP endpoint accessible from + // the API Management service. + PolicyContentFormatRawxmlLink PolicyFormat = "rawxml-link" + // PolicyContentFormatXML - The contents are inline and Content type is an XML document. + PolicyContentFormatXML PolicyFormat = "xml" + // PolicyContentFormatXMLLink - The policy XML document is hosted on a HTTP endpoint accessible from the API Management service. + PolicyContentFormatXMLLink PolicyFormat = "xml-link" +) + +func (p *PolicyFormat) AzurePolicyFormat() *apim.PolicyContentFormat { + if p == nil { + return nil + } + policyFormat := apim.PolicyContentFormat(*p) + return &policyFormat +} + +// APIType - Type of API. +type APIType string + +const ( + APITypeGraphql APIType = "graphql" + APITypeHTTP APIType = "http" + APITypeWebsocket APIType = "websocket" +) + +func (a APIType) AzureApiType() *apim.APIType { + apiType := apim.APIType(a) + return &apiType +} + +type ProvisioningState string + +const ( + ProvisioningStateSucceeded ProvisioningState = "Succeeded" + ProvisioningStateFailed ProvisioningState = "Failed" + ProvisioningStateUpdating ProvisioningState = "Updating" + ProvisioningStateDeleting ProvisioningState = "Deleting" +) diff --git a/services/dis-apim-operator/api/v1alpha1/apiversion_types.go b/services/dis-apim-operator/api/v1alpha1/apiversion_types.go index d4eaa7d0..49b6c95e 100644 --- a/services/dis-apim-operator/api/v1alpha1/apiversion_types.go +++ b/services/dis-apim-operator/api/v1alpha1/apiversion_types.go @@ -17,6 +17,11 @@ limitations under the License. package v1alpha1 import ( + "fmt" + "reflect" + + "github.com/Altinn/altinn-platform/services/dis-apim-operator/internal/utils" + apim "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/apimanagement/armapimanagement/v2" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) @@ -28,25 +33,149 @@ type ApiVersionSpec struct { // INSERT ADDITIONAL SPEC FIELDS - desired state of cluster // Important: Run "make" to regenerate code after modifying this file - // Foo is an example field of ApiVersion. Edit apiversion_types.go to remove/update - Foo string `json:"foo,omitempty"` + // ApiVersionSetId - The identifier of the API Version Set this version is a part of. + // +kubebuilder:validation:Required + ApiVersionSetId string `json:"apiVersionSetId"` + // ApiVersionScheme - The scheme of the API version. Default value is "Segment". + // +kubebuilder:validation:Optional + // +kubebuilder:default:=Segment + ApiVersionScheme APIVersionScheme `json:"apiVersionScheme,omitempty"` + // Path - API prefix. The value is combined with the API version to form the URL of the API endpoint. + // +kubebuilder:validation:Required + // +kubebuilder:validation:MinLength:=1 + Path string `json:"path"` + // ApiType - Type of API. + // +kubebuilder:validation:Optional + // +kubebuilder:default:="http" + // +default:value:"http" + // +kubebuilder:validation:Enum:=graphql;http;websocket + ApiType *APIType `json:"apiType,omitempty"` + // Contact - Contact details of the API owner. + // +kubebuilder:validation:Optional + Contact *APIContactInformation `json:"contact,omitempty"` + // ApiVersionSubSpec defines the desired state of ApiVersion + ApiVersionSubSpec `json:",inline"` +} + +// ApiVersionSubSpec defines the desired state of ApiVersion +type ApiVersionSubSpec struct { + // INSERT ADDITIONAL SPEC FIELDS - desired state of cluster + // Important: Run "make" to regenerate code after modifying this file + // Name - Name of the API Version. If no name is provided this will be the default version + // +kubebuilder:validation:Optional + Name *string `json:"name,omitempty"` + // DisplayName - The display name of the API Version. This name is used by the developer portal as the API Version name. + // +kubebuilder:validation:Required + // +kubebuilder:validation:MinLength:=1 + DisplayName string `json:"displayName"` + // Description - Description of the API Version. May include its purpose, where to get more information, and other relevant information. + // +kubebuilder:validation:Optional + Description string `json:"description,omitempty"` + // ServiceUrl - Absolute URL of the backend service implementing this API. Cannot be more than 2000 characters long. + // +kubebuilder:validation:Optional + // +kubebuilder:validation:MaxLength:=2000 + ServiceUrl *string `json:"serviceUrl,omitempty"` + // Products - Products that the API is associated with. Products are groups of APIs. + // +kubebuilder:validation:Optional + Products []string `json:"products,omitempty"` + // ContentFormat - Format of the Content in which the API is getting imported. Default value is openapi+json. + // +kubebuilder:validation:Optional + // +kubebuilder:default:=openapi+json + ContentFormat *ContentFormat `json:"contentFormat,omitempty"` + // Content - The contents of the API. The value is a string containing the content of the API. + // +kubebuilder:validation:Required + // +kubebuilder:validation:MinLength:=1 + Content *string `json:"content"` + // SubscriptionRquired - Indicates if subscription is required to access the API. Default value is true. + // +kubebuilder:validation:Optional + // +kubebuilder:default:=true + SubscriptionRequired *bool `json:"subscriptionRequired,omitempty"` + // Protocols - Describes protocols over which API is made available. Default value is https. + // +kubebuilder:validation:Optional + // +kubebuilder:default:={https} + Protocols []Protocol `json:"protocols,omitempty"` + // IsCurrent - Indicates if API Version is the current api version. Default value is true. + // +kubebuilder:validation:Optional + // +kubebuilder:default:=true + IsCurrent *bool `json:"isCurrent,omitempty"` + // Policies - The API Version Policy description. + // +kubebuilder:validation:Optional + Policies *ApiPolicySpec `json:"policies,omitempty"` +} + +// ApiPolicySpec defines the desired state of ApiVersion +type ApiPolicySpec struct { + // PolicyContent - The contents of the Policy as string. + // +kubebuilder:validation:Required + PolicyContent *string `json:"policyContent"` + // PolicyFormat - Format of the Policy in which the API is getting imported. + // +kubebuilder:validation:Optional + // +kubebuilder:default:=rawxml + // +kubebuilder:validation:Enum:=xml;xml-link;rawxml;rawxml-link + PolicyFormat *PolicyFormat `json:"policyFormat,omitempty"` + // PolicyValues Value references for replacing policy expressions. + // +kubebuilder:validation:Optional + PolicyValues []PolicyValue `json:"policyValues,omitempty"` +} + +// PolicyValue defines the desired state of ApiVersion +// +kubebuilder:validation:XValidation:rule="!has(self.value) || !has(self.idFromBackend)",message="Either value or idFromBackend must be set, but not both" +// +kubebuilder:validation:XValidation:rule="has(self.value) || has(self.idFromBackend)",message="Either value or idFromBackend must be set" +type PolicyValue struct { + // Name - The key of the policy value. + // +kubebuilder:validation:Required + Name string `json:"name,omitempty"` + // Value - The value of the policy value. + // +kubebuilder:validation:Optional + Value *string `json:"value,omitempty"` + // IdFromBackend references a backend defined in the same namespace. The PolicyValue.Name will be replaced in the ApiPolicySpec with the id of the backend in Azure. + // +kubebuilder:validation:Optional + IdFromBackend *FromBackend `json:"idFromBackend,omitempty"` +} + +// FromBackend defines the desired state of ApiVersion +type FromBackend struct { + // Name + // +kubebuilder:validation:Required + Name string `json:"name,omitempty"` + // Namespace Namespace where the backend is defined. Default value is the same namespace as the API Version. + // +kubebuilder:validation:Optional + Namespace *string `json:"namespace,omitempty"` } // ApiVersionStatus defines the observed state of ApiVersion. type ApiVersionStatus struct { - // INSERT ADDITIONAL STATUS FIELD - define observed state of cluster - // Important: Run "make" to regenerate code after modifying this file + // ProvisioningState - The provisioning state of the API. Possible values are: Succeeded, Failed, Updating, Deleting. + // +kubebuilder:validation:Optional + // +kubebuilder:validation:enum:=Succeeded;Failed;Updating;Deleting + ProvisioningState ProvisioningState `json:"provisioningState,omitempty"` + // ResumeToken - The token used to track long-running operations. + // +kubebuilder:validation:Optional + ResumeToken string `json:"resumeToken,omitempty"` + // LastAppliedSpecSha - The sha256 of the last applied spec. + // +kubebuilder:validation:Optional + LastAppliedSpecSha string `json:"lastAppliedSpecSha,omitempty"` + // LastAppliedPolicySha - The sha256 of the last applied policy. + // +kubebuilder:validation:Optional + LastAppliedPolicySha string `json:"lastAppliedPolicySha,omitempty"` + // LastAppliedPolicyBase64 - The base64 of the last applied spec. + // +kubebuilder:validation:Optional + LastAppliedPolicyBase64 string `json:"lastAppliedPolicyBase64,omitempty"` } // +kubebuilder:object:root=true // +kubebuilder:subresource:status +// +kubebuilder:printcolumn:name="State",type=string,JSONPath=`.status.provisioningState` +// +kubebuilder:printcolumn:name="Age",type="date",JSONPath=".metadata.creationTimestamp" // ApiVersion is the Schema for the apiversions API. type ApiVersion struct { metav1.TypeMeta `json:",inline"` metav1.ObjectMeta `json:"metadata,omitempty"` - Spec ApiVersionSpec `json:"spec,omitempty"` + // Spec defines the desired state of ApiVersion + Spec ApiVersionSpec `json:"spec,omitempty"` + // Status defines the observed state of ApiVersion Status ApiVersionStatus `json:"status,omitempty"` } @@ -62,3 +191,76 @@ type ApiVersionList struct { func init() { SchemeBuilder.Register(&ApiVersion{}, &ApiVersionList{}) } + +func (avss *ApiVersionSubSpec) GetApiVersionSpecifier() string { + versionSpecifier := avss.Name + if versionSpecifier == nil || *versionSpecifier == "" { + versionSpecifier = utils.ToPointer("default") + } + return *versionSpecifier +} + +func (avss *ApiVersionSubSpec) GetApiVersionFullName(apiFullName string) string { + return fmt.Sprintf("%s-%s", apiFullName, avss.GetApiVersionSpecifier()) +} + +func (av *ApiVersion) GetApiVersionAzureFullName() string { + return fmt.Sprintf("%s-%s", av.Namespace, av.Name) +} + +func (a *ApiVersion) RequireUpdate(new ApiVersion) bool { + return !a.Matches(new) +} + +func (a *ApiVersion) Matches(new ApiVersion) bool { + return a.Spec.Path == new.Spec.Path && + a.Spec.ApiVersionScheme == new.Spec.ApiVersionScheme && + pointerValueEqual(a.Spec.ApiType, new.Spec.ApiType) && + pointerValueEqual(a.Spec.Contact, new.Spec.Contact) && + pointerValueEqual(a.Spec.ApiVersionSubSpec.Name, new.Spec.ApiVersionSubSpec.Name) && + a.Spec.ApiVersionSubSpec.DisplayName == new.Spec.ApiVersionSubSpec.DisplayName && + a.Spec.ApiVersionSubSpec.Description == new.Spec.ApiVersionSubSpec.Description && + pointerValueEqual(a.Spec.ApiVersionSubSpec.ServiceUrl, new.Spec.ApiVersionSubSpec.ServiceUrl) && + reflect.DeepEqual(a.Spec.ApiVersionSubSpec.Products, new.Spec.ApiVersionSubSpec.Products) && + pointerValueEqual(a.Spec.ApiVersionSubSpec.ContentFormat, new.Spec.ApiVersionSubSpec.ContentFormat) && + pointerValueEqual(a.Spec.ApiVersionSubSpec.Content, new.Spec.ApiVersionSubSpec.Content) && + pointerValueEqual(a.Spec.ApiVersionSubSpec.SubscriptionRequired, new.Spec.ApiVersionSubSpec.SubscriptionRequired) && + reflect.DeepEqual(a.Spec.ApiVersionSubSpec.Protocols, new.Spec.ApiVersionSubSpec.Protocols) && + pointerValueEqual(a.Spec.ApiVersionSubSpec.IsCurrent, new.Spec.ApiVersionSubSpec.IsCurrent) && + ((a.Spec.ApiVersionSubSpec.Policies == nil && new.Spec.ApiVersionSubSpec.Policies == nil) || + (a.Spec.ApiVersionSubSpec.Policies != nil && new.Spec.ApiVersionSubSpec.Policies != nil && + pointerValueEqual(a.Spec.ApiVersionSubSpec.Policies.PolicyContent, new.Spec.ApiVersionSubSpec.Policies.PolicyContent) && + pointerValueEqual(a.Spec.ApiVersionSubSpec.Policies.PolicyFormat, new.Spec.ApiVersionSubSpec.Policies.PolicyFormat))) +} + +func (a *ApiVersion) ToAzureCreateOrUpdateParameter() apim.APICreateOrUpdateParameter { + apiCreateOrUpdateParams := apim.APICreateOrUpdateParameter{ + Properties: &apim.APICreateOrUpdateProperties{ + Path: &a.Spec.Path, + APIType: a.Spec.ApiType.AzureApiType(), + Description: &a.Spec.Description, + DisplayName: &a.Spec.DisplayName, + Format: a.Spec.ContentFormat.AzureContentFormat(), + IsCurrent: a.Spec.IsCurrent, + Protocols: ToApimProtocolSlice(a.Spec.Protocols), + ServiceURL: a.Spec.ServiceUrl, + SubscriptionRequired: a.Spec.SubscriptionRequired, + Value: a.Spec.Content, + APIVersionSetID: utils.ToPointer(a.Spec.ApiVersionSetId), + APIVersion: a.Spec.Name, + }, + } + if a.Spec.Contact != nil { + apiCreateOrUpdateParams.Properties.Contact = a.Spec.Contact.AzureAPIContactInformation() + } + return apiCreateOrUpdateParams +} +func pointerValueEqual[T comparable](a *T, b *T) bool { + if a == nil && b == nil { + return true + } + if a == nil || b == nil { + return false + } + return *a == *b +} diff --git a/services/dis-apim-operator/api/v1alpha1/backend_types.go b/services/dis-apim-operator/api/v1alpha1/backend_types.go index 8e4258a9..890521c5 100644 --- a/services/dis-apim-operator/api/v1alpha1/backend_types.go +++ b/services/dis-apim-operator/api/v1alpha1/backend_types.go @@ -28,13 +28,13 @@ import ( type BackendSpec struct { // Title - Title of the Backend. May include its purpose, where to get more information, and other relevant information. // +kubebuilder:validation:Required - Title string `json:"title,omitempty"` + Title string `json:"title"` // Description - Description of the Backend. May include its purpose, where to get more information, and other relevant information. // +kubebuilder:validation:Optional Description *string `json:"description,omitempty"` // Url - URL of the Backend. // +kubebuilder:validation:Required - Url string `json:"url,omitempty"` + Url string `json:"url"` // ValidateCertificateChain - Whether to validate the certificate chain when using the backend. // +kubebuilder:validation:Optional // +kubebuilder:default:=true @@ -55,6 +55,7 @@ type BackendStatus struct { BackendID string `json:"backendID,omitempty"` // ProvisioningState - The provisioning state of the Backend. // +kubebuilder:validation:Optional + // +kubebuilder:validation:enum:=Succeeded;Failed ProvisioningState BackendProvisioningState `json:"provisioningState,omitempty"` // LastProvisioningError - The last error that occurred during provisioning. // +kubebuilder:validation:Optional @@ -73,6 +74,8 @@ const ( // +kubebuilder:object:root=true // +kubebuilder:subresource:status +// +kubebuilder:printcolumn:name="State",type=string,JSONPath=".status.provisioningState" +// +kubebuilder:printcolumn:name="Age",type="date",JSONPath=".metadata.creationTimestamp" // Backend is the Schema for the backends API. type Backend struct { diff --git a/services/dis-apim-operator/api/v1alpha1/zz_generated.deepcopy.go b/services/dis-apim-operator/api/v1alpha1/zz_generated.deepcopy.go index b61dd50e..55c76f48 100644 --- a/services/dis-apim-operator/api/v1alpha1/zz_generated.deepcopy.go +++ b/services/dis-apim-operator/api/v1alpha1/zz_generated.deepcopy.go @@ -24,13 +24,43 @@ import ( runtime "k8s.io/apimachinery/pkg/runtime" ) +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *APIContactInformation) DeepCopyInto(out *APIContactInformation) { + *out = *in + if in.Email != nil { + in, out := &in.Email, &out.Email + *out = new(string) + **out = **in + } + if in.Name != nil { + in, out := &in.Name, &out.Name + *out = new(string) + **out = **in + } + if in.URL != nil { + in, out := &in.URL, &out.URL + *out = new(string) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new APIContactInformation. +func (in *APIContactInformation) DeepCopy() *APIContactInformation { + if in == nil { + return nil + } + out := new(APIContactInformation) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *Api) DeepCopyInto(out *Api) { *out = *in out.TypeMeta = in.TypeMeta in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) - out.Spec = in.Spec - out.Status = in.Status + in.Spec.DeepCopyInto(&out.Spec) + in.Status.DeepCopyInto(&out.Status) } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Api. @@ -83,9 +113,63 @@ func (in *ApiList) DeepCopyObject() runtime.Object { return nil } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ApiPolicySpec) DeepCopyInto(out *ApiPolicySpec) { + *out = *in + if in.PolicyContent != nil { + in, out := &in.PolicyContent, &out.PolicyContent + *out = new(string) + **out = **in + } + if in.PolicyFormat != nil { + in, out := &in.PolicyFormat, &out.PolicyFormat + *out = new(PolicyFormat) + **out = **in + } + if in.PolicyValues != nil { + in, out := &in.PolicyValues, &out.PolicyValues + *out = make([]PolicyValue, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ApiPolicySpec. +func (in *ApiPolicySpec) DeepCopy() *ApiPolicySpec { + if in == nil { + return nil + } + out := new(ApiPolicySpec) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *ApiSpec) DeepCopyInto(out *ApiSpec) { *out = *in + if in.Description != nil { + in, out := &in.Description, &out.Description + *out = new(string) + **out = **in + } + if in.ApiType != nil { + in, out := &in.ApiType, &out.ApiType + *out = new(APIType) + **out = **in + } + if in.Contact != nil { + in, out := &in.Contact, &out.Contact + *out = new(APIContactInformation) + (*in).DeepCopyInto(*out) + } + if in.Versions != nil { + in, out := &in.Versions, &out.Versions + *out = make([]ApiVersionSubSpec, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ApiSpec. @@ -101,6 +185,13 @@ func (in *ApiSpec) DeepCopy() *ApiSpec { // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *ApiStatus) DeepCopyInto(out *ApiStatus) { *out = *in + if in.VersionStates != nil { + in, out := &in.VersionStates, &out.VersionStates + *out = make(map[string]ApiVersionStatus, len(*in)) + for key, val := range *in { + (*out)[key] = val + } + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ApiStatus. @@ -118,7 +209,7 @@ func (in *ApiVersion) DeepCopyInto(out *ApiVersion) { *out = *in out.TypeMeta = in.TypeMeta in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) - out.Spec = in.Spec + in.Spec.DeepCopyInto(&out.Spec) out.Status = in.Status } @@ -175,6 +266,17 @@ func (in *ApiVersionList) DeepCopyObject() runtime.Object { // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *ApiVersionSpec) DeepCopyInto(out *ApiVersionSpec) { *out = *in + if in.ApiType != nil { + in, out := &in.ApiType, &out.ApiType + *out = new(APIType) + **out = **in + } + if in.Contact != nil { + in, out := &in.Contact, &out.Contact + *out = new(APIContactInformation) + (*in).DeepCopyInto(*out) + } + in.ApiVersionSubSpec.DeepCopyInto(&out.ApiVersionSubSpec) } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ApiVersionSpec. @@ -202,6 +304,66 @@ func (in *ApiVersionStatus) DeepCopy() *ApiVersionStatus { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ApiVersionSubSpec) DeepCopyInto(out *ApiVersionSubSpec) { + *out = *in + if in.Name != nil { + in, out := &in.Name, &out.Name + *out = new(string) + **out = **in + } + if in.ServiceUrl != nil { + in, out := &in.ServiceUrl, &out.ServiceUrl + *out = new(string) + **out = **in + } + if in.Products != nil { + in, out := &in.Products, &out.Products + *out = make([]string, len(*in)) + copy(*out, *in) + } + if in.ContentFormat != nil { + in, out := &in.ContentFormat, &out.ContentFormat + *out = new(ContentFormat) + **out = **in + } + if in.Content != nil { + in, out := &in.Content, &out.Content + *out = new(string) + **out = **in + } + if in.SubscriptionRequired != nil { + in, out := &in.SubscriptionRequired, &out.SubscriptionRequired + *out = new(bool) + **out = **in + } + if in.Protocols != nil { + in, out := &in.Protocols, &out.Protocols + *out = make([]Protocol, len(*in)) + copy(*out, *in) + } + if in.IsCurrent != nil { + in, out := &in.IsCurrent, &out.IsCurrent + *out = new(bool) + **out = **in + } + if in.Policies != nil { + in, out := &in.Policies, &out.Policies + *out = new(ApiPolicySpec) + (*in).DeepCopyInto(*out) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ApiVersionSubSpec. +func (in *ApiVersionSubSpec) DeepCopy() *ApiVersionSubSpec { + if in == nil { + return nil + } + out := new(ApiVersionSubSpec) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *Backend) DeepCopyInto(out *Backend) { *out = *in @@ -310,3 +472,48 @@ func (in *BackendStatus) DeepCopy() *BackendStatus { in.DeepCopyInto(out) return out } + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *FromBackend) DeepCopyInto(out *FromBackend) { + *out = *in + if in.Namespace != nil { + in, out := &in.Namespace, &out.Namespace + *out = new(string) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new FromBackend. +func (in *FromBackend) DeepCopy() *FromBackend { + if in == nil { + return nil + } + out := new(FromBackend) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *PolicyValue) DeepCopyInto(out *PolicyValue) { + *out = *in + if in.Value != nil { + in, out := &in.Value, &out.Value + *out = new(string) + **out = **in + } + if in.IdFromBackend != nil { + in, out := &in.IdFromBackend, &out.IdFromBackend + *out = new(FromBackend) + (*in).DeepCopyInto(*out) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PolicyValue. +func (in *PolicyValue) DeepCopy() *PolicyValue { + if in == nil { + return nil + } + out := new(PolicyValue) + in.DeepCopyInto(out) + return out +} diff --git a/services/dis-apim-operator/cmd/main.go b/services/dis-apim-operator/cmd/main.go index ffacc422..673e8893 100644 --- a/services/dis-apim-operator/cmd/main.go +++ b/services/dis-apim-operator/cmd/main.go @@ -182,15 +182,23 @@ func main() { } } if err = (&controller.ApiReconciler{ - Client: mgr.GetClient(), - Scheme: mgr.GetScheme(), + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + NewClient: azure.NewAPIMClient, + ApimClientConfig: &azure.ApimClientConfig{ + AzureConfig: *operatorConfig, + }, }).SetupWithManager(mgr); err != nil { setupLog.Error(err, "unable to create controller", "controller", "Api") os.Exit(1) } if err = (&controller.ApiVersionReconciler{ - Client: mgr.GetClient(), - Scheme: mgr.GetScheme(), + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + NewClient: azure.NewAPIMClient, + ApimClientConfig: &azure.ApimClientConfig{ + AzureConfig: *operatorConfig, + }, }).SetupWithManager(mgr); err != nil { setupLog.Error(err, "unable to create controller", "controller", "ApiVersion") os.Exit(1) diff --git a/services/dis-apim-operator/config/crd/bases/apim.dis.altinn.cloud_apis.yaml b/services/dis-apim-operator/config/crd/bases/apim.dis.altinn.cloud_apis.yaml new file mode 100644 index 00000000..c84e94c7 --- /dev/null +++ b/services/dis-apim-operator/config/crd/bases/apim.dis.altinn.cloud_apis.yaml @@ -0,0 +1,272 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.16.4 + name: apis.apim.dis.altinn.cloud +spec: + group: apim.dis.altinn.cloud + names: + kind: Api + listKind: ApiList + plural: apis + singular: api + scope: Namespaced + versions: + - additionalPrinterColumns: + - jsonPath: .status.provisioningState + name: State + type: string + - jsonPath: .metadata.creationTimestamp + name: Age + type: date + name: v1alpha1 + schema: + openAPIV3Schema: + description: Api is the Schema for the apis API. + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + type: string + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + metadata: + type: object + spec: + description: ApiSpec defines the desired state of Api. + properties: + apiType: + default: http + description: ApiType - Type of API. + enum: + - graphql + - http + - websocket + type: string + contact: + description: Contact - Contact details of the API owner. + properties: + email: + description: The email address of the contact person/organization. + MUST be in the format of an email address + type: string + name: + description: The identifying name of the contact person/organization + type: string + url: + description: The URL pointing to the contact information. MUST + be in the format of a URL + type: string + type: object + description: + description: Description - Description of the API. May include its + purpose, where to get more information, and other relevant information. + type: string + displayName: + description: DisplayName - The display name of the API. This name + is used by the developer portal as the API name. + type: string + path: + description: Path - API prefix. The value is combined with the API + version to form the URL of the API endpoint. + type: string + versioningScheme: + default: Segment + description: VersioningScheme - Indicates the versioning scheme used + for the API. Possible values include, but are not limited to, "Segment", + "Query", "Header". Default value is "Segment". + enum: + - Header + - Query + - Segment + type: string + versions: + description: Versions - A list of API versions associated with the + API. If the API is specified using the OpenAPI definition, then + the API version is set by the version field of the OpenAPI definition. + items: + description: ApiVersionSubSpec defines the desired state of ApiVersion + properties: + content: + description: Content - The contents of the API. The value is + a string containing the content of the API. + minLength: 1 + type: string + contentFormat: + default: openapi+json + description: ContentFormat - Format of the Content in which + the API is getting imported. Default value is openapi+json. + type: string + description: + description: Description - Description of the API Version. May + include its purpose, where to get more information, and other + relevant information. + type: string + displayName: + description: DisplayName - The display name of the API Version. + This name is used by the developer portal as the API Version + name. + minLength: 1 + type: string + isCurrent: + default: true + description: IsCurrent - Indicates if API Version is the current + api version. Default value is true. + type: boolean + name: + description: |- + INSERT ADDITIONAL SPEC FIELDS - desired state of cluster + Important: Run "make" to regenerate code after modifying this file + Name - Name of the API Version. If no name is provided this will be the default version + type: string + policies: + description: Policies - The API Version Policy description. + properties: + policyContent: + description: PolicyContent - The contents of the Policy + as string. + type: string + policyFormat: + default: rawxml + description: PolicyFormat - Format of the Policy in which + the API is getting imported. + enum: + - xml + - xml-link + - rawxml + - rawxml-link + type: string + policyValues: + description: PolicyValues Value references for replacing + policy expressions. + items: + description: PolicyValue defines the desired state of + ApiVersion + properties: + idFromBackend: + description: IdFromBackend references a backend defined + in the same namespace. The PolicyValue.Name will + be replaced in the ApiPolicySpec with the id of + the backend in Azure. + properties: + name: + description: Name + type: string + namespace: + description: Namespace Namespace where the backend + is defined. Default value is the same namespace + as the API Version. + type: string + required: + - name + type: object + name: + description: Name - The key of the policy value. + type: string + value: + description: Value - The value of the policy value. + type: string + required: + - name + type: object + x-kubernetes-validations: + - message: Either value or idFromBackend must be set, + but not both + rule: '!has(self.value) || !has(self.idFromBackend)' + - message: Either value or idFromBackend must be set + rule: has(self.value) || has(self.idFromBackend) + type: array + required: + - policyContent + type: object + products: + description: Products - Products that the API is associated + with. Products are groups of APIs. + items: + type: string + type: array + protocols: + default: + - https + description: Protocols - Describes protocols over which API + is made available. Default value is https. + items: + type: string + type: array + serviceUrl: + description: ServiceUrl - Absolute URL of the backend service + implementing this API. Cannot be more than 2000 characters + long. + maxLength: 2000 + type: string + subscriptionRequired: + default: true + description: SubscriptionRquired - Indicates if subscription + is required to access the API. Default value is true. + type: boolean + required: + - content + - displayName + type: object + type: array + required: + - displayName + - path + - versions + type: object + status: + description: ApiStatus defines the observed state of Api. + properties: + apiVersionSetID: + description: ApiVersionSetID - The identifier of the API Version Set. + type: string + provisioningState: + description: 'ProvisioningState - The provisioning state of the API. + Possible values are: Succeeded, Failed, Updating, Deleting.' + type: string + versionStates: + additionalProperties: + description: ApiVersionStatus defines the observed state of ApiVersion. + properties: + lastAppliedPolicyBase64: + description: LastAppliedPolicyBase64 - The base64 of the last + applied spec. + type: string + lastAppliedPolicySha: + description: LastAppliedPolicySha - The sha256 of the last applied + policy. + type: string + lastAppliedSpecSha: + description: LastAppliedSpecSha - The sha256 of the last applied + spec. + type: string + provisioningState: + description: 'ProvisioningState - The provisioning state of + the API. Possible values are: Succeeded, Failed, Updating, + Deleting.' + type: string + resumeToken: + description: ResumeToken - The token used to track long-running + operations. + type: string + type: object + description: VersionStates - A list of API Version deployed in the + API Management service and the current state of the API Version. + type: object + type: object + type: object + served: true + storage: true + subresources: + status: {} diff --git a/services/dis-apim-operator/config/crd/bases/apim.dis.altinn.cloud_apiversions.yaml b/services/dis-apim-operator/config/crd/bases/apim.dis.altinn.cloud_apiversions.yaml new file mode 100644 index 00000000..f20395f2 --- /dev/null +++ b/services/dis-apim-operator/config/crd/bases/apim.dis.altinn.cloud_apiversions.yaml @@ -0,0 +1,229 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.16.4 + name: apiversions.apim.dis.altinn.cloud +spec: + group: apim.dis.altinn.cloud + names: + kind: ApiVersion + listKind: ApiVersionList + plural: apiversions + singular: apiversion + scope: Namespaced + versions: + - additionalPrinterColumns: + - jsonPath: .status.provisioningState + name: State + type: string + - jsonPath: .metadata.creationTimestamp + name: Age + type: date + name: v1alpha1 + schema: + openAPIV3Schema: + description: ApiVersion is the Schema for the apiversions API. + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + type: string + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + metadata: + type: object + spec: + description: Spec defines the desired state of ApiVersion + properties: + apiType: + default: http + description: ApiType - Type of API. + enum: + - graphql + - http + - websocket + type: string + apiVersionScheme: + default: Segment + description: ApiVersionScheme - The scheme of the API version. Default + value is "Segment". + type: string + apiVersionSetId: + description: ApiVersionSetId - The identifier of the API Version Set + this version is a part of. + type: string + contact: + description: Contact - Contact details of the API owner. + properties: + email: + description: The email address of the contact person/organization. + MUST be in the format of an email address + type: string + name: + description: The identifying name of the contact person/organization + type: string + url: + description: The URL pointing to the contact information. MUST + be in the format of a URL + type: string + type: object + content: + description: Content - The contents of the API. The value is a string + containing the content of the API. + minLength: 1 + type: string + contentFormat: + default: openapi+json + description: ContentFormat - Format of the Content in which the API + is getting imported. Default value is openapi+json. + type: string + description: + description: Description - Description of the API Version. May include + its purpose, where to get more information, and other relevant information. + type: string + displayName: + description: DisplayName - The display name of the API Version. This + name is used by the developer portal as the API Version name. + minLength: 1 + type: string + isCurrent: + default: true + description: IsCurrent - Indicates if API Version is the current api + version. Default value is true. + type: boolean + name: + description: |- + INSERT ADDITIONAL SPEC FIELDS - desired state of cluster + Important: Run "make" to regenerate code after modifying this file + Name - Name of the API Version. If no name is provided this will be the default version + type: string + path: + description: Path - API prefix. The value is combined with the API + version to form the URL of the API endpoint. + minLength: 1 + type: string + policies: + description: Policies - The API Version Policy description. + properties: + policyContent: + description: PolicyContent - The contents of the Policy as string. + type: string + policyFormat: + default: rawxml + description: PolicyFormat - Format of the Policy in which the + API is getting imported. + enum: + - xml + - xml-link + - rawxml + - rawxml-link + type: string + policyValues: + description: PolicyValues Value references for replacing policy + expressions. + items: + description: PolicyValue defines the desired state of ApiVersion + properties: + idFromBackend: + description: IdFromBackend references a backend defined + in the same namespace. The PolicyValue.Name will be replaced + in the ApiPolicySpec with the id of the backend in Azure. + properties: + name: + description: Name + type: string + namespace: + description: Namespace Namespace where the backend is + defined. Default value is the same namespace as the + API Version. + type: string + required: + - name + type: object + name: + description: Name - The key of the policy value. + type: string + value: + description: Value - The value of the policy value. + type: string + required: + - name + type: object + x-kubernetes-validations: + - message: Either value or idFromBackend must be set, but not + both + rule: '!has(self.value) || !has(self.idFromBackend)' + - message: Either value or idFromBackend must be set + rule: has(self.value) || has(self.idFromBackend) + type: array + required: + - policyContent + type: object + products: + description: Products - Products that the API is associated with. + Products are groups of APIs. + items: + type: string + type: array + protocols: + default: + - https + description: Protocols - Describes protocols over which API is made + available. Default value is https. + items: + type: string + type: array + serviceUrl: + description: ServiceUrl - Absolute URL of the backend service implementing + this API. Cannot be more than 2000 characters long. + maxLength: 2000 + type: string + subscriptionRequired: + default: true + description: SubscriptionRquired - Indicates if subscription is required + to access the API. Default value is true. + type: boolean + required: + - apiVersionSetId + - content + - displayName + - path + type: object + status: + description: Status defines the observed state of ApiVersion + properties: + lastAppliedPolicyBase64: + description: LastAppliedPolicyBase64 - The base64 of the last applied + spec. + type: string + lastAppliedPolicySha: + description: LastAppliedPolicySha - The sha256 of the last applied + policy. + type: string + lastAppliedSpecSha: + description: LastAppliedSpecSha - The sha256 of the last applied spec. + type: string + provisioningState: + description: 'ProvisioningState - The provisioning state of the API. + Possible values are: Succeeded, Failed, Updating, Deleting.' + type: string + resumeToken: + description: ResumeToken - The token used to track long-running operations. + type: string + type: object + type: object + served: true + storage: true + subresources: + status: {} diff --git a/services/dis-apim-operator/config/crd/bases/apim.dis.altinn.cloud_backends.yaml b/services/dis-apim-operator/config/crd/bases/apim.dis.altinn.cloud_backends.yaml index b3697d0b..9817caa6 100644 --- a/services/dis-apim-operator/config/crd/bases/apim.dis.altinn.cloud_backends.yaml +++ b/services/dis-apim-operator/config/crd/bases/apim.dis.altinn.cloud_backends.yaml @@ -14,7 +14,14 @@ spec: singular: backend scope: Namespaced versions: - - name: v1alpha1 + - additionalPrinterColumns: + - jsonPath: .status.provisioningState + name: State + type: string + - jsonPath: .metadata.creationTimestamp + name: Age + type: date + name: v1alpha1 schema: openAPIV3Schema: description: Backend is the Schema for the backends API. diff --git a/services/dis-apim-operator/config/rbac/role.yaml b/services/dis-apim-operator/config/rbac/role.yaml index d01aa7e6..19f5e8ce 100644 --- a/services/dis-apim-operator/config/rbac/role.yaml +++ b/services/dis-apim-operator/config/rbac/role.yaml @@ -7,6 +7,8 @@ rules: - apiGroups: - apim.dis.altinn.cloud resources: + - apis + - apiversions - backends verbs: - create @@ -19,12 +21,16 @@ rules: - apiGroups: - apim.dis.altinn.cloud resources: + - apis/finalizers + - apiversions/finalizers - backends/finalizers verbs: - update - apiGroups: - apim.dis.altinn.cloud resources: + - apis/status + - apiversions/status - backends/status verbs: - get diff --git a/services/dis-apim-operator/config/samples/apim_v1alpha1_api.yaml b/services/dis-apim-operator/config/samples/apim_v1alpha1_api.yaml index 91d92c80..57179a48 100644 --- a/services/dis-apim-operator/config/samples/apim_v1alpha1_api.yaml +++ b/services/dis-apim-operator/config/samples/apim_v1alpha1_api.yaml @@ -6,4 +6,47 @@ metadata: app.kubernetes.io/managed-by: kustomize name: api-sample spec: - # TODO(user): Add fields here + displayName: Example API + description: This is an example API. + versioningScheme: Segment + path: example + apiType: http + contact: + name: John Doe + email: john.doe@example.com + versions: + - name: v1 + displayName: Version 1 + description: First version of the API + serviceUrl: https://example.com/v1 + products: + - product1 + - product2 + protocols: + - https + subscriptionRequired: true + contentFormat: swagger-link-json + content: https://primary-test-aca-vga.gentleground-884783d5.norwayeast.azurecontainerapps.io/swagger/doc.json + isCurrent: true + policies: + policyContent: | + + + + + + + policyFormat: xml + - name: v2 + displayName: Version 2 + description: First version of the API + serviceUrl: https://example.com/v2 + products: + - product1 + - product2 + protocols: + - https + subscriptionRequired: true + contentFormat: swagger-link-json + content: https://primary-test-aca-vga.gentleground-884783d5.norwayeast.azurecontainerapps.io/swagger/doc.json + isCurrent: true \ No newline at end of file diff --git a/services/dis-apim-operator/config/samples/apim_v1alpha1_backend.ignore.yaml b/services/dis-apim-operator/config/samples/apim_v1alpha1_backend.ignore.yaml deleted file mode 100644 index c4689d89..00000000 --- a/services/dis-apim-operator/config/samples/apim_v1alpha1_backend.ignore.yaml +++ /dev/null @@ -1,12 +0,0 @@ -apiVersion: apim.dis.altinn.cloud/v1alpha1 -kind: Backend -metadata: - labels: - app.kubernetes.io/name: dis-apim-operator - app.kubernetes.io/managed-by: kustomize - name: backend-sample -spec: - title: test-backend - description: test-backend - url: https://primary-test-aca-vga.ambitioushill-7fbb0e9c.norwayeast.azurecontainerapps.io - azureResourceUidPrefix: test \ No newline at end of file diff --git a/services/dis-apim-operator/config/samples/apim_v1alpha1_backend.yaml b/services/dis-apim-operator/config/samples/apim_v1alpha1_backend.yaml index ccf2e4aa..e760a946 100644 --- a/services/dis-apim-operator/config/samples/apim_v1alpha1_backend.yaml +++ b/services/dis-apim-operator/config/samples/apim_v1alpha1_backend.yaml @@ -6,4 +6,6 @@ metadata: app.kubernetes.io/managed-by: kustomize name: backend-sample spec: - + title: backend-sample + description: Sample backend + url: https://api.example.com diff --git a/services/dis-apim-operator/internal/azure/long_running_operations.go b/services/dis-apim-operator/internal/azure/long_running_operations.go new file mode 100644 index 00000000..ec41bb58 --- /dev/null +++ b/services/dis-apim-operator/internal/azure/long_running_operations.go @@ -0,0 +1,42 @@ +package azure + +import ( + "context" + "net/http" + + "github.com/Azure/azure-sdk-for-go/sdk/azcore/runtime" + "sigs.k8s.io/controller-runtime/pkg/log" +) + +type OperationStatus string + +const ( + OperationStatusInProgress OperationStatus = "InProgress" + OperationStatusSucceeded OperationStatus = "Succeeded" + OperationStatusFailed OperationStatus = "Failed" +) + +func StartResumeOperation[T any](ctx context.Context, poller *runtime.Poller[T]) (status OperationStatus, result T, resumeToken string, err error) { + logger := log.FromContext(ctx) + res, err := poller.Poll(ctx) + if err != nil { + status = OperationStatusFailed + return + } + if res.StatusCode != http.StatusOK && res.StatusCode != http.StatusCreated && res.StatusCode != http.StatusAccepted { + status = OperationStatusFailed + err = runtime.NewResponseError(res) + return + } + if poller.Done() { + status = OperationStatusSucceeded + result, err = poller.Result(ctx) + } else { + status = OperationStatusInProgress + resumeToken, err = poller.ResumeToken() + if err != nil { + logger.Error(err, "Failed to get resume Token") + } + } + return +} diff --git a/services/dis-apim-operator/internal/azure/long_running_operations_test.go b/services/dis-apim-operator/internal/azure/long_running_operations_test.go new file mode 100644 index 00000000..db6a278b --- /dev/null +++ b/services/dis-apim-operator/internal/azure/long_running_operations_test.go @@ -0,0 +1,141 @@ +package azure + +import ( + "context" + "errors" + "net/http" + "testing" + + "github.com/Altinn/altinn-platform/services/dis-apim-operator/internal/utils" + "github.com/Azure/azure-sdk-for-go/sdk/azcore/runtime" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Long running operations", func() { + When("When waiting for a long running operation", func() { + + It("should return OperationStatusSucceeded", func() { + By("returning 200 res from poller.Poll and true poller.Done") + p, err := runtime.NewPoller(&http.Response{StatusCode: http.StatusOK}, runtime.Pipeline{}, &runtime.NewPollerOptions[string]{ + Response: utils.ToPointer("fake response"), + Handler: &MockPoller[string]{ + IsDone: true, + PollResult: http.Response{ + StatusCode: http.StatusOK, + }, + }, + }) + Expect(err).ToNot(HaveOccurred()) + status, res, _, err := StartResumeOperation(context.Background(), p) + Expect(err).ToNot(HaveOccurred()) + Expect(status).To(Equal(OperationStatusSucceeded)) + Expect(res).To(Equal("fake response")) + By("returning 202 res from poller.Poll and true poller.Done") + p, err = runtime.NewPoller(&http.Response{StatusCode: http.StatusAccepted}, runtime.Pipeline{}, &runtime.NewPollerOptions[string]{ + Response: utils.ToPointer("fake response"), + Handler: &MockPoller[string]{ + IsDone: true, + PollResult: http.Response{ + StatusCode: http.StatusOK, + }, + }, + }) + Expect(err).ToNot(HaveOccurred()) + status, res, _, err = StartResumeOperation(context.Background(), p) + Expect(err).ToNot(HaveOccurred()) + Expect(status).To(Equal(OperationStatusSucceeded)) + Expect(res).To(Equal("fake response")) + By("returning 201 res from poller.Poll and true poller.Done") + p, err = runtime.NewPoller(&http.Response{StatusCode: http.StatusCreated}, runtime.Pipeline{}, &runtime.NewPollerOptions[string]{ + Response: utils.ToPointer("fake response"), + Handler: &MockPoller[string]{ + IsDone: true, + PollResult: http.Response{ + StatusCode: http.StatusOK, + }, + }, + }) + Expect(err).ToNot(HaveOccurred()) + status, res, _, err = StartResumeOperation(context.Background(), p) + Expect(err).ToNot(HaveOccurred()) + Expect(status).To(Equal(OperationStatusSucceeded)) + Expect(res).To(Equal("fake response")) + }) + It("should return OperationStatusInProgress", func() { + By("returning false when Done is called") + p, err := runtime.NewPoller(nil, runtime.Pipeline{}, &runtime.NewPollerOptions[string]{ + Handler: &MockPoller[string]{ + IsDone: false, + PollResult: http.Response{ + StatusCode: http.StatusOK, + }, + }, + }) + Expect(err).ToNot(HaveOccurred()) + status, res, _, err := StartResumeOperation(context.Background(), p) + Expect(err).ToNot(HaveOccurred()) + Expect(status).To(Equal(OperationStatusInProgress)) + Expect(res).To(BeEmpty()) + }) + It("should return OperationStatusFailed", func() { + By("returning poller.Poll returning an error") + p, err := runtime.NewPoller(nil, runtime.Pipeline{}, &runtime.NewPollerOptions[string]{ + Handler: &MockPoller[string]{ + IsDone: false, + PollError: errors.New("fake error"), + PollResult: http.Response{ + StatusCode: http.StatusOK, + }, + }, + }) + Expect(err).ToNot(HaveOccurred()) + status, res, _, err := StartResumeOperation(context.Background(), p) + Expect(err).To(HaveOccurred()) + Expect(status).To(Equal(OperationStatusFailed)) + Expect(res).To(BeEmpty()) + By("returning poller.Poll returning a non 2xx status code") + p, err = runtime.NewPoller(nil, runtime.Pipeline{}, &runtime.NewPollerOptions[string]{ + Handler: &MockPoller[string]{ + IsDone: false, + PollResult: http.Response{ + StatusCode: http.StatusBadRequest, + }, + }, + }) + Expect(err).ToNot(HaveOccurred()) + status, res, _, err = StartResumeOperation(context.Background(), p) + Expect(err).To(HaveOccurred()) + Expect(status).To(Equal(OperationStatusFailed)) + Expect(res).To(BeEmpty()) + }) + }) +}) + +func TestControllers(t *testing.T) { + RegisterFailHandler(Fail) + + RunSpecs(t, "Controller Suite") +} + +type MockPoller[T any] struct { + IsDone bool + PollResult http.Response + PollError error + FakeResultError error +} + +func (p MockPoller[T]) Poll(_ context.Context) (*http.Response, error) { + return &p.PollResult, p.PollError +} + +func (p MockPoller[T]) Done() bool { + return p.IsDone +} + +func (p MockPoller[T]) Result(_ context.Context, _ *T) error { + if p.FakeResultError != nil { + return p.FakeResultError + } + return nil +} diff --git a/services/dis-apim-operator/internal/controller/api_controller.go b/services/dis-apim-operator/internal/controller/api_controller.go index 003b39fd..bbfd4435 100644 --- a/services/dis-apim-operator/internal/controller/api_controller.go +++ b/services/dis-apim-operator/internal/controller/api_controller.go @@ -18,19 +18,34 @@ package controller import ( "context" + "fmt" + "time" + "github.com/Altinn/altinn-platform/services/dis-apim-operator/internal/azure" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" "sigs.k8s.io/controller-runtime/pkg/log" apimv1alpha1 "github.com/Altinn/altinn-platform/services/dis-apim-operator/api/v1alpha1" ) +var ( + jobOwnerKey = ".metadata.controller" + apiGVStr = apimv1alpha1.GroupVersion.String() +) + +const API_FINALIZER = "api.apim.dis.altinn.cloud/finalizer" + // ApiReconciler reconciles a Api object type ApiReconciler struct { client.Client - Scheme *runtime.Scheme + Scheme *runtime.Scheme + NewClient newApimClient + ApimClientConfig *azure.ApimClientConfig + apimClient *azure.APIMClient } // +kubebuilder:rbac:groups=apim.dis.altinn.cloud,resources=apis,verbs=get;list;watch;create;update;patch;delete @@ -47,17 +62,259 @@ type ApiReconciler struct { // For more details, check Reconcile and its Result here: // - https://pkg.go.dev/sigs.k8s.io/controller-runtime@v0.19.0/pkg/reconcile func (r *ApiReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { - _ = log.FromContext(ctx) + logger := log.FromContext(ctx) + logger.Info("Reconciling Api") + var api apimv1alpha1.Api + if err := r.Get(ctx, req.NamespacedName, &api); err != nil { + if client.IgnoreNotFound(err) != nil { + logger.Error(err, "unable to fetch Api") + } + return ctrl.Result{}, client.IgnoreNotFound(err) + } + if !controllerutil.ContainsFinalizer(&api, API_FINALIZER) { + controllerutil.AddFinalizer(&api, API_FINALIZER) + err := r.Update(ctx, &api) + if err != nil { + logger.Error(err, "Failed to add finalizer") + return ctrl.Result{}, err + } + } + if r.apimClient == nil { + c, err := r.NewClient(r.ApimClientConfig) + if err != nil { + logger.Error(err, "Failed to create new APIM client") + return ctrl.Result{}, err + } + r.apimClient = c + } + if api.DeletionTimestamp != nil { + return r.handleDeletion(ctx, &api) + } + azApi, err := r.apimClient.GetApiVersionSet(ctx, api.GetApiAzureFullName(), nil) + if err != nil { + if azure.IsNotFoundError(err) { - // TODO(user): your logic here - - return ctrl.Result{}, nil + return r.handleCreateUpdate(ctx, &api) + } + logger.Error(err, "Failed to get Azure Apim Api Version Set") + return ctrl.Result{}, err + } + if azApi.ID == nil { + logger.Info("No ID returned for API") + return ctrl.Result{RequeueAfter: 1 * time.Minute}, nil + } + api.Status.ApiVersionSetID = *azApi.ID + inSync, err := r.reconcileVersions(ctx, &api) + if err != nil { + logger.Error(err, "Failed to reconcile versions") + return ctrl.Result{}, err + } + if !inSync { + return ctrl.Result{RequeueAfter: 10 * time.Second}, nil + } + err = r.deleteRemovedVersions(ctx, &api) + if err != nil { + logger.Error(err, "Failed to delete removed versions") + return ctrl.Result{}, err + } + return ctrl.Result{RequeueAfter: 1 * time.Minute}, nil } // SetupWithManager sets up the controller with the Manager. func (r *ApiReconciler) SetupWithManager(mgr ctrl.Manager) error { + if err := mgr.GetFieldIndexer().IndexField(context.Background(), &apimv1alpha1.ApiVersion{}, jobOwnerKey, func(rawObj client.Object) []string { + // grab the job object, extract the owner... + job := rawObj.(*apimv1alpha1.ApiVersion) + owner := metav1.GetControllerOf(job) + if owner == nil { + return nil + } + // ...make sure it's a CronJob... + if owner.APIVersion != apiGVStr || owner.Kind != "Api" { + return nil + } + + // ...and if so, return it + return []string{owner.Name} + }); err != nil { + return err + } + return ctrl.NewControllerManagedBy(mgr). For(&apimv1alpha1.Api{}). + Owns(&apimv1alpha1.ApiVersion{}). Named("api"). Complete(r) } + +func (r *ApiReconciler) handleDeletion(ctx context.Context, api *apimv1alpha1.Api) (ctrl.Result, error) { + logger := log.FromContext(ctx) + azApiVS, err := r.apimClient.GetApiVersionSet(ctx, api.GetApiAzureFullName(), nil) + if err != nil { + if azure.IsNotFoundError(err) { + logger.Info("API version set not found", "VersionSetId", api.GetApiAzureFullName()) + controllerutil.RemoveFinalizer(api, API_FINALIZER) + return ctrl.Result{}, r.Update(ctx, api) + } + logger.Error(err, "Failed to get API version set", "VersionSetId", api.GetApiAzureFullName()) + return ctrl.Result{}, err + } + if r.isAllVersionsDeleted(ctx, api) { + logger.Info("Deleting API version set", "VersionSetId", api.GetApiAzureFullName()) + eTag := "*" + if azApiVS.ETag != nil { + eTag = *azApiVS.ETag + } + _, err := r.apimClient.DeleteApiVersionSet(ctx, api.GetApiAzureFullName(), eTag, nil) + if err != nil { + logger.Error(err, "Failed to delete API version set", "VersionSetId", api.GetApiAzureFullName()) + return ctrl.Result{}, err + } + controllerutil.RemoveFinalizer(api, API_FINALIZER) + return ctrl.Result{}, r.Update(ctx, api) + } + api.Status.ProvisioningState = apimv1alpha1.ProvisioningStateDeleting + + if err := r.handleDeleteVersions(ctx, api); err != nil { + return ctrl.Result{}, err + } + return ctrl.Result{}, r.Status().Update(ctx, api) +} + +func (r *ApiReconciler) handleDeleteVersions(ctx context.Context, api *apimv1alpha1.Api) error { + var apiVersionsList apimv1alpha1.ApiVersionList + apiVersionErr := r.List(ctx, &apiVersionsList, client.InNamespace(api.Namespace), client.MatchingFields{jobOwnerKey: api.Name}) + if client.IgnoreNotFound(apiVersionErr) != nil { + return apiVersionErr + } + versions := apiVersionsList.Items + for _, version := range versions { + if err := r.Delete(ctx, &version); client.IgnoreNotFound(err) != nil { + return err + } + } + return nil +} + +func (r *ApiReconciler) isAllVersionsDeleted(ctx context.Context, api *apimv1alpha1.Api) bool { + logger := log.FromContext(ctx) + var apiVersionList apimv1alpha1.ApiVersionList + apiVersionErr := r.List(ctx, &apiVersionList, client.InNamespace(api.Namespace), client.MatchingFields{jobOwnerKey: api.Name}) + versions := apiVersionList.Items + if client.IgnoreNotFound(apiVersionErr) != nil { + logger.Error(apiVersionErr, "Failed to list versions") + return false + } + if len(versions) > 0 { + logger.Info("Versions still exist", "Count", len(versions)) + return false + } + logger.Info("All versions deleted") + return true +} + +func (r *ApiReconciler) handleCreateUpdate(ctx context.Context, api *apimv1alpha1.Api) (ctrl.Result, error) { + azApi, err := r.apimClient.CreateUpdateApiVersionSet(ctx, api.GetApiAzureFullName(), api.ToAzureApiVersionSet(), nil) + if err != nil { + return ctrl.Result{}, err + } + if azApi.ID == nil { + return ctrl.Result{}, fmt.Errorf("No ID returned for API version set %s", api.GetApiAzureFullName()) + } + api.Status.ApiVersionSetID = *azApi.ID + api.Status.ProvisioningState = apimv1alpha1.ProvisioningStateUpdating + return ctrl.Result{RequeueAfter: 10 * time.Second}, r.Status().Update(ctx, api) +} + +func (r *ApiReconciler) reconcileVersions(ctx context.Context, api *apimv1alpha1.Api) (bool, error) { + logger := log.FromContext(ctx) + wantedVersions := api.ToApiVersions() + inSync := true + if api.Status.VersionStates == nil && len(wantedVersions) > 0 { + api.Status.VersionStates = make(map[string]apimv1alpha1.ApiVersionStatus) + } + for k, wantedVersion := range wantedVersions { + var existingVersion apimv1alpha1.ApiVersion + if err := r.Get(ctx, client.ObjectKey{Namespace: api.Namespace, Name: wantedVersion.Name}, &existingVersion); err != nil { + if client.IgnoreNotFound(err) != nil { + logger.Error(err, "Failed to get ApiVersion") + return false, err + } + if err := controllerutil.SetControllerReference(api, &wantedVersion, r.Scheme); err != nil { + logger.Error(err, "Failed to set controller reference") + return false, err + } + if err := r.Create(ctx, &wantedVersion); err != nil { + logger.Error(err, "Failed to create ApiVersion") + return false, err + } + inSync = false + continue + } else { + if !wantedVersion.Matches(existingVersion) { + existingVersion.Spec = wantedVersion.Spec + if err := r.Update(ctx, &existingVersion); err != nil { + logger.Error(err, "Failed to update ApiVersion") + return false, err + } + inSync = false + api.Status.VersionStates[k] = existingVersion.Status + } else { + if existingVersion.Status.ProvisioningState != apimv1alpha1.ProvisioningStateSucceeded { + inSync = false + } + api.Status.VersionStates[k] = existingVersion.Status + } + } + } + if !inSync { + api.Status.ProvisioningState = getStatusFromVersionStatuses(api.Status.VersionStates) + } else { + api.Status.ProvisioningState = apimv1alpha1.ProvisioningStateSucceeded + } + return inSync, r.Status().Update(ctx, api) +} + +func (r *ApiReconciler) deleteRemovedVersions(ctx context.Context, api *apimv1alpha1.Api) error { + var apiVersionList apimv1alpha1.ApiVersionList + err := r.List(ctx, &apiVersionList, client.InNamespace(api.Namespace), client.MatchingFields{jobOwnerKey: api.Name}) + if err != nil { + return err + } + versions := apiVersionList.Items + if len(versions) == len(api.Spec.Versions) || len(versions) == 0 { + return nil + } + + for _, version := range versions { + if !versionInList(version, api.Name, api.Spec.Versions) { + if err := r.Delete(ctx, &version); client.IgnoreNotFound(err) != nil { + return err + } + delete(api.Status.VersionStates, *version.Spec.Name) + } + } + return r.Status().Update(ctx, api) +} + +func versionInList(version apimv1alpha1.ApiVersion, apiName string, versions []apimv1alpha1.ApiVersionSubSpec) bool { + for _, v := range versions { + if v.GetApiVersionFullName(apiName) == version.Name { + return true + } + } + return false +} + +func getStatusFromVersionStatuses(versions map[string]apimv1alpha1.ApiVersionStatus) apimv1alpha1.ProvisioningState { + state := apimv1alpha1.ProvisioningStateSucceeded + for _, v := range versions { + if v.ProvisioningState == apimv1alpha1.ProvisioningStateFailed { + return apimv1alpha1.ProvisioningStateFailed + } + if v.ProvisioningState != apimv1alpha1.ProvisioningStateSucceeded { + state = apimv1alpha1.ProvisioningStateUpdating + } + } + return state +} diff --git a/services/dis-apim-operator/internal/controller/api_controller_test.go b/services/dis-apim-operator/internal/controller/api_controller_test.go index e296d288..88a0d88a 100644 --- a/services/dis-apim-operator/internal/controller/api_controller_test.go +++ b/services/dis-apim-operator/internal/controller/api_controller_test.go @@ -18,67 +18,129 @@ package controller import ( "context" + "fmt" + "time" + apimv1alpha1 "github.com/Altinn/altinn-platform/services/dis-apim-operator/api/v1alpha1" + "github.com/Altinn/altinn-platform/services/dis-apim-operator/internal/utils" + apim "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/apimanagement/armapimanagement/v2" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" "k8s.io/apimachinery/pkg/api/errors" - "k8s.io/apimachinery/pkg/types" - "sigs.k8s.io/controller-runtime/pkg/reconcile" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" +) - apimv1alpha1 "github.com/Altinn/altinn-platform/services/dis-apim-operator/api/v1alpha1" +const ( + timeout = time.Second * 60 + interval = time.Millisecond * 250 ) var _ = Describe("Api Controller", func() { + Context("When reconciling a resource", func() { const resourceName = "test-resource" + const resourceNamespace = "default" ctx := context.Background() typeNamespacedName := types.NamespacedName{ Name: resourceName, - Namespace: "default", // TODO(user):Modify as needed + Namespace: resourceNamespace, } - api := &apimv1alpha1.Api{} - - BeforeEach(func() { - By("creating the custom resource for the Kind Api") - err := k8sClient.Get(ctx, typeNamespacedName, api) - if err != nil && errors.IsNotFound(err) { - resource := &apimv1alpha1.Api{ - ObjectMeta: metav1.ObjectMeta{ - Name: resourceName, - Namespace: "default", + It("should successfully reconcile the resource", func() { + resource := &apimv1alpha1.Api{ + ObjectMeta: metav1.ObjectMeta{ + Name: resourceName, + Namespace: resourceNamespace, + }, + Spec: apimv1alpha1.ApiSpec{ + DisplayName: "test-api", + Path: "/test-api", + Versions: []apimv1alpha1.ApiVersionSubSpec{ + { + Name: utils.ToPointer("v1"), + Content: utils.ToPointer(`{"openapi": "3.0.0","info": {"title": "Minimal API v1","version": "1.0.0"},""paths": {}}`), + DisplayName: "the default version", + }, }, - // TODO(user): Specify other spec details if needed. - } - Expect(k8sClient.Create(ctx, resource)).To(Succeed()) + }, } - }) + Expect(k8sClient.Create(ctx, resource)).To(Succeed()) - AfterEach(func() { - // TODO(user): Cleanup logic after each test, like removing the resource instance. - resource := &apimv1alpha1.Api{} - err := k8sClient.Get(ctx, typeNamespacedName, resource) - Expect(err).NotTo(HaveOccurred()) + updatedApi := getUpdatedApi(ctx, typeNamespacedName) + Expect(updatedApi.Spec.DisplayName).To(Equal("test-api")) + Eventually(fakeApim.APIMVersionSets).Should(HaveLen(1)) + apimResourceName := resourceNamespace + "-" + resourceName + Expect(fakeApim.APIMVersionSets[apimResourceName].Properties.DisplayName).To(Equal(utils.ToPointer("test-api"))) + Expect(fakeApim.APIMVersionSets[apimResourceName].Name).To(Equal(utils.ToPointer(fmt.Sprintf("%s-%s", resourceNamespace, resourceName)))) + Expect(fakeApim.APIMVersionSets[apimResourceName].Properties).NotTo(BeNil()) + Expect(fakeApim.APIMVersionSets[apimResourceName].Properties.DisplayName).To(Equal(utils.ToPointer("test-api"))) + Expect(*fakeApim.APIMVersionSets[apimResourceName].Properties.VersioningScheme).To(Equal(apim.VersioningSchemeSegment)) + Expect(fakeApim.APIMVersionSets[apimResourceName].Properties.VersionQueryName).To(BeNil()) + var apiVersionList apimv1alpha1.ApiVersionList + Eventually(func(g Gomega) { + err := k8sClient.List(ctx, &apiVersionList) + g.Expect(err).NotTo(HaveOccurred()) + g.Expect(apiVersionList.Items).To(HaveLen(1)) + }, timeout, interval).Should(Succeed(), "list of apiVersions should have length 1") + Expect(apiVersionList.Items[0].Spec.DisplayName).To(Equal("the default version")) + Expect(*apiVersionList.Items[0].Spec.Name).To(Equal("v1")) + Expect(*apiVersionList.Items[0].Spec.Content).To(Equal(`{"openapi": "3.0.0","info": {"title": "Minimal API v1","version": "1.0.0"},""paths": {}}`)) - By("Cleanup the specific resource instance Api") - Expect(k8sClient.Delete(ctx, resource)).To(Succeed()) - }) - It("should successfully reconcile the resource", func() { - By("Reconciling the created resource") - controllerReconciler := &ApiReconciler{ - Client: k8sClient, - Scheme: k8sClient.Scheme(), - } + By("updating the openapi content in the apiversion if it has changed") + Eventually(func(g Gomega) { + updatedApi = getUpdatedApi(ctx, typeNamespacedName) + updatedApi.Spec.Versions[0].Content = utils.ToPointer(`{"openapi": "3.0.0","info": {"title": "Minimal API v1","version": "1.0.0"},""paths": {"test": "test"}}`) + g.Expect(k8sClient.Update(ctx, &updatedApi)).To(Succeed()) + }, timeout, interval).Should(Succeed(), "apiVersion content should eventually be updated") - _, err := controllerReconciler.Reconcile(ctx, reconcile.Request{ - NamespacedName: typeNamespacedName, - }) + Eventually(func(g Gomega) { + g.Expect(fakeApim.APIMVersionSets).To(HaveLen(1)) + g.Expect(k8sClient.List(ctx, &apiVersionList)).Should(Succeed()) + g.Expect(apiVersionList.Items).To(HaveLen(1)) + g.Expect(*apiVersionList.Items[0].Spec.Content).To(Equal(`{"openapi": "3.0.0","info": {"title": "Minimal API v1","version": "1.0.0"},""paths": {"test": "test"}}`)) + }, timeout, interval).Should(Succeed(), "apiVersion content should eventually be updated") + + By("adding a new apiVersion if it has been added to the custom resource") + Eventually(func(g Gomega) { + updatedApi = getUpdatedApi(ctx, typeNamespacedName) + updatedApi.Spec.Versions = append(updatedApi.Spec.Versions, apimv1alpha1.ApiVersionSubSpec{ + Name: utils.ToPointer("v2"), + Content: utils.ToPointer(`{"openapi": "3.0.0","info": {"title": "Minimal API v2","version": "1.0.0"},""paths": {}}`), + DisplayName: "the second version", + }) + + g.Expect(k8sClient.Update(ctx, &updatedApi)).To(Succeed()) + }, timeout, interval).Should(Succeed(), "api should eventually be updated") + Expect(fakeApim.APIMVersionSets).To(HaveLen(1)) + Eventually(func(g Gomega) { + g.Expect(k8sClient.List(ctx, &apiVersionList)).To(Succeed()) + g.Expect(apiVersionList.Items).To(HaveLen(2)) + }, timeout, interval).Should(Succeed(), "apiVersion list should eventually have length 2") + + By("deleting the api if it has been marked for deletion") + err := k8sClient.Get(ctx, typeNamespacedName, &updatedApi) Expect(err).NotTo(HaveOccurred()) - // TODO(user): Add more specific assertions depending on your controller's reconciliation logic. - // Example: If you expect a certain status condition after reconciliation, verify it here. + Eventually(k8sClient.Delete).WithArguments(ctx, &updatedApi).Should(Succeed()) + Eventually(func(g Gomega) { + err := k8sClient.List(ctx, &apiVersionList) + g.Expect(err).NotTo(HaveOccurred()) + g.Expect(apiVersionList.Items).To(BeEmpty()) + }, timeout, interval).Should(Succeed(), "list of apiVersions should have length 0") + Eventually(func(g Gomega) { + g.Expect(fakeApim.APIMVersionSets).To(BeEmpty()) + err = k8sClient.Get(ctx, typeNamespacedName, &updatedApi) + g.Expect(errors.IsNotFound(err)).To(BeTrue()) + }, timeout, interval).Should(Succeed(), "api should eventually be deleted") }) }) }) + +func getUpdatedApi(ctx context.Context, typeNamespacedName types.NamespacedName) apimv1alpha1.Api { + resource := apimv1alpha1.Api{} + Eventually(func(g Gomega) { + g.Expect(k8sClient.Get(ctx, typeNamespacedName, &resource)).To(Succeed()) + }, timeout, interval).Should(Succeed()) + return resource +} diff --git a/services/dis-apim-operator/internal/controller/apiversion_controller.go b/services/dis-apim-operator/internal/controller/apiversion_controller.go index 664c4c6e..d4434e36 100644 --- a/services/dis-apim-operator/internal/controller/apiversion_controller.go +++ b/services/dis-apim-operator/internal/controller/apiversion_controller.go @@ -18,19 +18,34 @@ package controller import ( "context" + "fmt" + "time" + "github.com/Altinn/altinn-platform/services/dis-apim-operator/internal/azure" + "github.com/Altinn/altinn-platform/services/dis-apim-operator/internal/utils" + apim "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/apimanagement/armapimanagement/v2" "k8s.io/apimachinery/pkg/runtime" ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" "sigs.k8s.io/controller-runtime/pkg/log" apimv1alpha1 "github.com/Altinn/altinn-platform/services/dis-apim-operator/api/v1alpha1" ) +const ( + API_VERSION_FINALIZER = "apiversion.apim.dis.altinn.cloud/finalizer" + DEFAULT_REQUE_TIME = 1 * time.Minute + WAITING_FOR_LRO_REQUE_TIME = 5 * time.Second +) + // ApiVersionReconciler reconciles a ApiVersion object type ApiVersionReconciler struct { client.Client - Scheme *runtime.Scheme + Scheme *runtime.Scheme + NewClient newApimClient + ApimClientConfig *azure.ApimClientConfig + apimClient *azure.APIMClient } // +kubebuilder:rbac:groups=apim.dis.altinn.cloud,resources=apiversions,verbs=get;list;watch;create;update;patch;delete @@ -47,11 +62,42 @@ type ApiVersionReconciler struct { // For more details, check Reconcile and its Result here: // - https://pkg.go.dev/sigs.k8s.io/controller-runtime@v0.19.0/pkg/reconcile func (r *ApiVersionReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { - _ = log.FromContext(ctx) + logger := log.FromContext(ctx) - // TODO(user): your logic here + var apiVersion apimv1alpha1.ApiVersion + if err := r.Get(ctx, req.NamespacedName, &apiVersion); err != nil { + if client.IgnoreNotFound(err) != nil { + logger.Error(err, "unable to fetch ApiVersion") + } - return ctrl.Result{}, nil + return ctrl.Result{}, client.IgnoreNotFound(err) + } + if !controllerutil.ContainsFinalizer(&apiVersion, API_VERSION_FINALIZER) { + controllerutil.AddFinalizer(&apiVersion, API_VERSION_FINALIZER) + err := r.Update(ctx, &apiVersion) + if err != nil { + logger.Error(err, "Failed to add finalizer") + return ctrl.Result{}, err + } + } + if r.apimClient == nil { + c, err := r.NewClient(r.ApimClientConfig) + if err != nil { + logger.Error(err, "Failed to create new client") + return ctrl.Result{}, err + } + r.apimClient = c + } + if !apiVersion.DeletionTimestamp.IsZero() { + return r.deleteApiVersion(ctx, apiVersion) + } + _, err := r.apimClient.GetApi(ctx, apiVersion.GetApiVersionAzureFullName(), nil) + if azure.IgnoreNotFound(err) != nil { + logger.Error(err, "Failed to get API") + return ctrl.Result{}, err + } else { + return r.handleApiVersionUpdate(ctx, apiVersion) + } } // SetupWithManager sets up the controller with the Manager. @@ -61,3 +107,188 @@ func (r *ApiVersionReconciler) SetupWithManager(mgr ctrl.Manager) error { Named("apiversion"). Complete(r) } + +func (r *ApiVersionReconciler) deleteApiVersion(ctx context.Context, apiVersion apimv1alpha1.ApiVersion) (ctrl.Result, error) { + logger := log.FromContext(ctx) + logger.Info("Deleting APIVersion") + _, err := r.apimClient.DeleteApi(ctx, apiVersion.GetApiVersionAzureFullName(), "*", nil) + if azure.IgnoreNotFound(err) != nil { + logger.Error(err, "Failed to delete APIVersion") + return ctrl.Result{}, err + } + if apiVersion.Spec.Policies != nil { + _, err = r.apimClient.DeleteApiPolicy(ctx, apiVersion.GetApiVersionAzureFullName(), "*", nil) + if azure.IgnoreNotFound(err) != nil { + logger.Error(err, "Failed to delete policy") + return ctrl.Result{}, err + } + } + controllerutil.RemoveFinalizer(&apiVersion, API_VERSION_FINALIZER) + err = r.Update(ctx, &apiVersion) + if err != nil { + logger.Error(err, "Failed to remove finalizer") + return ctrl.Result{}, err + } + return ctrl.Result{}, nil +} + +func (r *ApiVersionReconciler) handleApiVersionUpdate(ctx context.Context, apiVersion apimv1alpha1.ApiVersion) (ctrl.Result, error) { + latestSha, err := utils.Sha256FromContent(ctx, apiVersion.Spec.Content) + if err != nil { + return ctrl.Result{}, fmt.Errorf("failed to get api spec sha: %w", err) + } + if apiVersion.Status.LastAppliedSpecSha != latestSha { + return r.createUpdateApimApi(ctx, apiVersion) + } + if apiVersion.Spec.Policies != nil { + _, policyErr := r.apimClient.GetApiPolicy(ctx, apiVersion.GetApiVersionAzureFullName(), nil) + if azure.IgnoreNotFound(policyErr) != nil { + return ctrl.Result{}, policyErr + } + lastPolicySha, shaErr := utils.Sha256FromContent(ctx, apiVersion.Spec.Policies.PolicyContent) + if shaErr != nil { + return ctrl.Result{}, fmt.Errorf("failed to get policy sha: %w", shaErr) + } + if apiVersion.Status.LastAppliedPolicySha != lastPolicySha || azure.IsNotFoundError(policyErr) { + if err := r.createUpdatePolicy(ctx, apiVersion); err != nil { + return ctrl.Result{}, fmt.Errorf("failed to create/update policy: %w", err) + } + } + } + return ctrl.Result{RequeueAfter: DEFAULT_REQUE_TIME}, nil +} + +func (r *ApiVersionReconciler) createUpdateApimApi(ctx context.Context, apiVersion apimv1alpha1.ApiVersion) (ctrl.Result, error) { + logger := log.FromContext(ctx) + resumeToken := apiVersion.Status.ResumeToken + logger.Info("Creating or updating API") + apimApiParams := apiVersion.ToAzureCreateOrUpdateParameter() + poller, err := r.apimClient.CreateUpdateApi( + ctx, + apiVersion.GetApiVersionAzureFullName(), + apimApiParams, + &apim.APIClientBeginCreateOrUpdateOptions{ResumeToken: resumeToken}) + + if err != nil { + logger.Error(err, "Failed to create/update API") + apiVersion.Status.ProvisioningState = apimv1alpha1.ProvisioningStateFailed + _ = r.Status().Update(ctx, &apiVersion) + return ctrl.Result{}, err + } + logger.Info("Watching LR operation") + status, _, token, err := azure.StartResumeOperation[apim.APIClientCreateOrUpdateResponse](ctx, poller) + if err != nil { + logger.Error(err, "Failed to watch LR operation") + return ctrl.Result{}, err + } + + switch status { + case azure.OperationStatusFailed: + logger.Error(err, "Failed to watch LR operation") + apiVersion.Status.ResumeToken = "" + apiVersion.Status.ProvisioningState = apimv1alpha1.ProvisioningStateFailed + err = r.Status().Update(ctx, &apiVersion) + if err != nil { + logger.Error(err, "Failed to update status") + } + return ctrl.Result{}, err + case azure.OperationStatusInProgress: + apiVersion.Status.ProvisioningState = apimv1alpha1.ProvisioningStateUpdating + apiVersion.Status.ResumeToken = token + err = r.Status().Update(ctx, &apiVersion) + if err != nil { + logger.Error(err, "Failed to update status") + return ctrl.Result{}, err + } + return ctrl.Result{RequeueAfter: WAITING_FOR_LRO_REQUE_TIME}, nil + case azure.OperationStatusSucceeded: + logger.Info("Operation completed") + apiVersion.Status.ResumeToken = "" + apiVersion.Status.ProvisioningState = apimv1alpha1.ProvisioningStateSucceeded + apiVersion.Status.LastAppliedSpecSha, err = utils.Sha256FromContent(ctx, apiVersion.Spec.Content) + if apiVersion.Spec.Policies != nil { + apiVersion.Status.LastAppliedPolicyBase64, err = utils.Base64FromContent(ctx, apiVersion.Spec.Policies.PolicyContent) + } + if err != nil { + logger.Error(err, "Failed to get spec sha") + return ctrl.Result{}, err + } + err = r.Status().Update(ctx, &apiVersion) + if err != nil { + logger.Error(err, "Failed to update status") + return ctrl.Result{}, err + } + return ctrl.Result{RequeueAfter: DEFAULT_REQUE_TIME}, nil + default: + logger.Error(nil, "Unexpected operation status", "status", status) + return ctrl.Result{}, fmt.Errorf("unexpected operation status: %s", status) + } +} + +func (r *ApiVersionReconciler) createUpdatePolicy(ctx context.Context, apiVersion apimv1alpha1.ApiVersion) error { + logger := log.FromContext(ctx) + if apiVersion.Spec.Policies == nil { + return nil + } + logger.Info("Creating or updating policy") + policy := apiVersion.Spec.Policies + if policy.PolicyContent == nil { + return fmt.Errorf("policy content is nil") + } + policyContent, err := r.runPolicyTemplating(ctx, policy.PolicyValues, *policy.PolicyContent, apiVersion.Namespace) + if err != nil { + return fmt.Errorf("failed to run policy templating: %w", err) + } + policyFormat := policy.PolicyFormat.AzurePolicyFormat() + _, err = r.apimClient.CreateUpdateApiPolicy( + ctx, + apiVersion.GetApiVersionAzureFullName(), + apim.PolicyContract{ + Properties: &apim.PolicyContractProperties{ + Value: &policyContent, + Format: policyFormat, + }}, + nil, + ) + if err != nil { + logger.Error(err, "Failed to create/update policy") + apiVersion.Status.ProvisioningState = apimv1alpha1.ProvisioningStateFailed + _ = r.Status().Update(ctx, &apiVersion) + return err + } + apiVersion.Status.LastAppliedPolicySha, err = utils.Sha256FromContent(ctx, apiVersion.Spec.Policies.PolicyContent) + if err != nil { + logger.Error(err, "Failed to get policy sha") + return err + } + apiVersion.Status.ProvisioningState = apimv1alpha1.ProvisioningStateSucceeded + err = r.Status().Update(ctx, &apiVersion) + if err != nil { + logger.Error(err, "Failed to update status") + return err + } + return nil +} + +func (r *ApiVersionReconciler) runPolicyTemplating(ctx context.Context, values []apimv1alpha1.PolicyValue, policyContent string, apiVersionNamespace string) (string, error) { + data := make(map[string]string) + for _, v := range values { + if v.IdFromBackend != nil { + namespace := apiVersionNamespace + if v.IdFromBackend.Namespace != nil { + namespace = *v.IdFromBackend.Namespace + } + var backend apimv1alpha1.Backend + err := r.Get(ctx, client.ObjectKey{Name: v.IdFromBackend.Name, Namespace: namespace}, &backend) + if err != nil { + return "", fmt.Errorf("failed to get backend: %w", err) + } + data[v.Name] = backend.GetAzureResourceName() + continue + } + if v.Value != nil { + data[v.Name] = *v.Value + } + } + return utils.GeneratePolicyFromTemplate(policyContent, data) +} diff --git a/services/dis-apim-operator/internal/controller/apiversion_controller_test.go b/services/dis-apim-operator/internal/controller/apiversion_controller_test.go index 787e417a..24635f91 100644 --- a/services/dis-apim-operator/internal/controller/apiversion_controller_test.go +++ b/services/dis-apim-operator/internal/controller/apiversion_controller_test.go @@ -15,70 +15,3 @@ limitations under the License. */ package controller - -import ( - "context" - - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" - "k8s.io/apimachinery/pkg/api/errors" - "k8s.io/apimachinery/pkg/types" - "sigs.k8s.io/controller-runtime/pkg/reconcile" - - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - - apimv1alpha1 "github.com/Altinn/altinn-platform/services/dis-apim-operator/api/v1alpha1" -) - -var _ = Describe("ApiVersion Controller", func() { - Context("When reconciling a resource", func() { - const resourceName = "test-resource" - - ctx := context.Background() - - typeNamespacedName := types.NamespacedName{ - Name: resourceName, - Namespace: "default", // TODO(user):Modify as needed - } - apiversion := &apimv1alpha1.ApiVersion{} - - BeforeEach(func() { - By("creating the custom resource for the Kind ApiVersion") - err := k8sClient.Get(ctx, typeNamespacedName, apiversion) - if err != nil && errors.IsNotFound(err) { - resource := &apimv1alpha1.ApiVersion{ - ObjectMeta: metav1.ObjectMeta{ - Name: resourceName, - Namespace: "default", - }, - // TODO(user): Specify other spec details if needed. - } - Expect(k8sClient.Create(ctx, resource)).To(Succeed()) - } - }) - - AfterEach(func() { - // TODO(user): Cleanup logic after each test, like removing the resource instance. - resource := &apimv1alpha1.ApiVersion{} - err := k8sClient.Get(ctx, typeNamespacedName, resource) - Expect(err).NotTo(HaveOccurred()) - - By("Cleanup the specific resource instance ApiVersion") - Expect(k8sClient.Delete(ctx, resource)).To(Succeed()) - }) - It("should successfully reconcile the resource", func() { - By("Reconciling the created resource") - controllerReconciler := &ApiVersionReconciler{ - Client: k8sClient, - Scheme: k8sClient.Scheme(), - } - - _, err := controllerReconciler.Reconcile(ctx, reconcile.Request{ - NamespacedName: typeNamespacedName, - }) - Expect(err).NotTo(HaveOccurred()) - // TODO(user): Add more specific assertions depending on your controller's reconciliation logic. - // Example: If you expect a certain status condition after reconciliation, verify it here. - }) - }) -}) diff --git a/services/dis-apim-operator/internal/controller/backend_controller.go b/services/dis-apim-operator/internal/controller/backend_controller.go index 7fa13e70..9f8290a4 100644 --- a/services/dis-apim-operator/internal/controller/backend_controller.go +++ b/services/dis-apim-operator/internal/controller/backend_controller.go @@ -32,7 +32,7 @@ import ( apimv1alpha1 "github.com/Altinn/altinn-platform/services/dis-apim-operator/api/v1alpha1" ) -const BACKEND_FINALIZER = "finalizers.apim.dis.altinn.cloud/backend" +const BACKEND_FINALIZER = "backend.apim.dis.altinn.cloud/finalizer" type newApimClient func(config *azure.ApimClientConfig) (*azure.APIMClient, error) @@ -78,12 +78,14 @@ func (r *BackendReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ct return ctrl.Result{}, err } } - c, err := r.NewClient(r.ApimClientConfig) - if err != nil { - logger.Error(err, "Failed to create APIM client") - return ctrl.Result{}, err + if r.apimClient == nil { + c, err := r.NewClient(r.ApimClientConfig) + if err != nil { + logger.Error(err, "Failed to create APIM client") + return ctrl.Result{}, err + } + r.apimClient = c } - r.apimClient = c if backend.DeletionTimestamp != nil { return ctrl.Result{}, r.handleDeletion(ctx, &backend) } diff --git a/services/dis-apim-operator/internal/controller/backend_controller_test.go b/services/dis-apim-operator/internal/controller/backend_controller_test.go index 2d2420b4..bfac136d 100644 --- a/services/dis-apim-operator/internal/controller/backend_controller_test.go +++ b/services/dis-apim-operator/internal/controller/backend_controller_test.go @@ -18,26 +18,13 @@ package controller import ( "context" - "net/http" - "time" - "github.com/Altinn/altinn-platform/services/dis-apim-operator/internal/azure" - "github.com/Altinn/altinn-platform/services/dis-apim-operator/internal/config" "github.com/Altinn/altinn-platform/services/dis-apim-operator/internal/utils" - testutils "github.com/Altinn/altinn-platform/services/dis-apim-operator/test/utils" - "github.com/Azure/azure-sdk-for-go/sdk/azcore" - "github.com/Azure/azure-sdk-for-go/sdk/azcore/arm" - apim "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/apimanagement/armapimanagement/v2" - apimfake "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/apimanagement/armapimanagement/v2/fake" - . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" "k8s.io/apimachinery/pkg/api/errors" - "k8s.io/apimachinery/pkg/types" - runctimeclient "sigs.k8s.io/controller-runtime/pkg/client" - "sigs.k8s.io/controller-runtime/pkg/reconcile" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" apimv1alpha1 "github.com/Altinn/altinn-platform/services/dis-apim-operator/api/v1alpha1" ) @@ -52,437 +39,56 @@ var _ = Describe("Backend Controller", func() { Name: resourceName, Namespace: "default", // TODO(user):Modify as needed } - backend := &apimv1alpha1.Backend{} - - BeforeEach(func() { - By("creating the custom resource for the Kind Backend") - err := k8sClient.Get(ctx, typeNamespacedName, backend) - if err != nil && errors.IsNotFound(err) { - resource := &apimv1alpha1.Backend{ - ObjectMeta: metav1.ObjectMeta{ - Name: resourceName, - Namespace: "default", - }, - Spec: apimv1alpha1.BackendSpec{ - Title: "test-backend", - Description: utils.ToPointer("Test backend for the operator"), - Url: "https://test.example.com", - }, - } - Expect(k8sClient.Create(ctx, resource)).To(Succeed()) - } - }) - - AfterEach(func() { - // TODO(user): Cleanup logic after each test, like removing the resource instance. - resource := &apimv1alpha1.Backend{} - err := k8sClient.Get(ctx, typeNamespacedName, resource) - Expect(runctimeclient.IgnoreNotFound(err)).NotTo(HaveOccurred()) - if err == nil { - By("Removing finalizer") - resource.SetFinalizers([]string{}) - Expect(k8sClient.Update(ctx, resource)).To(Succeed()) - if resource.DeletionTimestamp == nil { - By("Cleanup the specific resource instance Backend") - Expect(k8sClient.Delete(ctx, resource)).To(Succeed()) - } - } - }) It("should set success status when azure resource created", func() { - fakeServer := testutils.GetFakeBackendServer( - apim.BackendClientGetResponse{}, - utils.ToPointer(http.StatusNotFound), - apim.BackendClientCreateOrUpdateResponse{ - BackendContract: apim.BackendContract{ - ID: utils.ToPointer("/subscriptions/fake-subscription/resourceGroups/fake-resource-group/providers/APIM/Backend/fake-apim-backend"), - Name: utils.ToPointer("fake-apim-backend"), - Type: utils.ToPointer("Microsoft.ApiManagement/service/backends"), - }, - }, - nil, - apim.BackendClientDeleteResponse{}, - utils.ToPointer(http.StatusOK), - ) - transport := apimfake.NewBackendServerTransport(fakeServer) - factoryClientOptions := &arm.ClientOptions{ - ClientOptions: azcore.ClientOptions{ - Transport: transport, - }, - } - By("Reconciling the created resource") - controllerReconciler := &BackendReconciler{ - Client: k8sClient, - Scheme: k8sClient.Scheme(), - NewClient: testutils.NewFakeAPIMClient, - ApimClientConfig: &azure.ApimClientConfig{ - AzureConfig: config.AzureConfig{ - SubscriptionId: "fake-subscription-id", - ResourceGroup: "fake-resource-group", - ApimServiceName: "fake-apim-service", - }, - FactoryOptions: factoryClientOptions, - }, - } - - rsp, err := controllerReconciler.Reconcile(ctx, reconcile.Request{ - NamespacedName: typeNamespacedName, - }) - Expect(err).NotTo(HaveOccurred()) - Expect(rsp).To(Equal(reconcile.Result{RequeueAfter: 1 * time.Minute})) - // Fetch the updated Backend resource - updatedBackend := &apimv1alpha1.Backend{} - err = k8sClient.Get(ctx, typeNamespacedName, updatedBackend) - Expect(err).NotTo(HaveOccurred()) - Expect(updatedBackend.Status.ProvisioningState).To(Equal(apimv1alpha1.BackendProvisioningStateSucceeded)) - Expect(updatedBackend.Status.BackendID).To(Equal("/subscriptions/fake-subscription/resourceGroups/fake-resource-group/providers/APIM/Backend/fake-apim-backend")) - // TODO(user): Add more specific assertions depending on your controller's reconciliation logic. - // Example: If you expect a certain status condition after reconciliation, verify it here. - }) - It("should set failed status when azure resource failed to create and requeue", func() { - fakeServer := testutils.GetFakeBackendServer(apim.BackendClientGetResponse{}, utils.ToPointer(http.StatusNotFound), apim.BackendClientCreateOrUpdateResponse{}, utils.ToPointer(http.StatusInternalServerError), apim.BackendClientDeleteResponse{}, utils.ToPointer(http.StatusOK)) - transport := apimfake.NewBackendServerTransport(fakeServer) - factoryClientOptions := &arm.ClientOptions{ - ClientOptions: azcore.ClientOptions{ - Transport: transport, - }, - } - By("Reconciling the created resource") - controllerReconciler := &BackendReconciler{ - Client: k8sClient, - Scheme: k8sClient.Scheme(), - NewClient: testutils.NewFakeAPIMClient, - ApimClientConfig: &azure.ApimClientConfig{ - AzureConfig: config.AzureConfig{ - SubscriptionId: "fake-subscription-id", - ResourceGroup: "fake-resource-group", - ApimServiceName: "fake-apim-service", - }, - FactoryOptions: factoryClientOptions, - }, - } - - rsp, err := controllerReconciler.Reconcile(ctx, reconcile.Request{ - NamespacedName: typeNamespacedName, - }) - Expect(err).To(HaveOccurred()) - Expect(rsp).To(Equal(reconcile.Result{})) - // Fetch the updated Backend resource - updatedBackend := &apimv1alpha1.Backend{} - err = k8sClient.Get(ctx, typeNamespacedName, updatedBackend) - Expect(err).NotTo(HaveOccurred()) - Expect(updatedBackend.Status.ProvisioningState).To(Equal(apimv1alpha1.BackendProvisioningStateFailed)) - Expect(updatedBackend.Status.LastProvisioningError).NotTo(BeEmpty()) - // TODO(user): Add more specific assertions depending on your controller's reconciliation logic. - // Example: If you expect a certain status condition after reconciliation, verify it here. - }) - It("Should set status to succeeded if get returns backend", func() { - fakeServer := testutils.GetFakeBackendServer( - apim.BackendClientGetResponse{ - BackendContract: apim.BackendContract{ - Properties: &apim.BackendContractProperties{ - Protocol: utils.ToPointer(apim.BackendProtocolHTTP), - URL: utils.ToPointer("https://test.example.com"), - Description: utils.ToPointer("Test backend for the operator"), - TLS: &apim.BackendTLSProperties{ - ValidateCertificateChain: utils.ToPointer(true), - ValidateCertificateName: utils.ToPointer(true), - }, - Title: utils.ToPointer("test-backend"), - }, - ID: utils.ToPointer("/subscriptions/fake-subscription/resourceGroups/fake-resource-group/providers/APIM/Backend/fake-apim-backend"), - }, - }, - nil, - apim.BackendClientCreateOrUpdateResponse{}, - utils.ToPointer(http.StatusInternalServerError), - apim.BackendClientDeleteResponse{}, - utils.ToPointer(http.StatusOK), - ) - transport := apimfake.NewBackendServerTransport(fakeServer) - factoryClientOptions := &arm.ClientOptions{ - ClientOptions: azcore.ClientOptions{ - Transport: transport, - }, - } - By("Reconciling the created resource") - controllerReconciler := &BackendReconciler{ - Client: k8sClient, - Scheme: k8sClient.Scheme(), - NewClient: testutils.NewFakeAPIMClient, - ApimClientConfig: &azure.ApimClientConfig{ - AzureConfig: config.AzureConfig{ - SubscriptionId: "fake-subscription-id", - ResourceGroup: "fake-resource-group", - ApimServiceName: "fake-apim-service", - }, - FactoryOptions: factoryClientOptions, - }, - } - - rsp, err := controllerReconciler.Reconcile(ctx, reconcile.Request{ - NamespacedName: typeNamespacedName, - }) - Expect(err).NotTo(HaveOccurred()) - Expect(rsp).To(Equal(reconcile.Result{RequeueAfter: 1 * time.Minute})) - // Fetch the updated Backend resource - updatedBackend := &apimv1alpha1.Backend{} - err = k8sClient.Get(ctx, typeNamespacedName, updatedBackend) - Expect(err).NotTo(HaveOccurred()) - Expect(updatedBackend.Status.ProvisioningState).To(Equal(apimv1alpha1.BackendProvisioningStateSucceeded)) - Expect(updatedBackend.Status.LastProvisioningError).To(BeEmpty()) - Expect(updatedBackend.Status.BackendID).To(Equal("/subscriptions/fake-subscription/resourceGroups/fake-resource-group/providers/APIM/Backend/fake-apim-backend")) - // TODO(user): Add more specific assertions depending on your controller's reconciliation logic. - // Example: If you expect a certain status condition after reconciliation, verify it here. - }) - It("Should update azure resource if actual state does not match desired", func() { - fakeServer := testutils.GetFakeBackendServer( - apim.BackendClientGetResponse{ - BackendContract: apim.BackendContract{ - Properties: &apim.BackendContractProperties{ - Protocol: utils.ToPointer(apim.BackendProtocolHTTP), - URL: utils.ToPointer("https://example.com"), - Description: utils.ToPointer("Test backend for the operator"), - TLS: &apim.BackendTLSProperties{ - ValidateCertificateChain: utils.ToPointer(true), - ValidateCertificateName: utils.ToPointer(true), - }, - Title: utils.ToPointer("test-backend"), - }, - ID: utils.ToPointer("/subscriptions/fake-subscription/resourceGroups/fake-resource-group/providers/APIM/Backend/fake-apim-backend"), - }, - }, - nil, - apim.BackendClientCreateOrUpdateResponse{ - BackendContract: apim.BackendContract{ - Properties: &apim.BackendContractProperties{ - Protocol: utils.ToPointer(apim.BackendProtocolHTTP), - URL: utils.ToPointer("https://example.com"), - Description: utils.ToPointer("Test backend for the operator"), - TLS: &apim.BackendTLSProperties{ - ValidateCertificateChain: utils.ToPointer(true), - ValidateCertificateName: utils.ToPointer(true), - }, - Title: utils.ToPointer("test-backend"), - }, - ID: utils.ToPointer("/subscriptions/fake-subscription/resourceGroups/fake-resource-group/providers/APIM/Backend/fake-apim-backend-updated"), - }, + resource := &apimv1alpha1.Backend{ + ObjectMeta: metav1.ObjectMeta{ + Name: resourceName, + Namespace: "default", }, - nil, - apim.BackendClientDeleteResponse{}, - nil, - ) - transport := apimfake.NewBackendServerTransport(fakeServer) - factoryClientOptions := &arm.ClientOptions{ - ClientOptions: azcore.ClientOptions{ - Transport: transport, + Spec: apimv1alpha1.BackendSpec{ + Title: "test-backend", + Description: utils.ToPointer("Test backend for the operator"), + Url: "https://test.example.com", }, } - By("Reconciling the created resource") - controllerReconciler := &BackendReconciler{ - Client: k8sClient, - Scheme: k8sClient.Scheme(), - NewClient: testutils.NewFakeAPIMClient, - ApimClientConfig: &azure.ApimClientConfig{ - AzureConfig: config.AzureConfig{ - SubscriptionId: "fake-subscription-id", - ResourceGroup: "fake-resource-group", - ApimServiceName: "fake-apim-service", - }, - FactoryOptions: factoryClientOptions, - }, - } - - rsp, err := controllerReconciler.Reconcile(ctx, reconcile.Request{ - NamespacedName: typeNamespacedName, - }) - Expect(err).NotTo(HaveOccurred()) - Expect(rsp).To(Equal(reconcile.Result{RequeueAfter: 1 * time.Minute})) - // Fetch the updated Backend resource - updatedBackend := &apimv1alpha1.Backend{} - err = k8sClient.Get(ctx, typeNamespacedName, updatedBackend) - Expect(err).NotTo(HaveOccurred()) - Expect(updatedBackend.Status.ProvisioningState).To(Equal(apimv1alpha1.BackendProvisioningStateSucceeded)) - Expect(updatedBackend.Status.LastProvisioningError).To(BeEmpty()) - Expect(updatedBackend.Status.BackendID).To(Equal("/subscriptions/fake-subscription/resourceGroups/fake-resource-group/providers/APIM/Backend/fake-apim-backend-updated")) - // TODO(user): Add more specific assertions depending on your controller's reconciliation logic. - // Example: If you expect a certain status condition after reconciliation, verify it here. - }) - It("Should delete azure resources and remove finalizer on deletion", func() { - beforeTest := &apimv1alpha1.Backend{} - err := k8sClient.Get(ctx, typeNamespacedName, beforeTest) - Expect(err).NotTo(HaveOccurred()) - beforeTest.SetFinalizers([]string{BACKEND_FINALIZER}) - Expect(k8sClient.Update(ctx, beforeTest)).To(Succeed()) - Expect(k8sClient.Delete(ctx, beforeTest)).To(Succeed()) - fakeServer := testutils.GetFakeBackendServer( - apim.BackendClientGetResponse{ - ETag: utils.ToPointer("fake-etag"), - BackendContract: apim.BackendContract{ - Properties: &apim.BackendContractProperties{ - Protocol: utils.ToPointer(apim.BackendProtocolHTTP), - URL: utils.ToPointer("https://test.example.com"), - Description: utils.ToPointer("Test backend for the operator"), - TLS: &apim.BackendTLSProperties{ - ValidateCertificateChain: utils.ToPointer(true), - ValidateCertificateName: utils.ToPointer(true), - }, - Title: utils.ToPointer("test-backend"), - }, - ID: utils.ToPointer("/subscriptions/fake-subscription/resourceGroups/fake-resource-group/providers/APIM/Backend/fake-apim-backend"), - }, - }, - nil, - apim.BackendClientCreateOrUpdateResponse{}, - utils.ToPointer(http.StatusInternalServerError), - apim.BackendClientDeleteResponse{}, - nil, - ) - transport := apimfake.NewBackendServerTransport(fakeServer) - factoryClientOptions := &arm.ClientOptions{ - ClientOptions: azcore.ClientOptions{ - Transport: transport, - }, - } - By("Reconciling the created resource") - controllerReconciler := &BackendReconciler{ - Client: k8sClient, - Scheme: k8sClient.Scheme(), - NewClient: testutils.NewFakeAPIMClient, - ApimClientConfig: &azure.ApimClientConfig{ - AzureConfig: config.AzureConfig{ - SubscriptionId: "fake-subscription-id", - ResourceGroup: "fake-resource-group", - ApimServiceName: "fake-apim-service", - }, - FactoryOptions: factoryClientOptions, - }, - } - - rsp, err := controllerReconciler.Reconcile(ctx, reconcile.Request{ - NamespacedName: typeNamespacedName, - }) - Expect(err).NotTo(HaveOccurred()) - Expect(rsp).To(Equal(reconcile.Result{})) + Expect(k8sClient.Create(ctx, resource)).To(Succeed()) // Fetch the updated Backend resource updatedBackend := &apimv1alpha1.Backend{} - err = k8sClient.Get(ctx, typeNamespacedName, updatedBackend) - Expect(err).To(HaveOccurred()) - Expect(errors.IsNotFound(err)).To(BeTrue()) + Eventually(func(g Gomega) { + err := k8sClient.Get(ctx, typeNamespacedName, updatedBackend) + g.Expect(err).NotTo(HaveOccurred()) + g.Expect(updatedBackend.Status.ProvisioningState).To(Equal(apimv1alpha1.BackendProvisioningStateSucceeded)) + g.Expect(updatedBackend.Status.BackendID).To(Equal("/subscriptions/fake-subscription/resourceGroups/fake-resource-group/providers/APIM/Backend/" + updatedBackend.GetAzureResourceName())) + g.Expect(updatedBackend.Status.ProvisioningState).To(Equal(apimv1alpha1.BackendProvisioningStateSucceeded)) + g.Expect(fakeApim.Backends).To(HaveLen(1)) + }, timeout, interval).Should(Succeed()) // TODO(user): Add more specific assertions depending on your controller's reconciliation logic. - // Example: If you expect a certain status condition after reconciliation, verify it here. - }) - It("Should requeue deletion if delete of backend in azure failed", func() { - beforeTest := &apimv1alpha1.Backend{} - err := k8sClient.Get(ctx, typeNamespacedName, beforeTest) - Expect(err).NotTo(HaveOccurred()) - beforeTest.SetFinalizers([]string{BACKEND_FINALIZER}) - Expect(k8sClient.Update(ctx, beforeTest)).To(Succeed()) - Expect(k8sClient.Delete(ctx, beforeTest)).To(Succeed()) - fakeServer := testutils.GetFakeBackendServer( - apim.BackendClientGetResponse{ - ETag: utils.ToPointer("fake-etag"), - BackendContract: apim.BackendContract{ - Properties: &apim.BackendContractProperties{ - Protocol: utils.ToPointer(apim.BackendProtocolHTTP), - URL: utils.ToPointer("https://test.example.com"), - Description: utils.ToPointer("Test backend for the operator"), - TLS: &apim.BackendTLSProperties{ - ValidateCertificateChain: utils.ToPointer(true), - ValidateCertificateName: utils.ToPointer(true), - }, - Title: utils.ToPointer("test-backend"), - }, - ID: utils.ToPointer("/subscriptions/fake-subscription/resourceGroups/fake-resource-group/providers/APIM/Backend/fake-apim-backend"), - }, - }, - nil, - apim.BackendClientCreateOrUpdateResponse{}, - utils.ToPointer(http.StatusInternalServerError), - apim.BackendClientDeleteResponse{}, - utils.ToPointer(http.StatusInternalServerError), - ) - transport := apimfake.NewBackendServerTransport(fakeServer) - factoryClientOptions := &arm.ClientOptions{ - ClientOptions: azcore.ClientOptions{ - Transport: transport, - }, - } - By("Reconciling the created resource") - controllerReconciler := &BackendReconciler{ - Client: k8sClient, - Scheme: k8sClient.Scheme(), - NewClient: testutils.NewFakeAPIMClient, - ApimClientConfig: &azure.ApimClientConfig{ - AzureConfig: config.AzureConfig{ - SubscriptionId: "fake-subscription-id", - ResourceGroup: "fake-resource-group", - ApimServiceName: "fake-apim-service", - }, - FactoryOptions: factoryClientOptions, - }, - } - - rsp, err := controllerReconciler.Reconcile(ctx, reconcile.Request{ - NamespacedName: typeNamespacedName, - }) - Expect(err).To(HaveOccurred()) - Expect(rsp).To(Equal(reconcile.Result{})) + By("Updating the apim Backend if it does not match the desired state") + Eventually(func(g Gomega) { + updatedBackend.Spec.Url = "https://updated.example.com" + err := k8sClient.Update(ctx, updatedBackend) + Expect(err).NotTo(HaveOccurred()) + }, timeout, interval).Should(Succeed()) // Fetch the updated Backend resource - updatedBackend := &apimv1alpha1.Backend{} - err = k8sClient.Get(ctx, typeNamespacedName, updatedBackend) - Expect(err).ToNot(HaveOccurred()) - Expect(updatedBackend.Finalizers).To(HaveExactElements([]string{BACKEND_FINALIZER})) - // TODO(user): Add more specific assertions depending on your controller's reconciliation logic. - // Example: If you expect a certain status condition after reconciliation, verify it here. - }) - It("Should remove finalizer on deletion when azure backend not found", func() { - beforeTest := &apimv1alpha1.Backend{} - err := k8sClient.Get(ctx, typeNamespacedName, beforeTest) - Expect(err).NotTo(HaveOccurred()) - beforeTest.SetFinalizers([]string{BACKEND_FINALIZER}) - Expect(k8sClient.Update(ctx, beforeTest)).To(Succeed()) - Expect(k8sClient.Delete(ctx, beforeTest)).To(Succeed()) - fakeServer := testutils.GetFakeBackendServer( - apim.BackendClientGetResponse{}, - utils.ToPointer(http.StatusNotFound), - apim.BackendClientCreateOrUpdateResponse{}, - utils.ToPointer(http.StatusInternalServerError), - apim.BackendClientDeleteResponse{}, - utils.ToPointer(http.StatusInternalServerError), - ) - transport := apimfake.NewBackendServerTransport(fakeServer) - factoryClientOptions := &arm.ClientOptions{ - ClientOptions: azcore.ClientOptions{ - Transport: transport, - }, - } - By("Reconciling the created resource") - controllerReconciler := &BackendReconciler{ - Client: k8sClient, - Scheme: k8sClient.Scheme(), - NewClient: testutils.NewFakeAPIMClient, - ApimClientConfig: &azure.ApimClientConfig{ - AzureConfig: config.AzureConfig{ - SubscriptionId: "fake-subscription-id", - ResourceGroup: "fake-resource-group", - ApimServiceName: "fake-apim-service", - }, - FactoryOptions: factoryClientOptions, - }, - } - - rsp, err := controllerReconciler.Reconcile(ctx, reconcile.Request{ - NamespacedName: typeNamespacedName, - }) - Expect(err).NotTo(HaveOccurred()) - Expect(rsp).To(Equal(reconcile.Result{})) + Eventually(func(g Gomega) { + err := k8sClient.Get(ctx, typeNamespacedName, updatedBackend) + g.Expect(err).NotTo(HaveOccurred()) + g.Expect(updatedBackend.Status.ProvisioningState).To(Equal(apimv1alpha1.BackendProvisioningStateSucceeded)) + g.Expect(updatedBackend.Status.BackendID).To(Equal("/subscriptions/fake-subscription/resourceGroups/fake-resource-group/providers/APIM/Backend/" + updatedBackend.GetAzureResourceName())) + g.Expect(fakeApim.Backends).To(HaveLen(1)) + g.Expect(fakeApim.Backends).To(HaveKey(updatedBackend.GetAzureResourceName())) + g.Expect(*fakeApim.Backends[updatedBackend.GetAzureResourceName()].Properties.URL).To(Equal(updatedBackend.Spec.Url)) + }, timeout, interval).Should(Succeed()) + By("Deleting the apim Backend and removing the finalizer when the resource is deleted") + Eventually(k8sClient.Delete).WithArguments(ctx, updatedBackend).Should(Succeed()) // Fetch the updated Backend resource - updatedBackend := &apimv1alpha1.Backend{} - err = k8sClient.Get(ctx, typeNamespacedName, updatedBackend) - Expect(err).To(HaveOccurred()) - Expect(errors.IsNotFound(err)).To(BeTrue()) - // TODO(user): Add more specific assertions depending on your controller's reconciliation logic. + Eventually(func(g Gomega) { + err := k8sClient.Get(ctx, typeNamespacedName, updatedBackend) + g.Expect(err).To(HaveOccurred()) + g.Expect(errors.IsNotFound(err)).To(BeTrue()) + g.Expect(fakeApim.Backends).To(BeEmpty()) + }, timeout, interval).Should(Succeed()) // Example: If you expect a certain status condition after reconciliation, verify it here. }) }) diff --git a/services/dis-apim-operator/internal/controller/suite_test.go b/services/dis-apim-operator/internal/controller/suite_test.go index 4b4aa822..523a4339 100644 --- a/services/dis-apim-operator/internal/controller/suite_test.go +++ b/services/dis-apim-operator/internal/controller/suite_test.go @@ -23,15 +23,22 @@ import ( "runtime" "testing" + "github.com/Altinn/altinn-platform/services/dis-apim-operator/internal/azure" + "github.com/Altinn/altinn-platform/services/dis-apim-operator/internal/config" + testutils "github.com/Altinn/altinn-platform/services/dis-apim-operator/test/utils" + "github.com/Azure/azure-sdk-for-go/sdk/azcore" + "github.com/Azure/azure-sdk-for-go/sdk/azcore/arm" + apimfake "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/apimanagement/armapimanagement/v2/fake" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" - "k8s.io/client-go/kubernetes/scheme" "k8s.io/client-go/rest" + ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/envtest" logf "sigs.k8s.io/controller-runtime/pkg/log" "sigs.k8s.io/controller-runtime/pkg/log/zap" + "sigs.k8s.io/controller-runtime/pkg/manager" apimv1alpha1 "github.com/Altinn/altinn-platform/services/dis-apim-operator/api/v1alpha1" // +kubebuilder:scaffold:imports @@ -42,9 +49,11 @@ import ( var cfg *rest.Config var k8sClient client.Client +var k8sManager manager.Manager var testEnv *envtest.Environment var ctx context.Context var cancel context.CancelFunc +var fakeApim testutils.AzureApimFake func TestControllers(t *testing.T) { RegisterFailHandler(Fail) @@ -80,12 +89,64 @@ var _ = BeforeSuite(func() { err = apimv1alpha1.AddToScheme(scheme.Scheme) Expect(err).NotTo(HaveOccurred()) + fakeApim = testutils.NewFakeAPIMClientStruct() // +kubebuilder:scaffold:scheme - k8sClient, err = client.New(cfg, client.Options{Scheme: scheme.Scheme}) Expect(err).NotTo(HaveOccurred()) Expect(k8sClient).NotTo(BeNil()) - + k8sManager, err = ctrl.NewManager(cfg, ctrl.Options{ + Scheme: scheme.Scheme, + }) + Expect(err).NotTo(HaveOccurred()) + Expect(k8sManager).NotTo(BeNil()) + + serverFactoryTransport := apimfake.NewServerFactoryTransport(&apimfake.ServerFactory{ + APIVersionSetServer: fakeApim.FakeApiVersionServer, + APIServer: fakeApim.FakeApiServer, + BackendServer: fakeApim.FakeBackendServer, + }) + + apimClientConfig := &azure.ApimClientConfig{ + AzureConfig: config.AzureConfig{ + SubscriptionId: "fake-subscription-id", + ResourceGroup: "fake-resource-group", + ApimServiceName: "fake-apim-service", + }, + FactoryOptions: &arm.ClientOptions{ + ClientOptions: azcore.ClientOptions{ + Transport: serverFactoryTransport, + }, + }, + } + err = (&ApiVersionReconciler{ + Client: k8sManager.GetClient(), + Scheme: k8sManager.GetScheme(), + NewClient: testutils.NewFakeAPIMClient, + ApimClientConfig: apimClientConfig, + }).SetupWithManager(k8sManager) + Expect(err).ToNot(HaveOccurred()) + + err = (&BackendReconciler{ + Client: k8sManager.GetClient(), + Scheme: k8sManager.GetScheme(), + NewClient: testutils.NewFakeAPIMClient, + ApimClientConfig: apimClientConfig, + }).SetupWithManager(k8sManager) + Expect(err).ToNot(HaveOccurred()) + + err = (&ApiReconciler{ + Client: k8sManager.GetClient(), + Scheme: k8sManager.GetScheme(), + NewClient: testutils.NewFakeAPIMClient, + ApimClientConfig: apimClientConfig, + }).SetupWithManager(k8sManager) + Expect(err).ToNot(HaveOccurred()) + + go func() { + defer GinkgoRecover() + err = k8sManager.Start(ctx) + Expect(err).ToNot(HaveOccurred(), "failed to run manager") + }() }) var _ = AfterSuite(func() { diff --git a/services/dis-apim-operator/internal/utils/base64.go b/services/dis-apim-operator/internal/utils/base64.go new file mode 100644 index 00000000..b5d90f44 --- /dev/null +++ b/services/dis-apim-operator/internal/utils/base64.go @@ -0,0 +1,33 @@ +package utils + +import ( + "context" + "encoding/base64" + "io" +) + +// Base64FromUrlContent returns the base64 encoding of the content at the given URL. +func base64FromUrlContent(ctx context.Context, url string) (string, error) { + resp, err := getContentUrl(ctx, url) + if err != nil { + return "", err + } + defer closeIgnoreError(resp.Body) + + b, err := io.ReadAll(resp.Body) + if err != nil { + return "", err + } + return base64.StdEncoding.EncodeToString(b), nil +} + +// Base64FromContent returns the base64 encoding of the given content. If the content is a URL, it will fetch the content and return the base64 encoding. +func Base64FromContent(ctx context.Context, content *string) (string, error) { + if content == nil { + return "", nil + } + if isUrl(*content) { + return base64FromUrlContent(ctx, *content) + } + return base64.StdEncoding.EncodeToString([]byte(*content)), nil +} diff --git a/services/dis-apim-operator/internal/utils/base64_test.go b/services/dis-apim-operator/internal/utils/base64_test.go new file mode 100644 index 00000000..6dc4d891 --- /dev/null +++ b/services/dis-apim-operator/internal/utils/base64_test.go @@ -0,0 +1,60 @@ +package utils + +import ( + "context" + "fmt" + "net/http" + "net/http/httptest" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Base64FromContent", func() { + Context("with a valid URL", func() { + It("should return the correct Base64 string", func() { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = fmt.Fprintln(w, "test content") + })) + defer server.Close() + expectedHash := "dGVzdCBjb250ZW50Cg==" + hash, err := Base64FromContent(context.Background(), &server.URL) + Expect(err).NotTo(HaveOccurred()) + Expect(hash).To(BeEquivalentTo(expectedHash)) + }) + }) + + Context("with a valid content string", func() { + It("should return the correct Base64 string", func() { + content := "test content" + expectedHash := "dGVzdCBjb250ZW50" + hash, err := Base64FromContent(context.Background(), &content) + Expect(err).NotTo(HaveOccurred()) + Expect(hash).To(BeEquivalentTo(expectedHash)) + }) + }) + + Context("handle nil and empty string", func() { + It("should return empty string when nil", func() { + expectedHash := "" + hash, err := Base64FromContent(context.Background(), nil) + Expect(err).NotTo(HaveOccurred()) + Expect(hash).To(BeEquivalentTo(expectedHash)) + }) + It("should return empty string when empty string content", func() { + expectedHash := "" + emptyString := "" + hash, err := Base64FromContent(context.Background(), &emptyString) + Expect(err).NotTo(HaveOccurred()) + Expect(hash).To(BeEquivalentTo(expectedHash)) + }) + }) + + Context("with an invalid URL", func() { + It("should return an error", func() { + invalidUrl := "http://invalid-url" + _, err := Base64FromContent(context.Background(), &invalidUrl) + Expect(err).To(HaveOccurred()) + }) + }) +}) diff --git a/services/dis-apim-operator/internal/utils/consts.go b/services/dis-apim-operator/internal/utils/consts.go new file mode 100644 index 00000000..fb5f7bd1 --- /dev/null +++ b/services/dis-apim-operator/internal/utils/consts.go @@ -0,0 +1,4 @@ +package utils + +// maxContentSize is the maximum allowed size of the content fetched from a URL. (100MB) +const maxContentSize = 100 * 1024 * 1024 diff --git a/services/dis-apim-operator/internal/utils/policytemplate.go b/services/dis-apim-operator/internal/utils/policytemplate.go new file mode 100644 index 00000000..8dfe9edf --- /dev/null +++ b/services/dis-apim-operator/internal/utils/policytemplate.go @@ -0,0 +1,23 @@ +package utils + +import ( + "strings" + "text/template" +) + +// GeneratePolicyFromTemplate generates a policy from a template +func GeneratePolicyFromTemplate(templateContent string, data interface{}) (string, error) { + tmpl, err := template.New("policy").Parse(templateContent) + if err != nil { + return "", err + } + + var sb strings.Builder + tmpl.Option("missingkey=error") + err = tmpl.Execute(&sb, data) + if err != nil { + return "", err + } + + return sb.String(), nil +} diff --git a/services/dis-apim-operator/internal/utils/policytemplate_test.go b/services/dis-apim-operator/internal/utils/policytemplate_test.go new file mode 100644 index 00000000..0cb846d9 --- /dev/null +++ b/services/dis-apim-operator/internal/utils/policytemplate_test.go @@ -0,0 +1,77 @@ +package utils + +import ( + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +const ValidTemplate = "Hello, {{.Name}}!" + +var _ = Describe("GeneratePolicyFromTemplate", func() { + + Context("with valid template and data", func() { + + It("should generate the correct policy", func() { + expected := "Hello, World!" + templateContent := ValidTemplate + data := map[string]string{"Name": "World"} + result, err := GeneratePolicyFromTemplate(templateContent, data) + Expect(err).NotTo(HaveOccurred()) + Expect(result).To(Equal(expected)) + }) + }) + + Context("handles quotes", func() { + It("should generate the correct policy", func() { + expected := `Hello, "World"!` + templateContent := `Hello, "{{.Name}}"!` + data := map[string]string{"Name": "World"} + result, err := GeneratePolicyFromTemplate(templateContent, data) + Expect(err).NotTo(HaveOccurred()) + Expect(result).To(Equal(expected)) + }) + }) + + Context("with template missing data", func() { + It("should return an error", func() { + expected := "" + templateContent := ValidTemplate + data := map[string]string{} + result, err := GeneratePolicyFromTemplate(templateContent, data) + Expect(err).To(HaveOccurred()) + Expect(result).To(Equal(expected)) + }) + }) + + Context("with template data nil", func() { + It("should return an error", func() { + expected := "" + templateContent := ValidTemplate + result, err := GeneratePolicyFromTemplate(templateContent, nil) + Expect(err).To(HaveOccurred()) + Expect(result).To(Equal(expected)) + }) + }) + + Context("with invalid template syntax", func() { + It("should return an error", func() { + expected := "" + templateContent := "Hello, {{.Name" + data := map[string]string{"Name": "World"} + result, err := GeneratePolicyFromTemplate(templateContent, data) + Expect(err).To(HaveOccurred()) + Expect(result).To(Equal(expected)) + }) + }) + + Context("with empty template content", func() { + It("should return empty string", func() { + expected := "" + templateContent := "" + data := map[string]string{"Name": "World"} + result, err := GeneratePolicyFromTemplate(templateContent, data) + Expect(err).NotTo(HaveOccurred()) + Expect(result).To(Equal(expected)) + }) + }) +}) diff --git a/services/dis-apim-operator/internal/utils/sha.go b/services/dis-apim-operator/internal/utils/sha.go new file mode 100644 index 00000000..9def4e6c --- /dev/null +++ b/services/dis-apim-operator/internal/utils/sha.go @@ -0,0 +1,37 @@ +package utils + +import ( + "context" + "crypto/sha256" + "fmt" + "io" +) + +// Sha256FromUrlContent returns the SHA256 hash of the content at the given URL. +func sha256FromUrlContent(ctx context.Context, url string) (string, error) { + resp, err := getContentUrl(ctx, url) + if err != nil { + return "", err + } + defer closeIgnoreError(resp.Body) + + h := sha256.New() + if _, err := io.Copy(h, resp.Body); err != nil { + return "", err + } + return fmt.Sprintf("%x", h.Sum(nil)), nil +} + +// Sha256FromContent returns the SHA256 hash of the given content. If the content is a URL, it will fetch the content and return the SHA256 hash. +func Sha256FromContent(ctx context.Context, content *string) (string, error) { + if content == nil { + return "", nil + } + if isUrl(*content) { + + return sha256FromUrlContent(ctx, *content) + } + h := sha256.New() + h.Write([]byte(*content)) + return fmt.Sprintf("%x", h.Sum(nil)), nil +} diff --git a/services/dis-apim-operator/internal/utils/sha_test.go b/services/dis-apim-operator/internal/utils/sha_test.go new file mode 100644 index 00000000..16b310be --- /dev/null +++ b/services/dis-apim-operator/internal/utils/sha_test.go @@ -0,0 +1,60 @@ +package utils + +import ( + "context" + "fmt" + "net/http" + "net/http/httptest" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Sha256FromContent", func() { + Context("with a valid URL", func() { + It("should return the correct SHA256 hash", func() { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = fmt.Fprint(w, "test content") + })) + defer server.Close() + expectedHash := "6ae8a75555209fd6c44157c0aed8016e763ff435a19cf186f76863140143ff72" + hash, err := Sha256FromContent(context.Background(), &server.URL) + Expect(err).NotTo(HaveOccurred()) + Expect(hash).To(BeEquivalentTo(expectedHash)) + }) + }) + + Context("with a valid content string", func() { + It("should return the correct SHA256 hash", func() { + content := "test content" + expectedHash := "6ae8a75555209fd6c44157c0aed8016e763ff435a19cf186f76863140143ff72" + hash, err := Sha256FromContent(context.Background(), &content) + Expect(err).NotTo(HaveOccurred()) + Expect(hash).To(BeEquivalentTo(expectedHash)) + }) + }) + + Context("handle nil and empty string", func() { + It("should return empty string when nil", func() { + expectedHash := "" + hash, err := Sha256FromContent(context.Background(), nil) + Expect(err).NotTo(HaveOccurred()) + Expect(hash).To(BeEquivalentTo(expectedHash)) + }) + It("should return correct SHA256 when empty string content", func() { + expectedHash := "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + emptyString := "" + hash, err := Sha256FromContent(context.Background(), &emptyString) + Expect(err).NotTo(HaveOccurred()) + Expect(hash).To(BeEquivalentTo(expectedHash)) + }) + }) + + Context("with an invalid URL", func() { + It("should return an error", func() { + invalidUrl := "http://invalid-url" + _, err := Sha256FromContent(context.Background(), &invalidUrl) + Expect(err).To(HaveOccurred()) + }) + }) +}) diff --git a/services/dis-apim-operator/internal/utils/utils.go b/services/dis-apim-operator/internal/utils/utils.go new file mode 100644 index 00000000..a8fadfce --- /dev/null +++ b/services/dis-apim-operator/internal/utils/utils.go @@ -0,0 +1,50 @@ +package utils + +import ( + "context" + "crypto/tls" + "fmt" + "io" + "net/http" + "net/url" + "time" +) + +var httpClient = &http.Client{ + Timeout: 30 * time.Second, + Transport: &http.Transport{ + TLSClientConfig: &tls.Config{MinVersion: tls.VersionTLS12}, + }, +} + +func isUrl(s string) bool { + u, err := url.Parse(s) + if err != nil { + return false + } + return (u.Scheme == "http" || u.Scheme == "https") && len(u.Host) > 0 +} + +func getContentUrl(ctx context.Context, url string) (*http.Response, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, err + } + resp, err := httpClient.Do(req) + if err != nil { + return nil, err + } + if resp.StatusCode != http.StatusOK { + defer closeIgnoreError(resp.Body) + return nil, fmt.Errorf("unexpected status code %d", resp.StatusCode) + } + if resp.ContentLength > maxContentSize { + defer closeIgnoreError(resp.Body) + return nil, fmt.Errorf("content size exceeds the maximum allowed size of %d bytes, actual size %d", maxContentSize, resp.ContentLength) + } + return resp, nil +} + +func closeIgnoreError(c io.Closer) { + _ = c.Close() +} diff --git a/services/dis-apim-operator/internal/utils/utils_suite_test.go b/services/dis-apim-operator/internal/utils/utils_suite_test.go new file mode 100644 index 00000000..fe9ca5db --- /dev/null +++ b/services/dis-apim-operator/internal/utils/utils_suite_test.go @@ -0,0 +1,26 @@ +package utils + +import ( + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + logf "sigs.k8s.io/controller-runtime/pkg/log" + "sigs.k8s.io/controller-runtime/pkg/log/zap" +) + +func TestUtils(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Utils Suite") +} + +var _ = BeforeSuite(func() { + logf.SetLogger(zap.New(zap.WriteTo(GinkgoWriter), zap.UseDevMode(true))) + By("setting up the test environment") + // Add any setup steps that need to be done before the tests run +}) + +var _ = AfterSuite(func() { + By("tearing down the test environment") + // Add any teardown steps that need to be done after the tests run +}) diff --git a/services/dis-apim-operator/internal/utils/utils_test.go b/services/dis-apim-operator/internal/utils/utils_test.go new file mode 100644 index 00000000..fe72d80e --- /dev/null +++ b/services/dis-apim-operator/internal/utils/utils_test.go @@ -0,0 +1,72 @@ +package utils + +import ( + "context" + "fmt" + "io" + "net/http" + "net/http/httptest" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Utils", func() { + Context("isUrl", func() { + It("should return true for valid URLs", func() { + Expect(isUrl("http://example.com")).To(BeTrue()) + Expect(isUrl("https://example.com")).To(BeTrue()) + Expect(isUrl("http://example.com:8080")).To(BeTrue()) + }) + + It("should return false for invalid URLs", func() { + Expect(isUrl("example.com")).To(BeFalse()) + Expect(isUrl("/example/com")).To(BeFalse()) + Expect(isUrl("ftp://example.com")).To(BeFalse()) + Expect(isUrl("http://")).To(BeFalse()) + }) + }) + + Context("getContentUrl", func() { + It("should return content for a valid URL", func() { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = fmt.Fprint(w, "test content") + })) + defer server.Close() + + resp, err := getContentUrl(context.Background(), server.URL) + Expect(err).NotTo(HaveOccurred()) + defer closeIgnoreError(resp.Body) + + body, err := io.ReadAll(resp.Body) + Expect(err).NotTo(HaveOccurred()) + Expect(string(body)).To(Equal("test content")) + }) + + It("should return an error for an invalid URL", func() { + _, err := getContentUrl(context.Background(), "http://invalid-url") + Expect(err).To(HaveOccurred()) + }) + + It("should return an error for content exceeding max size", func() { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Length", fmt.Sprintf("%d", maxContentSize+1)) + _, _ = fmt.Fprintln(w, "test content") + })) + defer server.Close() + + _, err := getContentUrl(context.Background(), server.URL) + Expect(err).To(HaveOccurred()) + }) + It("should return an error for non-200 status codes", func() { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + })) + defer server.Close() + + _, err := getContentUrl(context.Background(), server.URL) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("unexpected status code 404")) + }) + }) +}) diff --git a/services/dis-apim-operator/test/utils/azure_apim_api_fake.go b/services/dis-apim-operator/test/utils/azure_apim_api_fake.go new file mode 100644 index 00000000..95f86c77 --- /dev/null +++ b/services/dis-apim-operator/test/utils/azure_apim_api_fake.go @@ -0,0 +1,283 @@ +package utils + +import ( + "context" + "net/http" + + "github.com/Altinn/altinn-platform/services/dis-apim-operator/internal/utils" + azfake "github.com/Azure/azure-sdk-for-go/sdk/azcore/fake" + apim "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/apimanagement/armapimanagement/v2" + apimfake "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/apimanagement/armapimanagement/v2/fake" +) + +type AzureApimFake struct { + APIMVersionSets map[string]apim.APIVersionSetContract + APIMVersions map[string]apim.APIContract + Backends map[string]apim.BackendContract + FakeApiServer apimfake.APIServer + FakeApiVersionServer apimfake.APIVersionSetServer + FakeBackendServer apimfake.BackendServer + createUpdateServerError bool + getServerError bool + deleteServerError bool +} + +type SimpleApimApiVersionSet struct { + ApiVersionSetId string + ApiVersionSetName string +} + +type SimpleApimApiVersion struct { + ApiVersionId string + ApiVersionName string + ApiContent string +} + +type SimpleApimBackend struct { + BackendId string + BackendName string + BackendURL string +} + +// NewFakeAPIMClient creates a new APIMClient +func NewFakeAPIMClientStruct() AzureApimFake { + aaf := AzureApimFake{ + APIMVersionSets: map[string]apim.APIVersionSetContract{}, + APIMVersions: map[string]apim.APIContract{}, + Backends: map[string]apim.BackendContract{}, + createUpdateServerError: false, + deleteServerError: false, + getServerError: false, + } + aaf.FakeApiServer = aaf.GetFakeApiServer() + aaf.FakeApiVersionServer = aaf.GetFakeApiVersionServer() + aaf.FakeBackendServer = aaf.GetFakeBackendServer() + return aaf +} + +func (a *AzureApimFake) GetFakeBackendServer() apimfake.BackendServer { + fakeServer := apimfake.BackendServer{ + CreateOrUpdate: func( + ctx context.Context, + resourceGroupName string, + serviceName string, + backendID string, + parameters apim.BackendContract, + options *apim.BackendClientCreateOrUpdateOptions, + ) (azfake.Responder[apim.BackendClientCreateOrUpdateResponse], azfake.ErrorResponder) { + responder := azfake.Responder[apim.BackendClientCreateOrUpdateResponse]{} + errResponder := azfake.ErrorResponder{} + if a.createUpdateServerError { + errResponder.SetResponseError(http.StatusInternalServerError, "Some fake internal server error occurred") + } else { + response := apim.BackendClientCreateOrUpdateResponse{ + BackendContract: apim.BackendContract{ + ID: utils.ToPointer("/subscriptions/fake-subscription/resourceGroups/fake-resource-group/providers/APIM/Backend/" + backendID), + Name: utils.ToPointer(backendID), + Type: utils.ToPointer("Microsoft.ApiManagement/service/backends"), + Properties: parameters.Properties, + }, + } + a.Backends[*response.Name] = response.BackendContract + responder.SetResponse(http.StatusOK, response, nil) + } + return responder, errResponder + }, + Delete: func( + ctx context.Context, + resourceGroupName string, + serviceName string, + backendID string, + ifMatch string, + options *apim.BackendClientDeleteOptions, + ) (azfake.Responder[apim.BackendClientDeleteResponse], azfake.ErrorResponder) { + responder := azfake.Responder[apim.BackendClientDeleteResponse]{} + errResponder := azfake.ErrorResponder{} + if a.deleteServerError { + errResponder.SetResponseError(http.StatusInternalServerError, "Some fake internal server error occurred") + } else { + response := apim.BackendClientDeleteResponse{} + if _, ok := a.Backends[backendID]; ok { + delete(a.Backends, backendID) + responder.SetResponse(http.StatusOK, response, nil) + } else { + errResponder.SetResponseError(http.StatusNotFound, "Backend not found") + } + } + return responder, errResponder + }, + Get: func( + ctx context.Context, + resourceGroupName string, + serviceName string, + backendID string, + options *apim.BackendClientGetOptions, + ) (azfake.Responder[apim.BackendClientGetResponse], azfake.ErrorResponder) { + responder := azfake.Responder[apim.BackendClientGetResponse]{} + errResponder := azfake.ErrorResponder{} + if a.getServerError { + errResponder.SetResponseError(http.StatusInternalServerError, "Some fake internal server error occurred") + } else { + response := apim.BackendClientGetResponse{} + if _, ok := a.Backends[backendID]; ok { + response.BackendContract = a.Backends[backendID] + response.ETag = utils.ToPointer("fake-etag") + responder.SetResponse(http.StatusOK, response, nil) + } else { + errResponder.SetResponseError(http.StatusNotFound, "Backend not found") + } + } + return responder, errResponder + }, + GetEntityTag: nil, + NewListByServicePager: nil, + Reconnect: nil, + Update: nil, + } + return fakeServer +} + +func (a *AzureApimFake) GetFakeApiServer() apimfake.APIServer { + fakeServer := apimfake.APIServer{ + BeginCreateOrUpdate: func(ctx context.Context, resourceGroupName string, serviceName string, apiID string, parameters apim.APICreateOrUpdateParameter, options *apim.APIClientBeginCreateOrUpdateOptions) (azfake.PollerResponder[apim.APIClientCreateOrUpdateResponse], azfake.ErrorResponder) { + responder := azfake.PollerResponder[apim.APIClientCreateOrUpdateResponse]{} + errResponder := azfake.ErrorResponder{} + if a.createUpdateServerError { + errResponder.SetResponseError(http.StatusInternalServerError, "Some fake internal server error occurred") + } else { + response := apim.APIClientCreateOrUpdateResponse{ + APIContract: apim.APIContract{ + ID: utils.ToPointer("/subscriptions/fake-subscription/resourceGroups/fake-resource-group/providers/APIM/Api/" + apiID), + Name: utils.ToPointer(apiID), + Type: utils.ToPointer("Microsoft.ApiManagement/service/apis"), + Properties: &apim.APIContractProperties{ + Path: parameters.Properties.Path, + APIRevision: parameters.Properties.APIRevision, + APIRevisionDescription: parameters.Properties.APIRevisionDescription, + APIType: parameters.Properties.APIType, + APIVersion: parameters.Properties.APIVersion, + APIVersionDescription: parameters.Properties.APIVersionDescription, + APIVersionSet: parameters.Properties.APIVersionSet, + APIVersionSetID: parameters.Properties.APIVersionSetID, + AuthenticationSettings: parameters.Properties.AuthenticationSettings, + Contact: parameters.Properties.Contact, + Description: parameters.Properties.Description, + DisplayName: parameters.Properties.DisplayName, + IsCurrent: parameters.Properties.IsCurrent, + License: parameters.Properties.License, + Protocols: parameters.Properties.Protocols, + ServiceURL: parameters.Properties.ServiceURL, + SourceAPIID: parameters.Properties.SourceAPIID, + SubscriptionKeyParameterNames: parameters.Properties.SubscriptionKeyParameterNames, + SubscriptionRequired: parameters.Properties.SubscriptionRequired, + TermsOfServiceURL: parameters.Properties.TermsOfServiceURL, + IsOnline: parameters.Properties.IsOnline, + }, + }, + } + a.APIMVersions[*response.Name] = response.APIContract + responder.SetTerminalResponse(http.StatusOK, response, nil) + } + return responder, errResponder + }, + Delete: func(ctx context.Context, resourceGroupName string, serviceName string, apiID string, ifMatch string, options *apim.APIClientDeleteOptions) (azfake.Responder[apim.APIClientDeleteResponse], azfake.ErrorResponder) { + responder := azfake.Responder[apim.APIClientDeleteResponse]{} + errResponder := azfake.ErrorResponder{} + if a.deleteServerError { + errResponder.SetResponseError(http.StatusInternalServerError, "Some fake internal server error occurred") + } else { + response := apim.APIClientDeleteResponse{} + if _, ok := a.APIMVersions[apiID]; ok { + delete(a.APIMVersions, apiID) + responder.SetResponse(http.StatusOK, response, nil) + } else { + errResponder.SetResponseError(http.StatusNotFound, "Backend not found") + } + } + return responder, errResponder + }, + Get: func(ctx context.Context, resourceGroupName string, serviceName string, apiID string, options *apim.APIClientGetOptions) (azfake.Responder[apim.APIClientGetResponse], azfake.ErrorResponder) { + responder := azfake.Responder[apim.APIClientGetResponse]{} + errResponder := azfake.ErrorResponder{} + if a.getServerError { + errResponder.SetResponseError(http.StatusInternalServerError, "Some fake internal server error occurred") + } else { + response := apim.APIClientGetResponse{} + if _, ok := a.APIMVersions[apiID]; ok { + response.APIContract = a.APIMVersions[apiID] + response.ETag = utils.ToPointer("fake-etag") + responder.SetResponse(http.StatusOK, response, nil) + } else { + errResponder.SetResponseError(http.StatusNotFound, "Backend not found") + } + } + return responder, errResponder + }, + GetEntityTag: nil, + NewListByServicePager: nil, + NewListByTagsPager: nil, + Update: nil, + } + return fakeServer +} + +func (a *AzureApimFake) GetFakeApiVersionServer() apimfake.APIVersionSetServer { + fakeServer := apimfake.APIVersionSetServer{ + CreateOrUpdate: func(ctx context.Context, resourceGroupName string, serviceName string, apiVersionSetID string, parameters apim.APIVersionSetContract, options *apim.APIVersionSetClientCreateOrUpdateOptions) (azfake.Responder[apim.APIVersionSetClientCreateOrUpdateResponse], azfake.ErrorResponder) { + responder := azfake.Responder[apim.APIVersionSetClientCreateOrUpdateResponse]{} + errResponder := azfake.ErrorResponder{} + if a.createUpdateServerError { + errResponder.SetResponseError(http.StatusInternalServerError, "Some fake internal server error occurred") + } else { + response := apim.APIVersionSetClientCreateOrUpdateResponse{ + APIVersionSetContract: apim.APIVersionSetContract{ + ID: utils.ToPointer("/subscriptions/fake-subscription/resourceGroups/fake-resource-group/providers/APIM/ApiVersionSet/" + apiVersionSetID), + Name: utils.ToPointer(apiVersionSetID), + Type: utils.ToPointer("Microsoft.ApiManagement/service/apiVersionSets"), + Properties: parameters.Properties, + }, + } + a.APIMVersionSets[*response.Name] = response.APIVersionSetContract + responder.SetResponse(http.StatusOK, response, nil) + } + return responder, errResponder + }, + Delete: func(ctx context.Context, resourceGroupName string, serviceName string, apiVersionSetID string, ifMatch string, options *apim.APIVersionSetClientDeleteOptions) (azfake.Responder[apim.APIVersionSetClientDeleteResponse], azfake.ErrorResponder) { + responder := azfake.Responder[apim.APIVersionSetClientDeleteResponse]{} + errResponder := azfake.ErrorResponder{} + if a.deleteServerError { + errResponder.SetResponseError(http.StatusInternalServerError, "Some fake internal server error occurred") + } else { + response := apim.APIVersionSetClientDeleteResponse{} + if _, ok := a.APIMVersionSets[apiVersionSetID]; ok { + delete(a.APIMVersionSets, apiVersionSetID) + responder.SetResponse(http.StatusOK, response, nil) + } else { + errResponder.SetResponseError(http.StatusNotFound, "Backend not found") + } + } + return responder, errResponder + }, + Get: func(ctx context.Context, resourceGroupName string, serviceName string, apiVersionSetID string, options *apim.APIVersionSetClientGetOptions) (azfake.Responder[apim.APIVersionSetClientGetResponse], azfake.ErrorResponder) { + responder := azfake.Responder[apim.APIVersionSetClientGetResponse]{} + errResponder := azfake.ErrorResponder{} + if a.getServerError { + errResponder.SetResponseError(http.StatusInternalServerError, "Some fake internal server error occurred") + } else { + response := apim.APIVersionSetClientGetResponse{} + if _, ok := a.APIMVersionSets[apiVersionSetID]; ok { + response.APIVersionSetContract = a.APIMVersionSets[apiVersionSetID] + response.ETag = utils.ToPointer("fake-etag") + responder.SetResponse(http.StatusOK, response, nil) + } else { + errResponder.SetResponseError(http.StatusNotFound, "Backend not found") + } + } + return responder, errResponder + }, + GetEntityTag: nil, + NewListByServicePager: nil, + Update: nil, + } + return fakeServer +} diff --git a/services/dis-apim-operator/test/utils/azure_fake.go b/services/dis-apim-operator/test/utils/azure_fake.go index 672db9f2..f81ab4c6 100644 --- a/services/dis-apim-operator/test/utils/azure_fake.go +++ b/services/dis-apim-operator/test/utils/azure_fake.go @@ -1,13 +1,8 @@ package utils import ( - "context" - "net/http" - "github.com/Altinn/altinn-platform/services/dis-apim-operator/internal/azure" - azfake "github.com/Azure/azure-sdk-for-go/sdk/azcore/fake" apim "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/apimanagement/armapimanagement/v2" - apimfake "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/apimanagement/armapimanagement/v2/fake" ) // NewAPIMClient creates a new APIMClient @@ -18,79 +13,3 @@ func NewFakeAPIMClient(config *azure.ApimClientConfig) (*azure.APIMClient, error } return azure.NewApimClientWithFactory(config, clientFactory), nil } - -func GetFakeBackendServer( - getResponse apim.BackendClientGetResponse, - getErrorCode *int, - createOrUpdateResponse apim.BackendClientCreateOrUpdateResponse, - createOrUpdateErrorCode *int, - deleteResponse apim.BackendClientDeleteResponse, - deleteErrorCode *int, -) *apimfake.BackendServer { - fakeServer := &apimfake.BackendServer{ - CreateOrUpdate: func( - ctx context.Context, - resourceGroupName string, - serviceName string, - backendID string, - parameters apim.BackendContract, - options *apim.BackendClientCreateOrUpdateOptions, - ) (azfake.Responder[apim.BackendClientCreateOrUpdateResponse], azfake.ErrorResponder) { - - response := createOrUpdateResponse - - responder := azfake.Responder[apim.BackendClientCreateOrUpdateResponse]{} - - errResponder := azfake.ErrorResponder{} - if createOrUpdateErrorCode != nil { - errResponder.SetResponseError(*createOrUpdateErrorCode, "Some fake error occurred") - } else { - response.Properties = parameters.Properties - } - responder.SetResponse(http.StatusOK, response, nil) - - return responder, errResponder - }, - Delete: func( - ctx context.Context, - resourceGroupName string, - serviceName string, - backendID string, - ifMatch string, - options *apim.BackendClientDeleteOptions, - ) (azfake.Responder[apim.BackendClientDeleteResponse], azfake.ErrorResponder) { - response := deleteResponse - responder := azfake.Responder[apim.BackendClientDeleteResponse]{} - - responder.SetResponse(http.StatusOK, response, nil) - - errorResponder := azfake.ErrorResponder{} - if deleteErrorCode != nil { - errorResponder.SetResponseError(*deleteErrorCode, "Some fake error occurred") - } - return responder, errorResponder - }, - Get: func( - ctx context.Context, - resourceGroupName string, - serviceName string, - backendID string, - options *apim.BackendClientGetOptions, - ) (azfake.Responder[apim.BackendClientGetResponse], azfake.ErrorResponder) { - response := getResponse - responder := azfake.Responder[apim.BackendClientGetResponse]{} - - responder.SetResponse(http.StatusOK, response, nil) - errResponder := azfake.ErrorResponder{} - if getErrorCode != nil { - errResponder.SetResponseError(*getErrorCode, "Some fake error occurred") - } - return responder, errResponder - }, - GetEntityTag: nil, - NewListByServicePager: nil, - Reconnect: nil, - Update: nil, - } - return fakeServer -}