From 5f463afaa9a1c2c73bd6f33ea67a2187eec59629 Mon Sep 17 00:00:00 2001 From: Simon Murray Date: Mon, 3 Jun 2024 09:15:21 +0100 Subject: [PATCH] Tweak OpenAPI Definitions You can externally reference other schemas in OpenAPI, so we can share things across different microservices (yay), to do this you need to import the generated types and schema, so this puts that in place. Secondly we need to tweak the read metadata to add additional scope where it's required for various views. Third, we can share some metadata conversion functions. --- Makefile | 10 +- charts/core/Chart.yaml | 4 +- pkg/constants/constants.go | 9 ++ {openapi => pkg/openapi}/common.spec.yaml | 20 ++++ {openapi => pkg/openapi}/schema.go | 35 +++---- {openapi => pkg/openapi}/types.go | 55 ++++++++++- pkg/server/conversion/conversion.go | 109 ++++++++++++++++++++++ 7 files changed, 218 insertions(+), 24 deletions(-) rename {openapi => pkg/openapi}/common.spec.yaml (72%) rename {openapi => pkg/openapi}/schema.go (64%) rename {openapi => pkg/openapi}/types.go (51%) create mode 100644 pkg/server/conversion/conversion.go diff --git a/Makefile b/Makefile index 7b5aeea..a0cde55 100644 --- a/Makefile +++ b/Makefile @@ -29,7 +29,9 @@ CONTROLLER_TOOLS_VERSION=v0.14.0 # This should be kept in sync with the Kubenetes library versions defined in go.mod. CODEGEN_VERSION=v0.27.3 -OPENAPI_CODEGEN_VERSION=v1.12.4 +OPENAPI_CODEGEN_VERSION=v1.16.2 + +OPENAPI_FILES = pkg/openapi/types.go pkg/openapi/schema.go # Defined the mock generator version. MOCKGEN_VERSION=v0.3.0 @@ -53,7 +55,7 @@ GENCLIENTS = $(MODULE)/$(GENDIR)/clientset # Main target, builds all binaries. .PHONY: all -all: $(GENDIR) $(CRDDIR) openapi/types.go +all: $(GENDIR) $(CRDDIR) $(OPENAPI_FILES) # TODO: we may wamt to consider porting the rest of the CRD and client generation # stuff over... that said, we don't need the clients really do we, controller-runtime @@ -68,11 +70,11 @@ test-unit: go test -coverpkg ./... -coverprofile cover.out ./... go tool cover -html cover.out -o cover.html -openapi/types.go: openapi/common.spec.yaml +pkg/openapi/types.go: pkg/openapi/common.spec.yaml @go install github.com/deepmap/oapi-codegen/cmd/oapi-codegen@$(OPENAPI_CODEGEN_VERSION) oapi-codegen -generate types,skip-prune -package openapi -o $@ $< -openapi/schema.go: openapi/common.spec.yaml +pkg/openapi/schema.go: pkg/openapi/common.spec.yaml @go install github.com/deepmap/oapi-codegen/cmd/oapi-codegen@$(OPENAPI_CODEGEN_VERSION) oapi-codegen -generate spec,skip-prune -package openapi -o $@ $< diff --git a/charts/core/Chart.yaml b/charts/core/Chart.yaml index 92db16a..94ea22f 100644 --- a/charts/core/Chart.yaml +++ b/charts/core/Chart.yaml @@ -4,7 +4,7 @@ description: A Helm chart for deploying Unikorn Core type: application -version: v0.1.32 -appVersion: v0.1.32 +version: v0.1.33 +appVersion: v0.1.33 icon: https://assets.unikorn-cloud.org/images/logos/dark-on-light/icon.svg diff --git a/pkg/constants/constants.go b/pkg/constants/constants.go index b425b77..e04a442 100644 --- a/pkg/constants/constants.go +++ b/pkg/constants/constants.go @@ -25,6 +25,15 @@ const ( // This is the default version in the Makefile. DeveloperVersion = "0.0.0" + // NameLabel is attached to every resource to give it a mutable display + // name. While the character set is limited to [0-9A-Za-z_-.] it is at least + // indexed in etcd which gives us another string to our bow. + NameLabel = "unikorn-cloud.org/name" + + // DescriptionAnnotation is optionally attached to a resource to allow + // an unconstriained and verbose description about the resource. + DescriptionAnnotation = "unikorn-cloud.org/description" + // VersionLabel is a label applied to resources so we know the application // version that was used to create them (and thus what metadata is valid // for them). Metadata may be upgraded to a later version for any resource. diff --git a/openapi/common.spec.yaml b/pkg/openapi/common.spec.yaml similarity index 72% rename from openapi/common.spec.yaml rename to pkg/openapi/common.spec.yaml index dcbc378..5ba7f20 100644 --- a/openapi/common.spec.yaml +++ b/pkg/openapi/common.spec.yaml @@ -51,3 +51,23 @@ components: $ref: '#/components/schemas/resourceProvisioningStatus' resourceWriteMetadata: $ref: '#/components/schemas/resourceMetadata' + organizationScopedResourceReadMetadata: + allOf: + - $ref: '#/components/schemas/resourceReadMetadata' + - type: object + required: + - organizationId + properties: + organizationId: + description: The organization identifier the resource belongs to. + type: string + projectScopedResourceReadMetadata: + allOf: + - $ref: '#/components/schemas/organizationScopedResourceReadMetadata' + - type: object + required: + - projectId + properties: + projectId: + description: The project identifier the resource belongs to. + type: string diff --git a/openapi/schema.go b/pkg/openapi/schema.go similarity index 64% rename from openapi/schema.go rename to pkg/openapi/schema.go index 4212b27..9201182 100644 --- a/openapi/schema.go +++ b/pkg/openapi/schema.go @@ -1,6 +1,6 @@ // Package openapi provides primitives to interact with the openapi HTTP API. // -// Code generated by github.com/deepmap/oapi-codegen version v1.12.4 DO NOT EDIT. +// Code generated by github.com/deepmap/oapi-codegen version v1.16.2 DO NOT EDIT. package openapi import ( @@ -18,16 +18,17 @@ import ( // Base64 encoded, gzipped, json marshaled Swagger object var swaggerSpec = []string{ - "H4sIAAAAAAAC/+xUX2/TMBD/KqdjDyCl3XhBIi/TJF4mQExjAolR0CW+NGaOndnndaXKd0dOuza00cT2", - "zFtin+93vz/2CkvXtM6ylYD5CkNZc0P9500s2FsWDh+oYPOFTOS0rjiUXreincUcz+COjFbwPhbcF4NJ", - "1Wk1cgaybHVJxiwhBlZQOQ+eg4u+ZLDUcACpSaAkCwV/t9oqvmcF2oLUDIqECgo8xQxbEmGfIH9cn0ze", - "nk2+0eT37OVpvvub/JzOVifZm9fdoOLV6RFmKMuWMccgXts5dhk+TPGRhRLMIbPLhzmbTcmGaeJAxsDZ", - "xfmOi2dSAcgqWHgtHPqJvWvZi+Zw0Hwf66rmXa/BVgZS6wCu/+llpDhvklm9PklBWGipoXGeoXRW+F6m", - "Y3xTacI98lxhji+Od74fb0w/HnW868W6jdqzwvx63Wg2UPDCuzsdtLPazj8LSQzj/NpBHQQhYXAV0JZ3", - "GpttbBJItDfWLexaxO2p4S8rzFDx3jZ77zzOHvH7kkkNPSdjPlWYXz+uzEFaumy152/pmRLbK93wuACi", - "G+5t2zq9oAD9OVaJfeV8Q4I5KhKepPIxJxUbfg5Qf+4pQFqNt49W38ZB8/N3o4lrR3PxLyqPJGo/hTrZ", - "/5fko4C7JLjiF5eC3Sx74jXvb/Z0GKGv6Yb/fzee926kZW0rh7mNxmToWrbUasxx/cbXYb3T/QkAAP//", - "IMzP2JoGAAA=", + "H4sIAAAAAAAC/+xWUU/bMBD+K6cbD5uUFvYyaXlBSHtB2zQEaJPGuukaXxoPxw72mQJV/vvkUNq0BASI", + "x73F9vm++767z8oCC1c3zrKVgPkCQ1FxTd3neZyytywcvtCUzXcykdO+4lB43Yh2FnM8gEsyWsHnOOUu", + "GEyKTruRM5DrRhdkzDXEwApK58FzcNEXDJZqDiAVCRRkYcq/rLaKr1iBtiAVgyKhKQUeY4YNibBPkL/P", + "9kYfD0Y/aXQzebufr1ejP+PJYi/78L7tRbzb38EM5bphzDGI13aGbYbOz8jqG0o0TgrXsDpe1nXMpL6y", + "UAJPfMmYbyXmZwvc8Vxijm9215rtLgXb9UO322yBjXcNe9HcidqHPVT35TytGPoxoBVb0aVm3ymyEm/K", + "xtlZAHHj+/TaDD1fRO1ZYX62DTpZxbvpXy4E20mbpTrT4jW0eKK299VZ1vCQMMvjV9FkDTUsx13SPvvN", + "iu44Qb0MWTohzTgZAwdHh+vSPJMKQFbB3Gvh0E30BveN5EPsV7l6RxlIpQO4btHZjOKsTs3olEkOg7mW", + "CmrnGQpnha9kPOSHFJpwH+vr4IuwLWyXqK/gkXeXOmhntZ2dCEkMD3Z3FQdBSBhcCbTincpmG+sEEu25", + "dXN7K+LqVn/JCjNUvHXM3jvf6/iav38V9z8y24XnzhCnuuZhAUTXvDnQcwrQ3WOV2JfO1ySYoyLhUQof", + "6qRiwy8B6u49B0g/YNNo9UXsJT/8NDhxzeBcPEXlgYnankKd2r8h+SDggPezZ9q8c/a4P0I/ksP/vxsv", + "ezfStralw9xGYzJ0DVtqNOZ4+w9QhduT9l8AAAD//1B07gW6CAAA", } // GetSwagger returns the content of the embedded swagger specification file @@ -35,16 +36,16 @@ var swaggerSpec = []string{ func decodeSpec() ([]byte, error) { zipped, err := base64.StdEncoding.DecodeString(strings.Join(swaggerSpec, "")) if err != nil { - return nil, fmt.Errorf("error base64 decoding spec: %s", err) + return nil, fmt.Errorf("error base64 decoding spec: %w", err) } zr, err := gzip.NewReader(bytes.NewReader(zipped)) if err != nil { - return nil, fmt.Errorf("error decompressing spec: %s", err) + return nil, fmt.Errorf("error decompressing spec: %w", err) } var buf bytes.Buffer _, err = buf.ReadFrom(zr) if err != nil { - return nil, fmt.Errorf("error decompressing spec: %s", err) + return nil, fmt.Errorf("error decompressing spec: %w", err) } return buf.Bytes(), nil @@ -62,7 +63,7 @@ func decodeSpecCached() func() ([]byte, error) { // Constructs a synthetic filesystem for resolving external references when loading openapi specifications. func PathToRawSpec(pathToFile string) map[string]func() ([]byte, error) { - var res = make(map[string]func() ([]byte, error)) + res := make(map[string]func() ([]byte, error)) if len(pathToFile) > 0 { res[pathToFile] = rawSpec } @@ -76,12 +77,12 @@ func PathToRawSpec(pathToFile string) map[string]func() ([]byte, error) { // Externally referenced files must be embedded in the corresponding golang packages. // Urls can be supported but this task was out of the scope. func GetSwagger() (swagger *openapi3.T, err error) { - var resolvePath = PathToRawSpec("") + resolvePath := PathToRawSpec("") loader := openapi3.NewLoader() loader.IsExternalRefsAllowed = true loader.ReadFromURIFunc = func(loader *openapi3.Loader, url *url.URL) ([]byte, error) { - var pathToFile = url.String() + pathToFile := url.String() pathToFile = path.Clean(pathToFile) getSpec, ok := resolvePath[pathToFile] if !ok { diff --git a/openapi/types.go b/pkg/openapi/types.go similarity index 51% rename from openapi/types.go rename to pkg/openapi/types.go index c873d9d..de71b0e 100644 --- a/openapi/types.go +++ b/pkg/openapi/types.go @@ -1,6 +1,6 @@ // Package openapi provides primitives to interact with the openapi HTTP API. // -// Code generated by github.com/deepmap/oapi-codegen version v1.12.4 DO NOT EDIT. +// Code generated by github.com/deepmap/oapi-codegen version v1.16.2 DO NOT EDIT. package openapi import ( @@ -20,6 +20,59 @@ const ( // indexed in the database. type KubernetesLabelValue = string +// OrganizationScopedResourceReadMetadata defines model for organizationScopedResourceReadMetadata. +type OrganizationScopedResourceReadMetadata struct { + // CreationTime The time the resource was created. + CreationTime time.Time `json:"creationTime"` + + // DeletionTime The time the resource was deleted. + DeletionTime *time.Time `json:"deletionTime,omitempty"` + + // Description The resource description, this optionally augments the name with more context. + Description *string `json:"description,omitempty"` + + // Id The unique resource ID. + Id string `json:"id"` + + // Name A valid Kubenetes label value, typically used for resource names that can be + // indexed in the database. + Name KubernetesLabelValue `json:"name"` + + // OrganizationId The organization identifier the resource belongs to. + OrganizationId string `json:"organizationId"` + + // ProvisioningStatus The provisioning state of a resource. + ProvisioningStatus ResourceProvisioningStatus `json:"provisioningStatus"` +} + +// ProjectScopedResourceReadMetadata defines model for projectScopedResourceReadMetadata. +type ProjectScopedResourceReadMetadata struct { + // CreationTime The time the resource was created. + CreationTime time.Time `json:"creationTime"` + + // DeletionTime The time the resource was deleted. + DeletionTime *time.Time `json:"deletionTime,omitempty"` + + // Description The resource description, this optionally augments the name with more context. + Description *string `json:"description,omitempty"` + + // Id The unique resource ID. + Id string `json:"id"` + + // Name A valid Kubenetes label value, typically used for resource names that can be + // indexed in the database. + Name KubernetesLabelValue `json:"name"` + + // OrganizationId The organization identifier the resource belongs to. + OrganizationId string `json:"organizationId"` + + // ProjectId The project identifier the resource belongs to. + ProjectId string `json:"projectId"` + + // ProvisioningStatus The provisioning state of a resource. + ProvisioningStatus ResourceProvisioningStatus `json:"provisioningStatus"` +} + // ResourceMetadata Resource metadata valid for all API resource reads and writes. type ResourceMetadata struct { // Description The resource description, this optionally augments the name with more context. diff --git a/pkg/server/conversion/conversion.go b/pkg/server/conversion/conversion.go new file mode 100644 index 0000000..ba9873b --- /dev/null +++ b/pkg/server/conversion/conversion.go @@ -0,0 +1,109 @@ +/* +Copyright 2024 the Unikorn Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package conversion + +import ( + "unicode" + + unikornv1 "github.com/unikorn-cloud/core/pkg/apis/unikorn/v1alpha1" + "github.com/unikorn-cloud/core/pkg/constants" + "github.com/unikorn-cloud/core/pkg/openapi" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/util/uuid" +) + +// ConvertStatusCondition translates from Kubernetes status conditions to API ones. +func ConvertStatusCondition(in *unikornv1.Condition) openapi.ResourceProvisioningStatus { + //nolint:exhaustive + switch in.Reason { + case unikornv1.ConditionReasonProvisioning: + return openapi.Provisioning + case unikornv1.ConditionReasonProvisioned: + return openapi.Provisioned + case unikornv1.ConditionReasonErrored: + return openapi.Error + case unikornv1.ConditionReasonDeprovisioning: + return openapi.Deprovisioning + default: + return openapi.Unknown + } +} + +// ResourceReadMetadata extracts generic metadata from a resource for GET APIs. +func ResourceReadMetadata(in metav1.Object, status openapi.ResourceProvisioningStatus) openapi.ResourceReadMetadata { + labels := in.GetLabels() + annotations := in.GetAnnotations() + + out := openapi.ResourceReadMetadata{ + Id: in.GetName(), + Name: labels[constants.NameLabel], + CreationTime: in.GetCreationTimestamp().Time, + ProvisioningStatus: status, + } + + if v, ok := annotations[constants.DescriptionAnnotation]; ok { + out.Description = &v + } + + if v := in.GetDeletionTimestamp(); v != nil { + out.DeletionTime = &v.Time + } + + return out +} + +// generateResourceID creates a valid Kubernetes name from a UUID. +func generateResourceID() string { + for { + // NOTE: Kubernetes UUIDs are based on version 4, aka random, + // so the first character will be a letter eventually, like + // a 6/16 chance: tl;dr infinite loops are... improbable. + if id := uuid.NewUUID(); unicode.IsLetter(rune(id[0])) { + return string(id) + } + } +} + +// ObjectMetadata creates Kubernetes object metadata from generic request metadata. +func ObjectMetadata(in *openapi.ResourceWriteMetadata, namespace string, labels map[string]string) metav1.ObjectMeta { + out := metav1.ObjectMeta{ + Namespace: namespace, + Name: generateResourceID(), + Labels: map[string]string{ + constants.NameLabel: in.Name, + }, + } + + for k, v := range labels { + out.Labels[k] = v + } + + if in.Description != nil { + out.Annotations = map[string]string{ + constants.DescriptionAnnotation: *in.Description, + } + } + + return out +} + +// UpdateObjectMetadata abstracts away metadata updates e.g. name and description changes. +func UpdateObjectMetadata(out, in metav1.Object) { + out.SetLabels(in.GetLabels()) + out.SetAnnotations(in.GetAnnotations()) +}