v2/internal/testcommon/samples_tester.go (323 lines of code) (raw):
/*
Copyright (c) Microsoft Corporation.
Licensed under the MIT license.
*/
package testcommon
import (
"bytes"
"encoding/json"
"fmt"
"os"
"path/filepath"
"reflect"
"regexp"
"strings"
"github.com/google/uuid"
"github.com/rotisserie/eris"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/util/yaml"
"sigs.k8s.io/controller-runtime/pkg/client"
"github.com/Azure/azure-service-operator/v2/internal/reflecthelpers"
"github.com/Azure/azure-service-operator/v2/internal/resolver"
"github.com/Azure/azure-service-operator/v2/pkg/genruntime"
)
const (
refsPackage = "refs"
defaultResourceGroup = "aso-sample-rg"
)
// Regex to match '/00000000-0000-0000-0000-000000000000' strings, to replace with the subscriptionID
var subRegex = regexp.MustCompile(`/([0]+-?)+`)
// An empty GUID, used to replace the subscriptionID and tenantID in the sample files
var emptyGUID = uuid.Nil.String()
// exclusions slice contains RESOURCES to exclude from test
var exclusions = []string{
// Excluding webtest as it contains hidden link reference
"webtest",
// Excluding dbformysql/user as is not an ARM resource
"user",
// Excluding sql serversadministrator and serversazureadonlyauthentication as they both require AAD auth
// which the samples recordings aren't using.
"serversadministrator",
"serversazureadonlyauthentication",
"serversfailovergroup", // Requires creating multiple linked SQL servers which is hard to do in the samples
// TODO: Unable to test diskencryptionsets sample since it requires keyvault/key URI.
// TODO: we don't support Keyvault/Keys to automate the process
"diskencryptionset",
// Excluding APIM Product and Subscription as we need to pass deleteSubscription flag to delete the subscription
// when we delete the Product. https://github.com/Azure/azure-service-operator/issues/3408
"api",
"apiversionset",
"product",
"subscription",
"productpolicy",
"productapi",
// Excluding cdn secret as it requires KV secrets
"secret",
// Excluding SignalR CustomDomain and CustomCertificate becaues they require KV secrets/certs
"customdomain",
"customcertificate",
// [Issue #3091] Exclude backupvaultsbackupinstance as it requires role assignments to be created after backup instance is created to make it land into protection configured state.
"backupvaultsbackupinstance",
}
type SamplesTester struct {
noSpaceNamer ResourceNamer
scheme *runtime.Scheme
groupVersionPath string
namespace string
useRandomName bool
rgName string
azureSubscription string
azureTenant string
}
type SampleObject struct {
SamplesMap map[string]client.Object
RefsMap map[string]client.Object
}
func (s *SampleObject) HasSamples() bool {
return len(s.SamplesMap) > 0
}
func NewSampleObject() *SampleObject {
return &SampleObject{
SamplesMap: make(map[string]client.Object),
RefsMap: make(map[string]client.Object),
}
}
func NewSamplesTester(
noSpaceNamer ResourceNamer,
scheme *runtime.Scheme,
groupVersionPath string,
namespace string,
useRandomName bool,
rgName string,
azureSubscription string,
azureTenant string,
) *SamplesTester {
return &SamplesTester{
noSpaceNamer: noSpaceNamer,
scheme: scheme,
groupVersionPath: groupVersionPath,
namespace: namespace,
useRandomName: useRandomName,
rgName: rgName,
azureSubscription: azureSubscription,
azureTenant: azureTenant,
}
}
func (t *SamplesTester) LoadSamples() (*SampleObject, error) {
samples := NewSampleObject()
err := filepath.Walk(t.groupVersionPath,
func(filePath string, info os.FileInfo, err error) error {
if !info.IsDir() && !IsSampleExcluded(filePath, exclusions) {
sample, err := t.getObjectFromFile(filePath)
if err != nil {
return eris.Wrapf(err, "loading sample from %s", filePath)
}
sample.SetNamespace(t.namespace)
if filepath.Base(filepath.Dir(filePath)) == refsPackage {
// We don't change name for dependencies as they have fixed refs which we can't access inside the object
// need to make sure we have right names for the refs in samples
t.handleObject(sample, samples.RefsMap)
} else {
if t.useRandomName {
sample.SetName(t.noSpaceNamer.GenerateName(""))
}
t.handleObject(sample, samples.SamplesMap)
}
}
return nil
})
if err != nil {
return nil, eris.Wrapf(err, "loading samples in %s", t.groupVersionPath)
}
// We add ownership once we have all the resources in the map
err = t.setSamplesOwnershipAndReferences(samples.SamplesMap, samples.RefsMap)
if err != nil {
return nil, eris.Wrap(err, "updating ownership of samples")
}
err = t.setRefsOwnershipAndReferences(samples.RefsMap)
if err != nil {
return nil, eris.Wrap(err, "updating ownership of refs")
}
return samples, nil
}
// handleObject handles the sample object by adding it into the samples map. If key already exists, then we append a
// random string to the key and add it to the map so that we don't overwrite the sample. As keys are only used to find
// if owner Kind actually exist in the map, so it should be fine to append random string here.
func (t *SamplesTester) handleObject(sample client.Object, samples map[string]client.Object) {
kind := sample.GetObjectKind().GroupVersionKind().Kind
_, found := samples[kind]
if found {
kind = kind + t.noSpaceNamer.makeRandomStringOfLength(5, t.noSpaceNamer.runes)
}
samples[kind] = sample
}
func (t *SamplesTester) getObjectFromFile(path string) (client.Object, error) {
jsonMap := make(map[string]interface{})
byteData, err := os.ReadFile(path)
if err != nil {
return nil, err
}
jsonBytes, err := yaml.ToJSON(byteData)
if err != nil {
return nil, err
}
err = json.Unmarshal(jsonBytes, &jsonMap)
if err != nil {
return nil, err
}
// We need unstructured object here to fetch the correct GVK for the object
unstructuredObj := unstructured.Unstructured{Object: jsonMap}
obj, err := genruntime.NewObjectFromExemplar(&unstructuredObj, t.scheme)
if err != nil {
return nil, err
}
decoder := json.NewDecoder(bytes.NewReader(jsonBytes))
decoder.DisallowUnknownFields()
err = decoder.Decode(obj)
if err != nil {
return nil, eris.Wrapf(err, "while decoding %s", path)
}
return obj, nil
}
func (t *SamplesTester) setSamplesOwnershipAndReferences(samples map[string]client.Object, refs map[string]client.Object) error {
for gk, sample := range samples {
asoType, ok := sample.(genruntime.ARMMetaObject)
if !ok {
continue
}
// We don't apply ownership to the resources which have no owner
if asoType.Owner() == nil {
continue
}
// We only set the owner name if Owner.Kind is ResourceGroup(as we have random rg names) or if we're using random names for resources.
// Otherwise, we let it be the same as on samples.
var ownersName string
if asoType.Owner().Kind == resolver.ResourceGroupKind {
ownersName = t.rgName
} else if t.useRandomName {
// Check if the owner exists in refs, then continue. We don't use random names for refs so its correct anyway.
_, found := refs[asoType.Owner().Kind]
if found {
continue
}
var owner client.Object
owner, found = samples[asoType.Owner().Kind]
if !found {
return fmt.Errorf("owner: %s, does not exist for resource '%s'", asoType.Owner().Kind, gk)
}
ownersName = owner.GetName()
}
if ownersName != "" {
asoType = setOwnersName(asoType, ownersName)
}
err := t.updateFieldsForTest(asoType)
if err != nil {
return err
}
}
return nil
}
func (t *SamplesTester) setRefsOwnershipAndReferences(samples map[string]client.Object) error {
for _, sample := range samples {
asoType, ok := sample.(genruntime.ARMMetaObject)
if !ok {
continue
}
// We don't apply ownership to the resources which have no owner
if asoType.Owner() == nil {
continue
}
// We only set the owner name if Owner.Kind is ResourceGroup(as we have random rg names) or if we're using random names for resources.
// Otherwise, we let it be the same as on samples.
var ownersName string
if asoType.Owner().Kind != resolver.ResourceGroupKind {
continue
}
ownersName = t.rgName
if ownersName != "" {
asoType = setOwnersName(asoType, ownersName)
}
err := t.updateFieldsForTest(asoType)
if err != nil {
return err
}
}
return nil
}
func setOwnersName(sample genruntime.ARMMetaObject, ownerName string) genruntime.ARMMetaObject {
specField := reflect.ValueOf(sample.GetSpec()).Elem()
ownerField := specField.FieldByName("Owner").Elem()
ownerField.FieldByName("Name").SetString(ownerName)
return sample
}
func PathContains(path string, matches []string) bool {
for _, match := range matches {
if strings.Contains(path, match) {
return true
}
}
return false
}
func IsSampleExcluded(path string, exclusions []string) bool {
// Exclude evertying that's not a yaml file
ext := filepath.Ext(path)
if ext != ".yaml" && ext != ".yml" {
return true
}
// Check our exclusion list
base := filepath.Base(path)
split := strings.Split(base, "_")
if len(split) < 2 {
return false
}
baseWithoutAPIVersion := split[1]
baseWithoutAPIVersion = strings.TrimSuffix(baseWithoutAPIVersion, filepath.Ext(baseWithoutAPIVersion))
for _, exclusion := range exclusions {
if baseWithoutAPIVersion == exclusion {
return true
}
}
return false
}
// updateFieldsForTest uses ReflectVisitor to update ARMReferences, SubscriptionIDs, and TenantIDs.
func (t *SamplesTester) updateFieldsForTest(obj genruntime.ARMMetaObject) error {
visitor := reflecthelpers.NewReflectVisitor()
visitor.VisitStruct = t.visitStruct
err := visitor.Visit(obj, t.rgName)
if err != nil {
return eris.Wrapf(err, "updating fields for test")
}
return nil
}
// visitStruct checks and sets the SubscriptionID and ResourceGroup name for ARM references to current values
func (t *SamplesTester) visitStruct(this *reflecthelpers.ReflectVisitor, it reflect.Value, ctx any) error {
// Configure any ResourceReference we find
if it.Type() == reflect.TypeOf(genruntime.ResourceReference{}) {
return t.visitResourceReference(this, it, ctx)
}
// Set the value of any SubscriptionID Field that's got an empty GUID as the value
if field := it.FieldByNameFunc(isField("subscriptionID")); field.IsValid() {
t.conditionalAssignString(field, emptyGUID, t.azureSubscription)
}
// Replace the empty-guid value in any armID field
if field := it.FieldByNameFunc(isField("armId")); field.IsValid() {
t.replaceString(field, emptyGUID, t.azureSubscription)
}
// Set the value of any TenantID Field that's got an empty GUID as the value
if field := it.FieldByNameFunc(isField("tenantID")); field.IsValid() {
t.conditionalAssignString(field, emptyGUID, t.azureTenant)
}
return reflecthelpers.IdentityVisitStruct(this, it, ctx)
}
// isField is a helper used to find fields by case-insensitive matches
func isField(field string) func(name string) bool {
return func(name string) bool {
return strings.EqualFold(name, field)
}
}
func (t *SamplesTester) conditionalAssignString(field reflect.Value, match string, value string) {
if field.Kind() == reflect.Ptr {
field = field.Elem()
}
if field.Kind() != reflect.String {
return
}
if field.String() == match {
field.SetString(value)
}
}
func (t *SamplesTester) replaceString(field reflect.Value, old string, new string) {
if field.Kind() == reflect.Ptr {
field = field.Elem()
}
if field.Kind() != reflect.String {
return
}
val := field.String()
val = strings.ReplaceAll(val, old, new)
field.SetString(val)
}
// visitResourceReference checks and sets the SubscriptionID and ResourceGroup name for ARM references to current values
func (t *SamplesTester) visitResourceReference(_ *reflecthelpers.ReflectVisitor, it reflect.Value, ctx any) error {
if !it.CanInterface() {
// This should be impossible given how the visitor works
panic("genruntime.ResourceReference field was unexpectedly nil")
}
ownersName := ctx.(string)
reference := it.Interface().(genruntime.ResourceReference)
if reference.ARMID != "" {
armIDField := it.FieldByName("ARMID")
if !armIDField.CanSet() {
return eris.New("cannot set 'ARMID' field of 'genruntime.ResourceReference'")
}
armIDString := armIDField.String()
armIDString = strings.ReplaceAll(armIDString, defaultResourceGroup, t.rgName)
armIDString = subRegex.ReplaceAllString(armIDString, fmt.Sprint("/", t.azureSubscription))
armIDField.SetString(armIDString)
} else if reference.Kind == "ResourceGroup" && ownersName != "" { // If we're referring to a resourceGroup, it needs to be updated to refer to the random one
// TODO: We're making the assumption that every reference of type ResourceGroup is by definition referring
// TODO: to the randomly generated RG name, but it's possible at some future date we have multiple resourceGroups
// TODO: floating around. If that happens we may need to update this logic to be a bit more discerning.
nameField := it.FieldByName("Name")
if !nameField.CanSet() {
return eris.New("cannot set 'Name' field of 'genruntime.ResourceReference'")
}
nameField.SetString(ownersName)
}
return nil
}