From 1da4a9137341ec26f5c1ab17b597cd08e732826b Mon Sep 17 00:00:00 2001 From: Matthew Christopher Date: Fri, 22 Mar 2024 15:02:40 -0700 Subject: [PATCH] Add namespace, label, and annotation support to asoctl Fixes #3865 --- v2/cmd/asoctl/cmd/import_azure_resource.go | 41 ++++++++++ .../importing/resource_import_result.go | 53 +++++++++++++ v2/internal/util/annotations/parse.go | 78 +++++++++++++++++++ v2/internal/util/annotations/parse_test.go | 52 +++++++++++++ v2/internal/util/labels/parse.go | 47 +++++++++++ v2/internal/util/labels/parse_test.go | 52 +++++++++++++ 6 files changed, 323 insertions(+) create mode 100644 v2/internal/util/annotations/parse.go create mode 100644 v2/internal/util/annotations/parse_test.go create mode 100644 v2/internal/util/labels/parse.go create mode 100644 v2/internal/util/labels/parse_test.go diff --git a/v2/cmd/asoctl/cmd/import_azure_resource.go b/v2/cmd/asoctl/cmd/import_azure_resource.go index 5364d5afdb9..73c5855f5ac 100644 --- a/v2/cmd/asoctl/cmd/import_azure_resource.go +++ b/v2/cmd/asoctl/cmd/import_azure_resource.go @@ -47,6 +47,25 @@ func newImportAzureResourceCommand() *cobra.Command { cmd.MarkFlagsMutuallyExclusive("output", "output-folder") + cmd.Flags().StringVarP( + &options.namespace, + "namespace", + "n", + "", + "Write the imported resources to the specified namespace") + cmd.Flags().StringSliceVarP( + &options.labels, + "label", + "l", + nil, + "Add the specified labels to the imported resources. Multiple comma-separated labels can be specified (example.com/mylabel=foo,example.com/mylabel2=bar) or the --labels (-l) argument can be used multiple times (-l example.com/mylabel=foo -l example.com/mylabel2=bar)") + cmd.Flags().StringSliceVarP( + &options.annotations, + "annotation", + "a", + nil, + "Add the specified annotations to the imported resources. Multiple comma-separated annotations can be specified (example.com/myannotation=foo,example.com/myannotation2=bar) or the --annotations (-a) argument can be used multiple times (-a example.com/myannotation=foo -a example.com/myannotation2=bar)") + return cmd } @@ -98,6 +117,25 @@ func importAzureResource(ctx context.Context, armIDs []string, options importAzu return nil } + // Apply additional configuration to imported resources. + if options.namespace != "" { + result.SetNamespace(options.namespace) + } + + if len(options.labels) > 0 { + err = result.AddLabels(options.labels) + if err != nil { + return errors.Wrap(err, "failed to add labels") + } + } + + if len(options.annotations) > 0 { + err = result.AddAnnotations(options.annotations) + if err != nil { + return errors.Wrap(err, "failed to add annotations") + } + } + if file, ok := options.writeToFile(); ok { log.Info( "Writing to a single file", @@ -130,6 +168,9 @@ func importAzureResource(ctx context.Context, armIDs []string, options importAzu type importAzureResourceOptions struct { outputPath *string outputFolder *string + namespace string + annotations []string + labels []string } func (option *importAzureResourceOptions) writeToFile() (string, bool) { diff --git a/v2/cmd/asoctl/internal/importing/resource_import_result.go b/v2/cmd/asoctl/internal/importing/resource_import_result.go index d191d32e0f7..dfb5b297caf 100644 --- a/v2/cmd/asoctl/internal/importing/resource_import_result.go +++ b/v2/cmd/asoctl/internal/importing/resource_import_result.go @@ -17,6 +17,8 @@ import ( "golang.org/x/exp/slices" "sigs.k8s.io/yaml" + "github.com/Azure/azure-service-operator/v2/internal/util/annotations" + "github.com/Azure/azure-service-operator/v2/internal/util/labels" "github.com/Azure/azure-service-operator/v2/pkg/genruntime" ) @@ -38,6 +40,57 @@ func (r *ResourceImportResult) SaveToSingleFile(filepath string) error { return r.saveTo(r.resources, filepath) } +// AddAnnotations adds the given annotations to all the resources +func (r *ResourceImportResult) AddAnnotations(toAdd []string) error { + // pre-parse the annotations + parsed, err := annotations.ParseAll(toAdd) + if err != nil { + return err + } + + for _, resource := range r.resources { + anntns := resource.GetAnnotations() + if anntns == nil { + anntns = make(map[string]string, len(toAdd)) + } + for _, annotation := range parsed { + anntns[annotation.Key] = annotation.Value + } + resource.SetAnnotations(anntns) + } + + return nil +} + +// AddLabels adds the given labels to all the resources +func (r *ResourceImportResult) AddLabels(toAdd []string) error { + // pre-parse the labels + parsed, err := labels.ParseAll(toAdd) + if err != nil { + return err + } + + for _, resource := range r.resources { + lbls := resource.GetLabels() + if lbls == nil { + lbls = make(map[string]string, len(toAdd)) + } + for _, label := range parsed { + lbls[label.Key] = label.Value + } + resource.SetLabels(lbls) + } + + return nil +} + +// SetNamespace sets the namespace for all the resources +func (r *ResourceImportResult) SetNamespace(namespace string) { + for _, resource := range r.resources { + resource.SetNamespace(namespace) + } +} + func (r *ResourceImportResult) SaveToIndividualFilesInFolder(folder string) error { // We name the files after the resource type and name // We allocate resources to files using a map, just in case we have a naming collision diff --git a/v2/internal/util/annotations/parse.go b/v2/internal/util/annotations/parse.go new file mode 100644 index 00000000000..13762a3171e --- /dev/null +++ b/v2/internal/util/annotations/parse.go @@ -0,0 +1,78 @@ +/* + * Copyright (c) Microsoft Corporation. + * Licensed under the MIT license. + */ + +package annotations + +import ( + "strings" + + "github.com/pkg/errors" +) + +type Annotation struct { + Key string + Value string +} + +// Parse parses an annotation. Amazingly there doesn't seem to be a function in client-go or similar that does this +func Parse(s string) (Annotation, error) { + split := strings.Split(s, "=") + if len(split) != 2 { + return Annotation{}, errors.Errorf("%s must have two parts separated by '='", s) + } + + // see https://kubernetes.io/docs/concepts/overview/working-with-objects/annotations/#syntax-and-character-set + key := split[0] + value := split[1] + + if len(key) == 0 { + return Annotation{}, errors.New("key can't be length 0") + } + + keySplit := strings.Split(key, "/") + if len(keySplit) > 2 { + return Annotation{}, errors.Errorf("key %s must contain only a single '/'", key) + } + + var name string + var prefix string + if len(keySplit) == 1 { + name = key + } else { + // Len == 2 + prefix = keySplit[0] + name = keySplit[1] + + } + + if len(key) > 63 { + return Annotation{}, errors.Errorf("name %s must be 63 characters or less", name) + } + + if len(prefix) > 253 { + return Annotation{}, errors.Errorf("prefix %s must be 253 characters or less", prefix) + } + + // TODO: Could enforce character restrictions too but not bothering for now + + return Annotation{ + Key: key, + Value: value, + }, nil +} + +func ParseAll(annotations []string) ([]Annotation, error) { + result := make([]Annotation, 0, len(annotations)) + + for _, annotation := range annotations { + parsed, err := Parse(annotation) + if err != nil { + return nil, errors.Wrapf(err, "failed parsing %s", annotation) + } + result = append(result, parsed) + } + + return result, nil +} diff --git a/v2/internal/util/annotations/parse_test.go b/v2/internal/util/annotations/parse_test.go new file mode 100644 index 00000000000..9c9f2c697a3 --- /dev/null +++ b/v2/internal/util/annotations/parse_test.go @@ -0,0 +1,52 @@ +/* + * Copyright (c) Microsoft Corporation. + * Licensed under the MIT license. + */ + +package annotations_test + +import ( + "testing" + + . "github.com/onsi/gomega" + + "github.com/Azure/azure-service-operator/v2/internal/util/annotations" +) + +func TestParse(t *testing.T) { + t.Parallel() + + tests := []struct { + annotation string + wantKey string + wantValue string + wantErr bool + }{ + {"example.com/annotation=value", "example.com/annotation", "value", false}, + {"example.com/annotation=", "example.com/annotation", "", false}, + {"=value", "", "", true}, + {"example.com/annotation", "", "", true}, + {"example.com/test/annotation", "", "", true}, + {"thisisaverylongannotationname_solonginfactthatitisgoingtocauseanerror", "", "", true}, + {"", "", "", true}, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.annotation, func(t *testing.T) { + t.Parallel() + g := NewGomegaWithT(t) + + actual, err := annotations.Parse(tt.annotation) + + if tt.wantErr { + g.Expect(err).To(HaveOccurred()) + } else { + g.Expect(err).ToNot(HaveOccurred()) + } + + g.Expect(actual.Key).To(Equal(tt.wantKey)) + g.Expect(actual.Value).To(Equal(tt.wantValue)) + }) + } +} diff --git a/v2/internal/util/labels/parse.go b/v2/internal/util/labels/parse.go new file mode 100644 index 00000000000..b8119facafd --- /dev/null +++ b/v2/internal/util/labels/parse.go @@ -0,0 +1,47 @@ +/* + * Copyright (c) Microsoft Corporation. + * Licensed under the MIT license. + */ + +package labels + +import ( + "github.com/pkg/errors" + + "github.com/Azure/azure-service-operator/v2/internal/util/annotations" +) + +type Label struct { + Key string + Value string +} + +// Parse parses a label. Amazingly there doesn't seem to be a function in client-go or similar that does this. +// There does exist an apimachinery labels.Parse but it parses label selectors not labels themselves. +func Parse(s string) (Label, error) { + // Currently the label restrictions are exactly the same as annotations, + // so we can just re-use annotation parse here + annotation, err := annotations.Parse(s) + if err != nil { + return Label{}, err + } + + return Label{ + Key: annotation.Key, + Value: annotation.Value, + }, nil +} + +func ParseAll(labels []string) ([]Label, error) { + result := make([]Label, 0, len(labels)) + + for _, annotation := range labels { + parsed, err := Parse(annotation) + if err != nil { + return nil, errors.Wrapf(err, "failed parsing %s", annotation) + } + result = append(result, parsed) + } + + return result, nil +} diff --git a/v2/internal/util/labels/parse_test.go b/v2/internal/util/labels/parse_test.go new file mode 100644 index 00000000000..08e577ac1f4 --- /dev/null +++ b/v2/internal/util/labels/parse_test.go @@ -0,0 +1,52 @@ +/* + * Copyright (c) Microsoft Corporation. + * Licensed under the MIT license. + */ + +package labels_test + +import ( + "testing" + + . "github.com/onsi/gomega" + + "github.com/Azure/azure-service-operator/v2/internal/util/labels" +) + +func TestParse(t *testing.T) { + t.Parallel() + + tests := []struct { + label string + wantKey string + wantValue string + wantErr bool + }{ + {"example.com/label=value", "example.com/label", "value", false}, + {"example.com/label=", "example.com/label", "", false}, + {"=value", "", "", true}, + {"example.com/label", "", "", true}, + {"example.com/test/label", "", "", true}, + {"thisisaverylonglabelname_solonginfactthatitisgoingtocauseanerror", "", "", true}, + {"", "", "", true}, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.label, func(t *testing.T) { + t.Parallel() + g := NewGomegaWithT(t) + + actual, err := labels.Parse(tt.label) + + if tt.wantErr { + g.Expect(err).To(HaveOccurred()) + } else { + g.Expect(err).ToNot(HaveOccurred()) + } + + g.Expect(actual.Key).To(Equal(tt.wantKey)) + g.Expect(actual.Value).To(Equal(tt.wantValue)) + }) + } +}