diff --git a/.changes/v2.22.0/557-features.md b/.changes/v2.22.0/557-features.md new file mode 100644 index 000000000..a3f095820 --- /dev/null +++ b/.changes/v2.22.0/557-features.md @@ -0,0 +1,2 @@ +* Added metadata support to Runtime Defined Entities with methods `rde.GetMetadataByKey`, `rde.GetMetadataById` `rde.GetMetadata`, + `rde.AddMetadata` and generic metadata methods `openApiMetadataEntry.Update` and `openApiMetadataEntry.Delete` [GH-557] diff --git a/govcd/metadata_openapi.go b/govcd/metadata_openapi.go new file mode 100644 index 000000000..2fd4ef8d8 --- /dev/null +++ b/govcd/metadata_openapi.go @@ -0,0 +1,252 @@ +/* + * Copyright 2023 VMware, Inc. All rights reserved. Licensed under the Apache v2 License. + */ + +package govcd + +import ( + "fmt" + "github.com/vmware/go-vcloud-director/v2/types/v56" + "net/url" +) + +// OpenApiMetadataEntry is a wrapper object for types.OpenApiMetadataEntry +type OpenApiMetadataEntry struct { + MetadataEntry *types.OpenApiMetadataEntry + client *Client + Etag string // Allows concurrent operations with metadata + href string // This is the HREF of the given metadata entry + parentEndpoint string // This is the endpoint of the object that has the metadata entries +} + +// --------------------------------------------------------------------------------------------------------------------- +// Specific objects compatible with metadata +// --------------------------------------------------------------------------------------------------------------------- + +// GetMetadata returns all the metadata from a DefinedEntity. +// NOTE: The obtained metadata doesn't have ETags, use GetMetadataById or GetMetadataByKey to obtain a ETag for a specific entry. +func (rde *DefinedEntity) GetMetadata() ([]*OpenApiMetadataEntry, error) { + endpoint := types.OpenApiPathVersion1_0_0 + types.OpenApiEndpointRdeEntities + return getAllOpenApiMetadata(rde.client, endpoint, rde.DefinedEntity.ID, nil) +} + +// GetMetadataByKey returns a unique DefinedEntity metadata entry corresponding to the given domain, namespace and key. +// The domain and namespace are only needed when there's more than one entry with the same key. +// This is a more costly operation than GetMetadataById due to ETags, so use that preferred option whenever possible. +func (rde *DefinedEntity) GetMetadataByKey(domain, namespace, key string) (*OpenApiMetadataEntry, error) { + endpoint := types.OpenApiPathVersion1_0_0 + types.OpenApiEndpointRdeEntities + return getOpenApiMetadataByKey(rde.client, endpoint, rde.DefinedEntity.ID, domain, namespace, key) +} + +// GetMetadataById returns a unique DefinedEntity metadata entry corresponding to the given domain, namespace and key. +// The domain and namespace are only needed when there's more than one entry with the same key. +func (rde *DefinedEntity) GetMetadataById(id string) (*OpenApiMetadataEntry, error) { + endpoint := types.OpenApiPathVersion1_0_0 + types.OpenApiEndpointRdeEntities + return getOpenApiMetadataById(rde.client, endpoint, rde.DefinedEntity.ID, id) +} + +// AddMetadata adds metadata to the receiver DefinedEntity. +func (rde *DefinedEntity) AddMetadata(metadataEntry types.OpenApiMetadataEntry) (*OpenApiMetadataEntry, error) { + endpoint := types.OpenApiPathVersion1_0_0 + types.OpenApiEndpointRdeEntities + return addOpenApiMetadata(rde.client, endpoint, rde.DefinedEntity.ID, metadataEntry) +} + +// --------------------------------------------------------------------------------------------------------------------- +// Metadata Entry methods for OpenAPI metadata +// --------------------------------------------------------------------------------------------------------------------- + +// Update updates the metadata value from the receiver entry. +// Only the value and persistence of the entry can be updated. Re-create the entry in case you want to modify any of the other fields. +func (entry *OpenApiMetadataEntry) Update(value interface{}, persistent bool) error { + if entry.MetadataEntry.ID == "" { + return fmt.Errorf("ID of the receiver metadata entry is empty") + } + + payload := types.OpenApiMetadataEntry{ + ID: entry.MetadataEntry.ID, + IsPersistent: persistent, + IsReadOnly: entry.MetadataEntry.IsReadOnly, + KeyValue: types.OpenApiMetadataKeyValue{ + Domain: entry.MetadataEntry.KeyValue.Domain, + Key: entry.MetadataEntry.KeyValue.Key, + Value: types.OpenApiMetadataTypedValue{ + Value: value, + Type: entry.MetadataEntry.KeyValue.Value.Type, + }, + Namespace: entry.MetadataEntry.KeyValue.Namespace, + }, + } + + apiVersion, err := entry.client.getOpenApiHighestElevatedVersion(entry.parentEndpoint) + if err != nil { + return err + } + + urlRef, err := url.ParseRequestURI(entry.href) + if err != nil { + return err + } + + headers, err := entry.client.OpenApiPutItemAndGetHeaders(apiVersion, urlRef, nil, payload, entry.MetadataEntry, map[string]string{"If-Match": entry.Etag}) + if err != nil { + return err + } + entry.Etag = headers.Get("Etag") + return nil +} + +// Delete deletes the receiver metadata entry. +func (entry *OpenApiMetadataEntry) Delete() error { + if entry.MetadataEntry.ID == "" { + return fmt.Errorf("ID of the receiver metadata entry is empty") + } + + apiVersion, err := entry.client.getOpenApiHighestElevatedVersion(entry.parentEndpoint) + if err != nil { + return err + } + + urlRef, err := url.ParseRequestURI(entry.href) + if err != nil { + return err + } + + err = entry.client.OpenApiDeleteItem(apiVersion, urlRef, nil, nil) + if err != nil { + return err + } + + entry.Etag = "" + entry.parentEndpoint = "" + entry.href = "" + entry.MetadataEntry = &types.OpenApiMetadataEntry{} + return nil +} + +// --------------------------------------------------------------------------------------------------------------------- +// OpenAPI Metadata private functions +// --------------------------------------------------------------------------------------------------------------------- + +// getAllOpenApiMetadata is a generic function to retrieve all metadata from any VCD object using its ID and the given OpenAPI endpoint. +// It supports query parameters to input, for example, filtering options. +func getAllOpenApiMetadata(client *Client, endpoint, objectId string, queryParameters url.Values) ([]*OpenApiMetadataEntry, error) { + apiVersion, err := client.getOpenApiHighestElevatedVersion(endpoint) + if err != nil { + return nil, err + } + + urlRef, err := client.OpenApiBuildEndpoint(endpoint, fmt.Sprintf("%s/metadata", objectId)) + if err != nil { + return nil, err + } + + allMetadata := []*types.OpenApiMetadataEntry{{}} + err = client.OpenApiGetAllItems(apiVersion, urlRef, queryParameters, &allMetadata, nil) + if err != nil { + return nil, err + } + + // Wrap all type.OpenApiMetadataEntry into OpenApiMetadataEntry types with client + results := make([]*OpenApiMetadataEntry, len(allMetadata)) + for i := range allMetadata { + results[i] = &OpenApiMetadataEntry{ + MetadataEntry: allMetadata[i], + client: client, + href: fmt.Sprintf("%s/%s", urlRef.String(), allMetadata[i].ID), + parentEndpoint: endpoint, + } + } + + return results, nil +} + +// getOpenApiMetadataByKey is a generic function to retrieve a unique metadata entry from any VCD object using its domain, namespace and key. +// The domain and namespace are only needed when there's more than one entry with the same key. +func getOpenApiMetadataByKey(client *Client, endpoint, objectId string, domain, namespace, key string) (*OpenApiMetadataEntry, error) { + queryParameters := url.Values{} + // As for now, the filter only supports filtering by key + queryParameters.Add("filter", fmt.Sprintf("keyValue.key==%s", key)) + metadata, err := getAllOpenApiMetadata(client, endpoint, objectId, queryParameters) + if err != nil { + return nil, err + } + + if len(metadata) == 0 { + return nil, fmt.Errorf("%s could not find the metadata associated to object %s", ErrorEntityNotFound, objectId) + } + + // There's more than one entry with same key, the namespace and domain need to be compared to be able to filter. + if len(metadata) > 1 { + var filteredMetadata []*OpenApiMetadataEntry + for _, entry := range metadata { + if entry.MetadataEntry.KeyValue.Namespace == namespace && entry.MetadataEntry.KeyValue.Domain == domain { + filteredMetadata = append(filteredMetadata, entry) + } + } + if len(filteredMetadata) > 1 { + return nil, fmt.Errorf("found %d metadata entries associated to object %s", len(filteredMetadata), objectId) + } + // Required to retrieve an ETag + return getOpenApiMetadataById(client, endpoint, objectId, filteredMetadata[0].MetadataEntry.ID) + } + + // Required to retrieve an ETag + return getOpenApiMetadataById(client, endpoint, objectId, metadata[0].MetadataEntry.ID) +} + +// getOpenApiMetadataById is a generic function to retrieve a unique metadata entry from any VCD object using its unique ID. +func getOpenApiMetadataById(client *Client, endpoint, objectId, metadataId string) (*OpenApiMetadataEntry, error) { + if metadataId == "" { + return nil, fmt.Errorf("input metadata entry ID is empty") + } + + apiVersion, err := client.getOpenApiHighestElevatedVersion(endpoint) + if err != nil { + return nil, err + } + + urlRef, err := client.OpenApiBuildEndpoint(endpoint, fmt.Sprintf("%s/metadata/%s", objectId, metadataId)) + if err != nil { + return nil, err + } + + response := &OpenApiMetadataEntry{ + MetadataEntry: &types.OpenApiMetadataEntry{}, + client: client, + href: urlRef.String(), + parentEndpoint: endpoint, + } + + headers, err := client.OpenApiGetItemAndHeaders(apiVersion, urlRef, nil, response.MetadataEntry, nil) + if err != nil { + return nil, err + } + response.Etag = headers.Get("Etag") + return response, nil +} + +// addOpenApiMetadata adds one metadata entry to the VCD object with given ID +func addOpenApiMetadata(client *Client, endpoint, objectId string, metadataEntry types.OpenApiMetadataEntry) (*OpenApiMetadataEntry, error) { + apiVersion, err := client.getOpenApiHighestElevatedVersion(endpoint) + if err != nil { + return nil, err + } + + urlRef, err := client.OpenApiBuildEndpoint(endpoint, fmt.Sprintf("%s/metadata", objectId)) + if err != nil { + return nil, err + } + + response := &OpenApiMetadataEntry{ + client: client, + MetadataEntry: &types.OpenApiMetadataEntry{}, + parentEndpoint: endpoint, + } + headers, err := client.OpenApiPostItemAndGetHeaders(apiVersion, urlRef, nil, metadataEntry, response.MetadataEntry, nil) + if err != nil { + return nil, err + } + response.Etag = headers.Get("Etag") + response.href = fmt.Sprintf("%s/%s", urlRef.String(), response.MetadataEntry.ID) + return response, nil +} diff --git a/govcd/metadata_openapi_test.go b/govcd/metadata_openapi_test.go new file mode 100644 index 000000000..9e49ee633 --- /dev/null +++ b/govcd/metadata_openapi_test.go @@ -0,0 +1,257 @@ +//go:build metadata || openapi || rde || functional || ALL + +/* + * Copyright 2023 VMware, Inc. All rights reserved. Licensed under the Apache v2 License. + */ + +package govcd + +import ( + "fmt" + "github.com/vmware/go-vcloud-director/v2/types/v56" + . "gopkg.in/check.v1" + "regexp" +) + +func (vcd *TestVCD) TestRdeMetadata(check *C) { + fmt.Printf("Running: %s\n", check.TestName()) + + // This RDE type comes out of the box in VCD + rdeType, err := vcd.client.GetRdeType("vmware", "tkgcluster", "1.0.0") + check.Assert(err, IsNil) + check.Assert(rdeType, NotNil) + + rde, err := rdeType.CreateRde(types.DefinedEntity{ + Name: check.TestName(), + Entity: map[string]interface{}{"foo": "bar"}, // We don't care about schema correctness here + }, nil) + check.Assert(err, IsNil) + check.Assert(rde, NotNil) + + err = rde.Resolve() // State will be RESOLUTION_ERROR, but we don't care. We resolve to be able to delete it later. + check.Assert(err, IsNil) + + // The RDE can't be deleted until rde.Resolve() is called + AddToCleanupListOpenApi(rde.DefinedEntity.ID, check.TestName(), types.OpenApiPathVersion1_0_0+types.OpenApiEndpointRdeEntities+rde.DefinedEntity.ID) + + testOpenApiMetadataCRUDActions(rde, check) + + err = rde.Delete() + check.Assert(err, IsNil) +} + +// openApiMetadataCompatible allows centralizing and generalizing the tests for OpenAPI metadata compatible resources. +type openApiMetadataCompatible interface { + GetMetadata() ([]*OpenApiMetadataEntry, error) + GetMetadataByKey(domain, namespace, key string) (*OpenApiMetadataEntry, error) + GetMetadataById(id string) (*OpenApiMetadataEntry, error) + AddMetadata(metadataEntry types.OpenApiMetadataEntry) (*OpenApiMetadataEntry, error) +} + +type openApiMetadataTest struct { + Key string + Value interface{} // The type depends on the Type attribute + UpdateValue interface{} + Namespace string + Type string + IsReadOnly bool + IsPersistent bool + Domain string + ExpectErrorOnFirstAddMatchesRegex string +} + +// testOpenApiMetadataCRUDActions performs a complete test of all use cases that metadata in OpenAPI can have, +// for an OpenAPI metadata compatible resource. +func testOpenApiMetadataCRUDActions(resource openApiMetadataCompatible, check *C) { + // Check how much metadata exists + metadata, err := resource.GetMetadata() + check.Assert(err, IsNil) + check.Assert(metadata, NotNil) + existingMetaDataCount := len(metadata) + + var testCases = []openApiMetadataTest{ + { + Key: "stringKey", + Value: "stringValue", + UpdateValue: "stringValueUpdated", + Type: types.OpenApiMetadataStringEntry, + IsReadOnly: false, + Domain: "TENANT", + Namespace: "foo", + }, + { + Key: "numberKey", + Value: "notANumber", + Type: types.OpenApiMetadataNumberEntry, + IsReadOnly: false, + Domain: "TENANT", + Namespace: "foo", + ExpectErrorOnFirstAddMatchesRegex: "notANumber", + }, + { + Key: "numberKey", + Value: float64(1), + UpdateValue: float64(42), + Type: types.OpenApiMetadataNumberEntry, + IsReadOnly: false, + Domain: "TENANT", + Namespace: "foo", + }, + { + Key: "negativeNumberKey", + Value: float64(-1), + UpdateValue: float64(-42), + Type: types.OpenApiMetadataNumberEntry, + IsReadOnly: false, + Domain: "TENANT", + Namespace: "foo", + }, + { + Key: "boolKey", + Value: "notABool", + Type: types.OpenApiMetadataBooleanEntry, + IsReadOnly: false, + Domain: "TENANT", + Namespace: "foo", + ExpectErrorOnFirstAddMatchesRegex: "notABool", + }, + { + Key: "boolKey", + Value: true, + UpdateValue: false, + Type: types.OpenApiMetadataBooleanEntry, + IsReadOnly: false, + Domain: "TENANT", + Namespace: "foo", + }, + { + Key: "providerKey", + Value: "providerValue", + UpdateValue: "providerValueUpdated", + Type: types.OpenApiMetadataStringEntry, + IsReadOnly: false, + Domain: "PROVIDER", + Namespace: "foo", + }, + { + Key: "readOnlyProviderKey", + Value: "readOnlyProviderValue", + Type: types.OpenApiMetadataStringEntry, + IsReadOnly: true, + Domain: "PROVIDER", + Namespace: "foo", + ExpectErrorOnFirstAddMatchesRegex: "VCD_META_CRUD_INVALID_FLAG", + }, + { + Key: "readOnlyTenantKey", + Value: "readOnlyTenantValue", + Type: types.OpenApiMetadataStringEntry, + IsReadOnly: true, + Domain: "TENANT", + Namespace: "foo", + }, + { + Key: "persistentKey", + Value: "persistentValue", + Type: types.OpenApiMetadataStringEntry, + IsReadOnly: false, + IsPersistent: true, + Domain: "TENANT", + Namespace: "foo", + }, + } + + for _, testCase := range testCases { + + var createdEntry *OpenApiMetadataEntry + createdEntry, err = resource.AddMetadata(types.OpenApiMetadataEntry{ + KeyValue: types.OpenApiMetadataKeyValue{ + Domain: testCase.Domain, + Key: testCase.Key, + Namespace: testCase.Namespace, + Value: types.OpenApiMetadataTypedValue{ + Type: testCase.Type, + Value: testCase.Value, + }, + }, + IsPersistent: testCase.IsPersistent, + IsReadOnly: testCase.IsReadOnly, + }) + if testCase.ExpectErrorOnFirstAddMatchesRegex != "" { + p := regexp.MustCompile("(?s)" + testCase.ExpectErrorOnFirstAddMatchesRegex) + check.Assert(p.MatchString(err.Error()), Equals, true) + continue + } + check.Assert(err, IsNil) + check.Assert(createdEntry, NotNil) + check.Assert(createdEntry.href, Not(Equals), "") + check.Assert(createdEntry.Etag, Not(Equals), "") + check.Assert(createdEntry.parentEndpoint, Not(Equals), "") + check.Assert(createdEntry.MetadataEntry, NotNil) + check.Assert(createdEntry.MetadataEntry.ID, Not(Equals), "") + + // Check if metadata was added correctly + metadata, err = resource.GetMetadata() + check.Assert(err, IsNil) + check.Assert(len(metadata), Equals, existingMetaDataCount+1) + found := false + for _, entry := range metadata { + if entry.MetadataEntry.ID == createdEntry.MetadataEntry.ID { + check.Assert(*entry.MetadataEntry, DeepEquals, *createdEntry.MetadataEntry) + found = true + break + } + } + check.Assert(found, Equals, true) + + metadataByKey, err := resource.GetMetadataByKey(createdEntry.MetadataEntry.KeyValue.Domain, createdEntry.MetadataEntry.KeyValue.Namespace, createdEntry.MetadataEntry.KeyValue.Key) + check.Assert(err, IsNil) + check.Assert(metadataByKey, NotNil) + check.Assert(metadataByKey.MetadataEntry, NotNil) + check.Assert(*metadataByKey.MetadataEntry, DeepEquals, *createdEntry.MetadataEntry) + check.Assert(metadataByKey.Etag, Equals, createdEntry.Etag) + check.Assert(metadataByKey.parentEndpoint, Equals, createdEntry.parentEndpoint) + check.Assert(metadataByKey.href, Equals, createdEntry.href) + + metadataById, err := resource.GetMetadataById(metadataByKey.MetadataEntry.ID) + check.Assert(err, IsNil) + check.Assert(metadataById, NotNil) + check.Assert(metadataById.MetadataEntry, NotNil) + check.Assert(*metadataById.MetadataEntry, DeepEquals, *metadataById.MetadataEntry) + check.Assert(metadataById.Etag, Equals, metadataByKey.Etag) + check.Assert(metadataById.parentEndpoint, Equals, metadataByKey.parentEndpoint) + check.Assert(metadataById.href, Equals, metadataByKey.href) + + if testCase.UpdateValue != nil { + oldEtag := metadataById.Etag + err = metadataById.Update(testCase.UpdateValue, !metadataById.MetadataEntry.IsPersistent) + check.Assert(err, IsNil) + check.Assert(metadataById, NotNil) + check.Assert(metadataById.MetadataEntry, NotNil) + // Changed fields + check.Assert(metadataById.MetadataEntry.IsPersistent, Equals, !metadataByKey.MetadataEntry.IsPersistent) + check.Assert(metadataById.MetadataEntry.KeyValue.Value.Value, Equals, testCase.UpdateValue) + // Non-changed fields + check.Assert(metadataById.MetadataEntry.ID, Equals, metadataByKey.MetadataEntry.ID) + check.Assert(metadataById.MetadataEntry.KeyValue.Value.Type, Equals, metadataByKey.MetadataEntry.KeyValue.Value.Type) + check.Assert(metadataById.MetadataEntry.KeyValue.Namespace, Equals, metadataByKey.MetadataEntry.KeyValue.Namespace) + check.Assert(metadataById.MetadataEntry.IsReadOnly, Equals, metadataByKey.MetadataEntry.IsReadOnly) + check.Assert(metadataById.Etag, Not(Equals), oldEtag) // ETag should be refreshed as we did an update + check.Assert(metadataById.parentEndpoint, Equals, metadataByKey.parentEndpoint) + check.Assert(metadataById.href, Equals, metadataByKey.href) + } + + err = metadataById.Delete() + check.Assert(err, IsNil) + check.Assert(*metadataById.MetadataEntry, DeepEquals, types.OpenApiMetadataEntry{}) + check.Assert(metadataById.Etag, Equals, "") + check.Assert(metadataById.href, Equals, "") + check.Assert(metadataById.parentEndpoint, Equals, "") + + // Check if metadata was deleted correctly + deletedMetadata, err := resource.GetMetadataById(metadataByKey.MetadataEntry.ID) + check.Assert(err, NotNil) + check.Assert(deletedMetadata, IsNil) + check.Assert(true, Equals, ContainsNotFound(err)) + } +} diff --git a/types/v56/constants.go b/types/v56/constants.go index d46537369..a00fe1bf0 100644 --- a/types/v56/constants.go +++ b/types/v56/constants.go @@ -605,6 +605,10 @@ const ( MetadataReadOnlyVisibility string = "READONLY" MetadataHiddenVisibility string = "PRIVATE" MetadataReadWriteVisibility string = "READWRITE" + + OpenApiMetadataStringEntry string = "StringEntry" + OpenApiMetadataNumberEntry string = "NumberEntry" + OpenApiMetadataBooleanEntry string = "BoolEntry" ) const ( diff --git a/types/v56/openapi.go b/types/v56/openapi.go index 89f3a52ab..76b8ab337 100644 --- a/types/v56/openapi.go +++ b/types/v56/openapi.go @@ -650,3 +650,25 @@ type VcenterDistributedSwitch struct { BackingRef OpenApiReference `json:"backingRef"` VirtualCenter OpenApiReference `json:"virtualCenter"` } + +// OpenApiMetadataEntry represents a metadata entry in VCD. +type OpenApiMetadataEntry struct { + ID string `json:"id,omitempty"` // UUID for OpenApiMetadataEntry. This is immutable + IsPersistent bool `json:"persistent,omitempty"` // Persistent entries can be copied over on some entity operation, for example: Creating a copy of an Org VDC, capturing a vApp to a template, instantiating a catalog item as a VM, etc. + IsReadOnly bool `json:"readOnly,omitempty"` // The kind of level of access organizations of the entry’s domain have + KeyValue OpenApiMetadataKeyValue `json:"keyValue,omitempty"` // Contains core metadata entry data +} + +// OpenApiMetadataKeyValue contains core metadata entry data. +type OpenApiMetadataKeyValue struct { + Domain string `json:"domain,omitempty"` // Only meaningful for providers. Allows them to share entries with their tenants. Currently, accepted values are: `TENANT`, `PROVIDER`, where that is the ascending sort order of the enumeration. + Key string `json:"key,omitempty"` // Key of the metadata entry + Value OpenApiMetadataTypedValue `json:"value,omitempty"` // Value of the metadata entry + Namespace string `json:"namespace,omitempty"` // Namespace of the metadata entry +} + +// OpenApiMetadataTypedValue the type and value of the metadata entry. +type OpenApiMetadataTypedValue struct { + Value interface{} `json:"value,omitempty"` // The Value is anything because it depends on the Type field. + Type string `json:"type,omitempty"` +}