From 0c01d33077451b6e4c303ffa552c0b17bbae086e Mon Sep 17 00:00:00 2001 From: Andrea Mazzotti Date: Mon, 25 Sep 2023 11:15:36 +0200 Subject: [PATCH 1/2] Added OpenAPI spec file --- Makefile | 30 ++ elemental-openapi.yaml | 357 ++++++++++++++++++ generate_openapi_test.go | 52 +++ go.mod | 8 +- go.sum | 18 + internal/api/elementalhost_controller.go | 124 +++++- ...elementalmachineregistration_controller.go | 37 +- internal/api/openapi.go | 20 + internal/api/server.go | 36 +- internal/api/types.go | 18 + 10 files changed, 663 insertions(+), 37 deletions(-) create mode 100644 elemental-openapi.yaml create mode 100644 generate_openapi_test.go create mode 100644 internal/api/openapi.go diff --git a/Makefile b/Makefile index 97c27cfa..002a8f5d 100644 --- a/Makefile +++ b/Makefile @@ -59,6 +59,10 @@ manifests: controller-gen ## Generate WebhookConfiguration, ClusterRole and Cust generate: controller-gen ## Generate code containing DeepCopy, DeepCopyInto, and DeepCopyObject method implementations. $(CONTROLLER_GEN) object:headerFile="hack/boilerplate.go.txt" paths="./..." +.PHONY: openapi +openapi: ## Generate Elemental OpenAPI specs + go test -v -run ^TestGenerateOpenAPI$ + .PHONY: fmt fmt: ## Run go fmt against code. go fmt ./... @@ -209,3 +213,29 @@ lint: ## See: https://golangci-lint.run/usage/linters/ -E tagliatelle \ -E revive \ -E wrapcheck + +ALL_VERIFY_CHECKS = manifests generate openapi + +.PHONY: verify +verify: $(addprefix verify-,$(ALL_VERIFY_CHECKS)) + +.PHONY: verify-manifests +verify-manifests: manifests + @if !(git diff --quiet HEAD); then \ + git diff; \ + echo "generated files are out of date, run make generate"; exit 1; \ + fi + +.PHONY: verify-openapi +verify-openapi: openapi + @if !(git diff --quiet HEAD); then \ + git diff; \ + echo "generated files are out of date, run make generate"; exit 1; \ + fi + +.PHONY: verify-generate +verify-generate: generate + @if !(git diff --quiet HEAD); then \ + git diff; \ + echo "generated files are out of date, run make generate"; exit 1; \ + fi diff --git a/elemental-openapi.yaml b/elemental-openapi.yaml new file mode 100644 index 00000000..fa60ac53 --- /dev/null +++ b/elemental-openapi.yaml @@ -0,0 +1,357 @@ +openapi: 3.0.3 +info: + description: This API can be used to interact with the Cluster API Elemental operator + title: Elemental API + version: v0.0.1 +paths: + /elemental/v1/namespaces/{namespace}/registrations/{registrationName}: + get: + description: This endpoint returns an ElementalRegistration. + parameters: + - in: path + name: namespace + required: true + schema: + type: string + - in: path + name: registrationName + required: true + schema: + type: string + responses: + "200": + content: + application/json: + schema: + $ref: '#/components/schemas/ApiRegistrationResponse' + description: Returns the ElementalRegistration + "404": + content: + text/html: + schema: + type: string + description: If the ElementalRegistration is not found + "500": + content: + text/html: + schema: + type: string + description: Internal Server Error + summary: Get ElementalRegistration + /elemental/v1/namespaces/{namespace}/registrations/{registrationName}/hosts: + post: + description: This endpoint create a new ElementalHost. + parameters: + - in: path + name: namespace + required: true + schema: + type: string + - in: path + name: registrationName + required: true + schema: + type: string + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/ApiHostCreateRequest' + responses: + "201": + description: ElementalHost correctly created. Location Header contains its + URI + "400": + content: + text/html: + schema: + type: string + description: ElementalHost request is badly formatted + "404": + content: + text/html: + schema: + type: string + description: ElementalRegistration not found + "409": + content: + text/html: + schema: + type: string + description: ElementalHost with same name within this ElementalRegistration + already exists + "500": + content: + text/html: + schema: + type: string + description: Internal Server Error + summary: Creates a new ElementalHost + /elemental/v1/namespaces/{namespace}/registrations/{registrationName}/hosts/{hostName}: + patch: + description: This endpoint patches an existing ElementalHost. + parameters: + - in: path + name: namespace + required: true + schema: + type: string + - in: path + name: registrationName + required: true + schema: + type: string + - in: path + name: hostName + required: true + schema: + type: string + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/ApiHostPatchRequest' + responses: + "200": + content: + application/json: + schema: + $ref: '#/components/schemas/ApiHostResponse' + description: Returns the patched ElementalHost + "400": + content: + text/html: + schema: + type: string + description: If the ElementalHostPatch request is badly formatted + "404": + content: + text/html: + schema: + type: string + description: If the ElementalRegistration or the ElementalHost are not found + "500": + content: + text/html: + schema: + type: string + description: Internal Server Error + summary: Patch ElementalHost + /elemental/v1/namespaces/{namespace}/registrations/{registrationName}/hosts/{hostName}/bootstrap: + get: + description: This endpoint returns the ElementalHost bootstrap instructions. + parameters: + - in: path + name: namespace + required: true + schema: + type: string + - in: path + name: registrationName + required: true + schema: + type: string + - in: path + name: hostName + required: true + schema: + type: string + responses: + "200": + content: + application/json: + schema: + $ref: '#/components/schemas/ApiBootstrapResponse' + description: Returns the ElementalHost bootstrap instructions + "404": + content: + text/html: + schema: + type: string + description: If the ElementalRegistration or ElementalHost are not found, + or if there are no bootstrap instructions yet + "500": + content: + text/html: + schema: + type: string + description: Internal Server Error + summary: Get ElementalHost bootstrap +components: + schemas: + ApiBootstrapFile: + properties: + content: + type: string + owner: + type: string + path: + type: string + permissions: + type: string + type: object + ApiBootstrapResponse: + properties: + runcmd: + items: + type: string + nullable: true + type: array + write_files: + items: + $ref: '#/components/schemas/ApiBootstrapFile' + nullable: true + type: array + type: object + ApiHostCreateRequest: + properties: + annotations: + additionalProperties: + type: string + type: object + labels: + additionalProperties: + type: string + type: object + name: + type: string + type: object + ApiHostPatchRequest: + properties: + annotations: + additionalProperties: + type: string + type: object + bootstrapped: + nullable: true + type: boolean + installed: + nullable: true + type: boolean + labels: + additionalProperties: + type: string + type: object + type: object + ApiHostResponse: + properties: + annotations: + additionalProperties: + type: string + type: object + bootstrapReady: + type: boolean + bootstrapped: + type: boolean + installed: + type: boolean + labels: + additionalProperties: + type: string + type: object + name: + type: string + type: object + ApiRegistrationResponse: + properties: + config: + $ref: '#/components/schemas/V1Beta1Config' + machineAnnotations: + additionalProperties: + type: string + type: object + machineLabels: + additionalProperties: + type: string + type: object + type: object + RuntimeRawExtension: + type: object + V1Beta1Config: + properties: + cloudConfig: + additionalProperties: + $ref: '#/components/schemas/RuntimeRawExtension' + type: object + elemental: + $ref: '#/components/schemas/V1Beta1Elemental' + type: object + V1Beta1Elemental: + properties: + install: + $ref: '#/components/schemas/V1Beta1Install' + registration: + $ref: '#/components/schemas/V1Beta1Registration' + reset: + $ref: '#/components/schemas/V1Beta1Reset' + type: object + V1Beta1Hostname: + properties: + prefix: + type: string + useExisting: + type: boolean + type: object + V1Beta1Install: + properties: + configDir: + type: string + configUrls: + items: + type: string + type: array + debug: + type: boolean + device: + type: string + disableBootEntry: + type: boolean + ejectCd: + type: boolean + firmware: + type: string + iso: + type: string + noFormat: + type: boolean + poweroff: + type: boolean + reboot: + type: boolean + systemUri: + type: string + tty: + type: string + type: object + V1Beta1Registration: + properties: + caCert: + type: string + hostname: + $ref: '#/components/schemas/V1Beta1Hostname' + noSmbios: + type: boolean + url: + type: string + type: object + V1Beta1Reset: + properties: + configUrls: + items: + type: string + type: array + debug: + type: boolean + enabled: + type: boolean + poweroff: + type: boolean + reboot: + type: boolean + resetOem: + type: boolean + resetPersistent: + type: boolean + systemUri: + type: string + type: object diff --git a/generate_openapi_test.go b/generate_openapi_test.go new file mode 100644 index 00000000..6c3b71f6 --- /dev/null +++ b/generate_openapi_test.go @@ -0,0 +1,52 @@ +package main + +import ( + "fmt" + "os" + "testing" + + "github.com/rancher-sandbox/cluster-api-provider-elemental/internal/api" + "github.com/swaggest/openapi-go/openapi3" + "github.com/swaggest/rest/gorillamux" +) + +func TestGenerateOpenAPI(t *testing.T) { + server := api.Server{} + router := server.NewRouter() + + // Setup OpenAPI schema. + refl := openapi3.NewReflector() + refl.SpecSchema().SetTitle("Elemental API") + refl.SpecSchema().SetVersion("v0.0.1") + refl.SpecSchema().SetDescription("This API can be used to interact with the Cluster API Elemental operator") + + // Walk the router with OpenAPI collector. + c := gorillamux.NewOpenAPICollector(refl) + + if err := router.Walk(c.Walker); err != nil { + t.Fatalf(fmt.Errorf("Walking routes: %w", err).Error()) + } + + // Get the resulting schema. + if yaml, err := refl.Spec.MarshalYAML(); err != nil { + t.Fatalf(fmt.Errorf("marshalling YAML: %w", err).Error()) + } else { + writeOpenAPISpecFile(t, yaml) + } +} + +func writeOpenAPISpecFile(t *testing.T, spec []byte) { + t.Helper() + + f, err := os.Create("elemental-openapi.yaml") + if err != nil { + t.Fatalf(fmt.Errorf("creating file: %w", err).Error()) + } + + defer f.Close() + + _, err = f.Write(spec) + if err != nil { + t.Fatalf(fmt.Errorf("Writing file: %w", err).Error()) + } +} diff --git a/go.mod b/go.mod index b582544a..8cb87d0f 100644 --- a/go.mod +++ b/go.mod @@ -80,7 +80,7 @@ require ( github.com/rancher-sandbox/linuxkit v1.0.1 // indirect github.com/samber/lo v1.37.0 // indirect github.com/satori/go.uuid v1.2.1-0.20181028125025-b2ce2384e17b // indirect - github.com/sergi/go-diff v1.2.0 // indirect + github.com/sergi/go-diff v1.3.1 // indirect github.com/shopspring/decimal v1.3.1 // indirect github.com/sirupsen/logrus v1.9.0 // indirect github.com/spectrocloud-labs/herd v0.4.2 // indirect @@ -88,6 +88,10 @@ require ( github.com/spf13/cast v1.5.1 // indirect github.com/spf13/jwalterweatherman v1.1.0 // indirect github.com/subosito/gotenv v1.4.2 // indirect + github.com/swaggest/form/v5 v5.1.1 // indirect + github.com/swaggest/jsonschema-go v0.3.58 // indirect + github.com/swaggest/refl v1.2.1 // indirect + github.com/swaggest/usecase v1.2.1 // indirect github.com/tredoe/osutil/v2 v2.0.0-rc.16 // indirect github.com/ulikunitz/xz v0.5.11 // indirect github.com/vbatts/tar-split v0.11.3 // indirect @@ -149,6 +153,8 @@ require ( github.com/spf13/cobra v1.7.0 github.com/spf13/pflag v1.0.5 // indirect github.com/spf13/viper v1.16.0 + github.com/swaggest/openapi-go v0.2.39 + github.com/swaggest/rest v0.2.58 github.com/twpayne/go-vfs v1.7.2 github.com/twpayne/go-vfsafero v1.0.0 go.uber.org/multierr v1.11.0 // indirect diff --git a/go.sum b/go.sum index 9261151b..dbf81a99 100644 --- a/go.sum +++ b/go.sum @@ -84,6 +84,7 @@ github.com/blang/semver v3.5.1+incompatible h1:cQNTCjp13qL8KC3Nbxr/y2Bqb63oX6wdn github.com/blang/semver v3.5.1+incompatible/go.mod h1:kRBLl5iJ+tD4TcOOxsy/0fnwebNt5EWlYSAyrTnjyyk= github.com/blang/semver/v4 v4.0.0 h1:1PFHFE6yCCTv8C1TeyNNarDzntLi7wMI5i/pzqYIsAM= github.com/blang/semver/v4 v4.0.0/go.mod h1:IbckMUScFkM3pff0VJDNKRiT6TG/YpiHIM2yvyW5YoQ= +github.com/bool64/dev v0.2.25/go.mod h1:iJbh1y/HkunEPhgebWRNcs8wfGq7sjvJ6W5iabL8ACg= github.com/canonical/go-efilib v0.3.1-0.20220324150059-04e254148b45 h1:vNz7eEqoD04D6aqty3yS8BacBwIUiLVGFDfrBHKgWCo= github.com/canonical/go-efilib v0.3.1-0.20220324150059-04e254148b45/go.mod h1:9b2PNAuPcZsB76x75/uwH99D8CyH/A2y4rq1/+bvplg= github.com/cavaliergopher/grab v2.0.0+incompatible h1:XLeGNAc7MIRTMb8RlRbN76uO8vx1/AeNMWWN7FYpDw8= @@ -480,6 +481,7 @@ github.com/satori/go.uuid v1.2.1-0.20181028125025-b2ce2384e17b/go.mod h1:dA0hQrY github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM= github.com/sergi/go-diff v1.2.0 h1:XU+rvMAioB0UC3q1MFrIQy4Vo5/4VsRDQQXHsEya6xQ= github.com/sergi/go-diff v1.2.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM= +github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I= github.com/shopspring/decimal v1.2.0/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o= github.com/shopspring/decimal v1.3.1 h1:2Usl1nmF/WZucqkFZhnfFYxxxu8LG21F6nPQBE5gKV8= github.com/shopspring/decimal v1.3.1/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o= @@ -535,6 +537,22 @@ github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXl github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= github.com/subosito/gotenv v1.4.2 h1:X1TuBLAMDFbaTAChgCBLu3DU3UPyELpnF2jjJ2cz/S8= github.com/subosito/gotenv v1.4.2/go.mod h1:ayKnFf/c6rvx/2iiLrJUk1e6plDbT3edrFNGqEflhK0= +github.com/swaggest/form/v5 v5.1.1 h1:ct6/rOQBGrqWUQ0FUv3vW5sHvTUb31AwTUWj947N6cY= +github.com/swaggest/form/v5 v5.1.1/go.mod h1:X1hraaoONee20PMnGNLQpO32f9zbQ0Czfm7iZThuEKg= +github.com/swaggest/jsonschema-go v0.3.57 h1:n6D/2K9557Yqn/NohXoszmjuN0Lp5n0DyHlVLRZXbOM= +github.com/swaggest/jsonschema-go v0.3.57/go.mod h1:5WFFGBBte5JAWAV8gDpNRJ/tlQnb1AHDdf/ghgsVUik= +github.com/swaggest/jsonschema-go v0.3.58 h1:OPixN4HW9H3FTh9BSomH2i0bdJi3V646TfSihzt9QBc= +github.com/swaggest/jsonschema-go v0.3.58/go.mod h1:5WFFGBBte5JAWAV8gDpNRJ/tlQnb1AHDdf/ghgsVUik= +github.com/swaggest/openapi-go v0.2.39 h1:GfICsAAFnQuyxfywsGyCbPqDKeMXxots4N/9j6+qSCk= +github.com/swaggest/openapi-go v0.2.39/go.mod h1:g+AfRIkPCHdhqfW8zOD1Sk3PwLhxpWW8SNWHXrmA08c= +github.com/swaggest/refl v1.2.0 h1:Qqqhfwi7REXF6/4cwJmj3gQMzl0Q0cYquxTYdD0kvi0= +github.com/swaggest/refl v1.2.0/go.mod h1:CkC6g7h1PW33KprTuYRSw8UUOslRUt4lF3oe7tTIgNU= +github.com/swaggest/refl v1.2.1 h1:1meX9NaXjM5lmb4kk4RP3OZsXFRke9B1EHAP/pCEKO0= +github.com/swaggest/refl v1.2.1/go.mod h1:CkC6g7h1PW33KprTuYRSw8UUOslRUt4lF3oe7tTIgNU= +github.com/swaggest/rest v0.2.58 h1:1QWLNree0kyqW3HYk4X6cKqycRVPj/w7exXM6lg6mN8= +github.com/swaggest/rest v0.2.58/go.mod h1:M6G4XRNytiBZNB7IbkolfQnhZDdGIXWCYELQrWrRVvw= +github.com/swaggest/usecase v1.2.1 h1:XYVdK9tK2KCPglTflUi7aWBrVwIyb58D5mvGWED7pNs= +github.com/swaggest/usecase v1.2.1/go.mod h1:5ccwVsLJ9eQpU4m0AGTM444pdqSPQBiocIwMmdRH9lQ= github.com/tmc/grpc-websocket-proxy v0.0.0-20190109142713-0ad062ec5ee5/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U= github.com/tredoe/osutil/v2 v2.0.0-rc.16 h1:5A2SKvyB2c3lhPYUIHyFtu6jbaXlaA3Hu5gWIam8Pik= github.com/tredoe/osutil/v2 v2.0.0-rc.16/go.mod h1:uLRVx/3pb7Y4RQhG8cQFbPE9ha5r81e6MXpBsxbTAYc= diff --git a/internal/api/elementalhost_controller.go b/internal/api/elementalhost_controller.go index 6de8c0e0..c0dd89a3 100644 --- a/internal/api/elementalhost_controller.go +++ b/internal/api/elementalhost_controller.go @@ -5,31 +5,63 @@ import ( "fmt" "net/http" + "github.com/go-logr/logr" "github.com/gorilla/mux" infrastructurev1beta1 "github.com/rancher-sandbox/cluster-api-provider-elemental/api/v1beta1" "github.com/rancher-sandbox/cluster-api-provider-elemental/internal/log" + "github.com/swaggest/openapi-go" corev1 "k8s.io/api/core/v1" k8sapierrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/utils/ptr" "sigs.k8s.io/cluster-api/util/patch" + "sigs.k8s.io/controller-runtime/pkg/client" k8sclient "sigs.k8s.io/controller-runtime/pkg/client" ) -func (s *Server) PatchMachineHost(response http.ResponseWriter, request *http.Request) { +var _ OpenAPIDecoratedHandler = (*PatchElementalHostHandler)(nil) +var _ http.Handler = (*PatchElementalHostHandler)(nil) + +type PatchElementalHostHandler struct { + logger logr.Logger + k8sClient client.Client +} + +func NewPatchElementalHostHandler(logger logr.Logger, k8sClient client.Client) *PatchElementalHostHandler { + return &PatchElementalHostHandler{ + logger: logger, + k8sClient: k8sClient, + } +} + +func (h *PatchElementalHostHandler) SetupOpenAPIOperation(oc openapi.OperationContext) error { + oc.SetSummary("Patch ElementalHost") + oc.SetDescription("This endpoint patches an existing ElementalHost.") + + oc.AddReqStructure(HostPatchRequest{}) + + oc.AddRespStructure(HostResponse{}, WithDecoration("Returns the patched ElementalHost", "application/json", http.StatusOK)) + oc.AddRespStructure(nil, WithDecoration("If the ElementalRegistration or the ElementalHost are not found", "text/html", http.StatusNotFound)) + oc.AddRespStructure(nil, WithDecoration("If the ElementalHostPatch request is badly formatted", "text/html", http.StatusBadRequest)) + oc.AddRespStructure(nil, WithDecoration("", "text/html", http.StatusInternalServerError)) + + return nil +} + +func (h *PatchElementalHostHandler) ServeHTTP(response http.ResponseWriter, request *http.Request) { pathVars := mux.Vars(request) namespace := pathVars["namespace"] registrationName := pathVars["registrationName"] hostName := pathVars["hostName"] - logger := s.logger.WithValues(log.KeyNamespace, namespace). + logger := h.logger.WithValues(log.KeyNamespace, namespace). WithValues(log.KeyElementalMachineRegistration, registrationName). WithValues(log.KeyElementalHost, hostName) logger.Info("Patching ElementalHost") // Fetch registration registration := &infrastructurev1beta1.ElementalMachineRegistration{} - if err := s.k8sClient.Get(request.Context(), k8sclient.ObjectKey{Namespace: namespace, Name: registrationName}, registration); err != nil { + if err := h.k8sClient.Get(request.Context(), k8sclient.ObjectKey{Namespace: namespace, Name: registrationName}, registration); err != nil { if k8sapierrors.IsNotFound(err) { response.WriteHeader(http.StatusNotFound) WriteResponse(logger, response, fmt.Sprintf("ElementalMachineRegistration '%s' not found", registrationName)) @@ -43,7 +75,7 @@ func (s *Server) PatchMachineHost(response http.ResponseWriter, request *http.Re // Fetch host host := &infrastructurev1beta1.ElementalHost{} - if err := s.k8sClient.Get(request.Context(), k8sclient.ObjectKey{Namespace: namespace, Name: hostName}, host); err != nil { + if err := h.k8sClient.Get(request.Context(), k8sclient.ObjectKey{Namespace: namespace, Name: hostName}, host); err != nil { if k8sapierrors.IsNotFound(err) { response.WriteHeader(http.StatusNotFound) WriteResponse(logger, response, fmt.Sprintf("ElementalHost '%s' not found", hostName)) @@ -73,7 +105,7 @@ func (s *Server) PatchMachineHost(response http.ResponseWriter, request *http.Re } // Patch the object - patchHelper, err := patch.NewHelper(host, s.k8sClient) + patchHelper, err := patch.NewHelper(host, h.k8sClient) if err != nil { logger.Error(err, "Initializing ElementalHost patch helper") response.WriteHeader(http.StatusInternalServerError) @@ -91,7 +123,7 @@ func (s *Server) PatchMachineHost(response http.ResponseWriter, request *http.Re // Fetch the updated host host = &infrastructurev1beta1.ElementalHost{} - if err := s.k8sClient.Get(request.Context(), k8sclient.ObjectKey{Namespace: namespace, Name: hostName}, host); err != nil { + if err := h.k8sClient.Get(request.Context(), k8sclient.ObjectKey{Namespace: namespace, Name: hostName}, host); err != nil { if k8sapierrors.IsNotFound(err) { response.WriteHeader(http.StatusNotFound) WriteResponse(logger, response, fmt.Sprintf("Updated ElementalHost '%s' not found", hostName)) @@ -108,7 +140,7 @@ func (s *Server) PatchMachineHost(response http.ResponseWriter, request *http.Re hostResponse.fromElementalHost(*host) responseBytes, err := json.Marshal(hostResponse) if err != nil { - s.logger.Error(err, "Could not encode response body", "host", fmt.Sprintf("%+v", hostResponse)) + h.logger.Error(err, "Could not encode response body", "host", fmt.Sprintf("%+v", hostResponse)) response.WriteHeader(http.StatusInternalServerError) WriteResponse(logger, response, fmt.Errorf("Could not encode response body: %w", err).Error()) return @@ -120,18 +152,48 @@ func (s *Server) PatchMachineHost(response http.ResponseWriter, request *http.Re WriteResponseBytes(logger, response, responseBytes) } -func (s *Server) PostMachineHost(response http.ResponseWriter, request *http.Request) { +var _ OpenAPIDecoratedHandler = (*PostElementalHostHandler)(nil) +var _ http.Handler = (*PostElementalHostHandler)(nil) + +type PostElementalHostHandler struct { + logger logr.Logger + k8sClient client.Client +} + +func NewPostElementalHostHandler(logger logr.Logger, k8sClient client.Client) *PostElementalHostHandler { + return &PostElementalHostHandler{ + logger: logger, + k8sClient: k8sClient, + } +} + +func (h *PostElementalHostHandler) SetupOpenAPIOperation(oc openapi.OperationContext) error { + oc.SetSummary("Creates a new ElementalHost") + oc.SetDescription("This endpoint create a new ElementalHost.") + + oc.AddReqStructure(HostCreateRequest{}) + + oc.AddRespStructure(nil, WithDecoration("ElementalHost correctly created. Location Header contains its URI", "", http.StatusCreated)) + oc.AddRespStructure(nil, WithDecoration("ElementalHost with same name within this ElementalRegistration already exists", "text/html", http.StatusConflict)) + oc.AddRespStructure(nil, WithDecoration("ElementalRegistration not found", "text/html", http.StatusNotFound)) + oc.AddRespStructure(nil, WithDecoration("ElementalHost request is badly formatted", "text/html", http.StatusBadRequest)) + oc.AddRespStructure(nil, WithDecoration("", "text/html", http.StatusInternalServerError)) + + return nil +} + +func (h *PostElementalHostHandler) ServeHTTP(response http.ResponseWriter, request *http.Request) { pathVars := mux.Vars(request) namespace := pathVars["namespace"] registrationName := pathVars["registrationName"] - logger := s.logger.WithValues(log.KeyNamespace, namespace). + logger := h.logger.WithValues(log.KeyNamespace, namespace). WithValues(log.KeyElementalMachineRegistration, registrationName) logger.Info("Creating new ElementalHost") // Fetch registration registration := &infrastructurev1beta1.ElementalMachineRegistration{} - if err := s.k8sClient.Get(request.Context(), k8sclient.ObjectKey{Namespace: namespace, Name: registrationName}, registration); err != nil { + if err := h.k8sClient.Get(request.Context(), k8sclient.ObjectKey{Namespace: namespace, Name: registrationName}, registration); err != nil { if k8sapierrors.IsNotFound(err) { response.WriteHeader(http.StatusNotFound) WriteResponse(logger, response, fmt.Sprintf("ElementalMachineRegistration '%s' not found", registrationName)) @@ -165,7 +227,7 @@ func (s *Server) PostMachineHost(response http.ResponseWriter, request *http.Req } // Create new Host - if err := s.k8sClient.Create(request.Context(), &newHost); err != nil { + if err := h.k8sClient.Create(request.Context(), &newHost); err != nil { if k8sapierrors.IsAlreadyExists(err) { response.WriteHeader(http.StatusConflict) WriteResponse(logger, response, fmt.Sprintf("Host '%s' in namespace '%s' already exists", namespace, newHost.Name)) @@ -183,20 +245,48 @@ func (s *Server) PostMachineHost(response http.ResponseWriter, request *http.Req response.WriteHeader(http.StatusCreated) } -func (s *Server) GetMachineHostBootstrap(response http.ResponseWriter, request *http.Request) { +var _ OpenAPIDecoratedHandler = (*GetElementalHostBootstrapHandler)(nil) +var _ http.Handler = (*GetElementalHostBootstrapHandler)(nil) + +type GetElementalHostBootstrapHandler struct { + logger logr.Logger + k8sClient client.Client +} + +func NewGetElementalHostBootstrapHandler(logger logr.Logger, k8sClient client.Client) *GetElementalHostBootstrapHandler { + return &GetElementalHostBootstrapHandler{ + logger: logger, + k8sClient: k8sClient, + } +} + +func (h *GetElementalHostBootstrapHandler) SetupOpenAPIOperation(oc openapi.OperationContext) error { + oc.SetSummary("Get ElementalHost bootstrap") + oc.SetDescription("This endpoint returns the ElementalHost bootstrap instructions.") + + oc.AddReqStructure(BootstrapGetRequest{}) + + oc.AddRespStructure(BootstrapResponse{}, WithDecoration("Returns the ElementalHost bootstrap instructions", "application/json", http.StatusOK)) + oc.AddRespStructure(nil, WithDecoration("If the ElementalRegistration or ElementalHost are not found, or if there are no bootstrap instructions yet", "text/html", http.StatusNotFound)) + oc.AddRespStructure(nil, WithDecoration("", "text/html", http.StatusInternalServerError)) + + return nil +} + +func (h *GetElementalHostBootstrapHandler) ServeHTTP(response http.ResponseWriter, request *http.Request) { pathVars := mux.Vars(request) namespace := pathVars["namespace"] registrationName := pathVars["registrationName"] hostName := pathVars["hostName"] - logger := s.logger.WithValues(log.KeyNamespace, namespace). + logger := h.logger.WithValues(log.KeyNamespace, namespace). WithValues(log.KeyElementalMachineRegistration, registrationName). WithValues(log.KeyElementalHost, hostName) - logger.Info("Getting MachineHost Bootstrap") + logger.Info("Getting ElementalHost Bootstrap") // Fetch registration registration := &infrastructurev1beta1.ElementalMachineRegistration{} - if err := s.k8sClient.Get(request.Context(), k8sclient.ObjectKey{Namespace: namespace, Name: registrationName}, registration); err != nil { + if err := h.k8sClient.Get(request.Context(), k8sclient.ObjectKey{Namespace: namespace, Name: registrationName}, registration); err != nil { if k8sapierrors.IsNotFound(err) { response.WriteHeader(http.StatusNotFound) WriteResponse(logger, response, fmt.Sprintf("ElementalMachineRegistration '%s' not found", registrationName)) @@ -210,7 +300,7 @@ func (s *Server) GetMachineHostBootstrap(response http.ResponseWriter, request * // Fetch host host := &infrastructurev1beta1.ElementalHost{} - if err := s.k8sClient.Get(request.Context(), k8sclient.ObjectKey{Namespace: namespace, Name: hostName}, host); err != nil { + if err := h.k8sClient.Get(request.Context(), k8sclient.ObjectKey{Namespace: namespace, Name: hostName}, host); err != nil { if k8sapierrors.IsNotFound(err) { response.WriteHeader(http.StatusNotFound) WriteResponse(logger, response, fmt.Sprintf("ElementalHost '%s' not found", hostName)) @@ -231,7 +321,7 @@ func (s *Server) GetMachineHostBootstrap(response http.ResponseWriter, request * // Fetch bootstrap secret bootstrapSecret := &corev1.Secret{} - if err := s.k8sClient.Get(request.Context(), k8sclient.ObjectKey{Namespace: host.Spec.BootstrapSecret.Namespace, Name: host.Spec.BootstrapSecret.Name}, bootstrapSecret); err != nil { + if err := h.k8sClient.Get(request.Context(), k8sclient.ObjectKey{Namespace: host.Spec.BootstrapSecret.Namespace, Name: host.Spec.BootstrapSecret.Name}, bootstrapSecret); err != nil { if k8sapierrors.IsNotFound(err) { logger.Error(err, "Could not find expected Bootstrap secret", log.KeyBootstrapSecret, host.Spec.BootstrapSecret.Name) response.WriteHeader(http.StatusInternalServerError) diff --git a/internal/api/elementalmachineregistration_controller.go b/internal/api/elementalmachineregistration_controller.go index f67b452c..f385b809 100644 --- a/internal/api/elementalmachineregistration_controller.go +++ b/internal/api/elementalmachineregistration_controller.go @@ -5,25 +5,56 @@ import ( "fmt" "net/http" + "github.com/go-logr/logr" "github.com/gorilla/mux" infrastructurev1beta1 "github.com/rancher-sandbox/cluster-api-provider-elemental/api/v1beta1" "github.com/rancher-sandbox/cluster-api-provider-elemental/internal/log" + "github.com/swaggest/openapi-go" k8sapierrors "k8s.io/apimachinery/pkg/api/errors" + "sigs.k8s.io/controller-runtime/pkg/client" k8sclient "sigs.k8s.io/controller-runtime/pkg/client" ) -func (s *Server) GetMachineRegistration(response http.ResponseWriter, request *http.Request) { +var _ OpenAPIDecoratedHandler = (*GetElementalRegistrationHandler)(nil) +var _ http.Handler = (*GetElementalRegistrationHandler)(nil) + +type GetElementalRegistrationHandler struct { + logger logr.Logger + k8sClient client.Client +} + +func NewGetElementalRegistrationHandler(logger logr.Logger, k8sClient client.Client) *GetElementalRegistrationHandler { + return &GetElementalRegistrationHandler{ + logger: logger, + k8sClient: k8sClient, + } +} + +func (h *GetElementalRegistrationHandler) SetupOpenAPIOperation(oc openapi.OperationContext) error { + oc.SetSummary("Get ElementalRegistration") + oc.SetDescription("This endpoint returns an ElementalRegistration.") + + oc.AddReqStructure(RegistrationGetRequest{}) + + oc.AddRespStructure(RegistrationResponse{}, WithDecoration("Returns the ElementalRegistration", "application/json", http.StatusOK)) + oc.AddRespStructure(nil, WithDecoration("If the ElementalRegistration is not found", "text/html", http.StatusNotFound)) + oc.AddRespStructure(nil, WithDecoration("", "text/html", http.StatusInternalServerError)) + + return nil +} + +func (h *GetElementalRegistrationHandler) ServeHTTP(response http.ResponseWriter, request *http.Request) { pathVars := mux.Vars(request) namespace := pathVars["namespace"] registrationName := pathVars["registrationName"] - logger := s.logger.WithValues(log.KeyNamespace, namespace). + logger := h.logger.WithValues(log.KeyNamespace, namespace). WithValues(log.KeyElementalMachineRegistration, registrationName) logger.Info("Getting ElementalMachineRegistration") // Fetch registration registration := &infrastructurev1beta1.ElementalMachineRegistration{} - if err := s.k8sClient.Get(request.Context(), k8sclient.ObjectKey{Namespace: namespace, Name: registrationName}, registration); err != nil { + if err := h.k8sClient.Get(request.Context(), k8sclient.ObjectKey{Namespace: namespace, Name: registrationName}, registration); err != nil { if k8sapierrors.IsNotFound(err) { response.WriteHeader(http.StatusNotFound) WriteResponse(logger, response, fmt.Sprintf("ElementalMachineRegistration '%s' not found", registrationName)) diff --git a/internal/api/openapi.go b/internal/api/openapi.go new file mode 100644 index 00000000..46ab7a69 --- /dev/null +++ b/internal/api/openapi.go @@ -0,0 +1,20 @@ +package api + +import ( + "net/http" + + "github.com/swaggest/openapi-go" +) + +type OpenAPIDecoratedHandler interface { + SetupOpenAPIOperation(oc openapi.OperationContext) error + ServeHTTP(w http.ResponseWriter, r *http.Request) +} + +func WithDecoration(description string, contentType string, httpStatus int) func(cu *openapi.ContentUnit) { + return func(cu *openapi.ContentUnit) { + cu.Description = description + cu.ContentType = contentType + cu.HTTPStatus = httpStatus + } +} diff --git a/internal/api/server.go b/internal/api/server.go index 0a1af7dc..63a1d145 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -33,30 +33,34 @@ func NewServer(ctx context.Context, k8sClient client.Client) *Server { } } -func (s *Server) Start() error { - s.logger.Info("Starting Elemental API V1 Server") - +func (s *Server) NewRouter() *mux.Router { router := mux.NewRouter() elementalV1 := router.PathPrefix(fmt.Sprintf("%s%s", PrefixAPI, PrefixV1)).Subrouter() - elementalV1.Path("/namespaces/{namespace}/registrations/{registrationName}"). - Methods("GET"). - HandlerFunc(s.GetMachineRegistration) // TODO: Wrap me with RegistrationToken auth handler + elementalV1.Handle("/namespaces/{namespace}/registrations/{registrationName}", + NewGetElementalRegistrationHandler(s.logger, s.k8sClient)). + Methods(http.MethodGet) + + elementalV1.Handle("/namespaces/{namespace}/registrations/{registrationName}/hosts", + NewPostElementalHostHandler(s.logger, s.k8sClient)). + Methods(http.MethodPost) - elementalV1.Path("/namespaces/{namespace}/registrations/{registrationName}/hosts"). - Methods("POST"). - HandlerFunc(s.PostMachineHost) // TODO: Wrap me with RegistrationToken + Host auth handler + elementalV1.Handle("/namespaces/{namespace}/registrations/{registrationName}/hosts/{hostName}", + NewPatchElementalHostHandler(s.logger, s.k8sClient)). + Methods(http.MethodPatch) - elementalV1.Path("/namespaces/{namespace}/registrations/{registrationName}/hosts/{hostName}"). - Methods("PATCH"). - HandlerFunc(s.PatchMachineHost) // TODO: Wrap me with RegistrationToken + Host auth handler + elementalV1.Handle("/namespaces/{namespace}/registrations/{registrationName}/hosts/{hostName}/bootstrap", + NewGetElementalHostBootstrapHandler(s.logger, s.k8sClient)). + Methods(http.MethodGet) - elementalV1.Path("/namespaces/{namespace}/registrations/{registrationName}/hosts/{hostName}/bootstrap"). - Methods("GET"). - HandlerFunc(s.GetMachineHostBootstrap) // TODO: Wrap me with RegistrationToken + Host auth handler + return router +} + +func (s *Server) Start() error { + s.logger.Info("Starting Elemental API V1 Server") s.httpServer = &http.Server{ - Handler: router, + Handler: s.NewRouter(), Addr: ":9090", WriteTimeout: 30 * time.Second, ReadTimeout: 30 * time.Second, diff --git a/internal/api/types.go b/internal/api/types.go index 975d5ce7..4fa03e12 100644 --- a/internal/api/types.go +++ b/internal/api/types.go @@ -10,6 +10,9 @@ import ( ) type HostCreateRequest struct { + Namespace string `path:"namespace"` + RegistrationName string `path:"registrationName"` + Name string `json:"name,omitempty"` Annotations map[string]string `json:"annotations,omitempty"` Labels map[string]string `json:"labels,omitempty"` @@ -27,6 +30,10 @@ func (h *HostCreateRequest) toElementalHost(namespace string) infrastructurev1be } type HostPatchRequest struct { + Namespace string `path:"namespace"` + RegistrationName string `path:"registrationName"` + HostName string `path:"hostName"` + Annotations map[string]string `json:"annotations,omitempty"` Labels map[string]string `json:"labels,omitempty"` Bootstrapped *bool `json:"bootstrapped,omitempty"` @@ -62,6 +69,11 @@ func (h *HostResponse) fromElementalHost(elementalHost infrastructurev1beta1.Ele h.Installed = elementalHost.Status.Installed } +type RegistrationGetRequest struct { + Namespace string `path:"namespace"` + RegistrationName string `path:"registrationName"` +} + type RegistrationResponse struct { // MachineLabels are labels propagated to each ElementalHost object linked to this registration. // +optional @@ -80,6 +92,12 @@ func (r *RegistrationResponse) fromElementalMachineRegistration(elementalRegistr r.Config = elementalRegistration.Spec.Config } +type BootstrapGetRequest struct { + Namespace string `path:"namespace"` + RegistrationName string `path:"registrationName"` + HostName string `path:"hostName"` +} + type BootstrapResponse struct { Files []BootstrapFile `json:"write_files" yaml:"write_files"` //nolint:tagliatelle //Matching cloud-init schema Commands []string `json:"runcmd" yaml:"runcmd"` From a55914929652c4a8037a7c677ed6206233d86c09 Mon Sep 17 00:00:00 2001 From: Andrea Mazzotti Date: Mon, 25 Sep 2023 11:17:21 +0200 Subject: [PATCH 2/2] Add verify GitHub workflow --- .github/workflows/verify.yaml | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 .github/workflows/verify.yaml diff --git a/.github/workflows/verify.yaml b/.github/workflows/verify.yaml new file mode 100644 index 00000000..1802d957 --- /dev/null +++ b/.github/workflows/verify.yaml @@ -0,0 +1,22 @@ +name: Verify +on: + pull_request: + push: + branches: + - main + tags: + - 'v*' +jobs: + lint: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v3 + with: + fetch-depth: 0 + - name: Install Go + uses: actions/setup-go@v3 + with: + go-version-file: go.mod + - name: Run verify checks + run: make verify