diff --git a/cmd/registry-connect/discover/apigee/edge/edge_client_test.go b/cmd/registry-connect/discover/apigee/edge/edge_client_test.go index 371aef77..a11578bb 100644 --- a/cmd/registry-connect/discover/apigee/edge/edge_client_test.go +++ b/cmd/registry-connect/discover/apigee/edge/edge_client_test.go @@ -33,6 +33,15 @@ func oauthTestServer(t *testing.T) *httptest.Server { AccessToken: "token", } + m.HandleFunc("/noauth", (func(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodPost: + w.WriteHeader(http.StatusUnauthorized) + default: + w.WriteHeader(http.StatusMethodNotAllowed) + } + })) + m.HandleFunc("/oauth", (func(w http.ResponseWriter, r *http.Request) { switch r.Method { case http.MethodPost: @@ -64,10 +73,12 @@ func TestNewEdgeClient(t *testing.T) { var err error + SetOAuthURL(ts.URL + "/noauth") + _, err = NewEdgeClient(opts) errorContains(t, err, "401") - SetOAuthURL(ts.URL + "/oauth") // intercept OAuth url + SetOAuthURL(ts.URL + "/oauth") _, err = NewEdgeClient(opts) if err != nil { diff --git a/cmd/registry-connect/publish/backstage/backstage.go b/cmd/registry-connect/publish/backstage/backstage.go index d49f90d8..6c563c36 100644 --- a/cmd/registry-connect/publish/backstage/backstage.go +++ b/cmd/registry-connect/publish/backstage/backstage.go @@ -15,30 +15,12 @@ package backstage import ( - "context" - "fmt" - "os" - "path/filepath" - "strings" - - "github.com/apigee/registry-experimental/cmd/registry-connect/publish/backstage/encoding" - "github.com/apigee/registry/pkg/application/apihub" "github.com/apigee/registry/pkg/connection" "github.com/apigee/registry/pkg/log" - "github.com/apigee/registry/pkg/mime" - "github.com/apigee/registry/pkg/names" - "github.com/apigee/registry/pkg/visitor" - "github.com/apigee/registry/rpc" "github.com/spf13/cobra" - "google.golang.org/protobuf/proto" - "gopkg.in/yaml.v3" ) -const ( - apiHubTag = "apihub" - apiLinkFormat = "https://pantheon.corp.google.com/apigee/hub/apis/%s/overview?project=%s" - taxonomiesLinkFormat = "https://pantheon.corp.google.com/apigee/hub/settings/taxonomies?project=%s" -) +var ownerName, ownerDesc string func Command() *cobra.Command { var filter string @@ -67,276 +49,9 @@ func Command() *cobra.Command { }, } cmd.Flags().StringVar(&filter, "filter", "", "filter selected apis") + cmd.Flags().StringVar(&ownerName, "owner-name", "", "Apigee contact name") + cmd.Flags().StringVar(&ownerDesc, "owner-desc", "", "Apigee contact description") + _ = cmd.MarkFlagRequired("owner-name") + _ = cmd.MarkFlagRequired("owner-desc") return cmd } - -func recommendedOrLatestVersion(ctx context.Context, client connection.RegistryClient, a *rpc.Api) (*rpc.ApiVersion, error) { - n, _ := names.ParseApi(a.Name) - versionName := n.Version("-") - - if a.RecommendedVersion != "" { - rv, err := names.ParseVersion(a.RecommendedVersion) - if err != nil { - return nil, err - } - versionName = rv - } - - var version *rpc.ApiVersion - err := visitor.ListVersions(ctx, client, versionName, "", func(ctx context.Context, av *rpc.ApiVersion) error { - version = av - return nil - }) - return version, err -} - -func primaryOrLatestSpec(ctx context.Context, client connection.RegistryClient, av *rpc.ApiVersion) (*rpc.ApiSpec, error) { - n, _ := names.ParseVersion(av.Name) - specName := n.Spec("-") - - if av.PrimarySpec != "" { - as, err := names.ParseSpec(av.PrimarySpec) - if err != nil { - return nil, err - } - specName = as - } - - var spec *rpc.ApiSpec - err := visitor.ListSpecs(ctx, client, specName, "", true, func(ctx context.Context, as *rpc.ApiSpec) error { - spec = as - return nil - }) - return spec, err -} - -func name(str string) string { - if len(str) > 63 { - str = str[0:63] - } - return str -} - -func required(value string) string { - if value == "" { - return "unknown" - } - return value -} - -type catalog struct { - client connection.RegistryClient - config connection.Config - filter string - root string - filesByKind map[string][]string -} - -func (c *catalog) Run(ctx context.Context) error { - c.filesByKind = map[string][]string{} - - if err := c.createGroups(ctx); err != nil { - return err - } - if err := c.createAPIs(ctx); err != nil { - return err - } - return c.writeCatalog() -} - -func (c *catalog) createGroups(ctx context.Context) error { - taxonomiesName, err := names.ParseArtifact(c.config.FQName("artifacts/apihub-taxonomies")) - if err != nil { - return err - } - return visitor.GetArtifact(ctx, c.client, taxonomiesName, true, func(ctx context.Context, a *rpc.Artifact) error { - message, err := mime.MessageForMimeType(a.GetMimeType()) - if err == nil { - err = proto.Unmarshal(a.GetContents(), message) - } - if err != nil { - return err - } - artifactName, _ := names.ParseArtifact(a.Name) - taxonomies := message.(*apihub.TaxonomyList) - for _, t := range taxonomies.GetTaxonomies() { - if t.Id == "apihub-team" { - for _, team := range t.Elements { - metadata := encoding.Metadata{ - Name: name(team.Id), - Namespace: artifactName.ProjectID(), - Title: team.DisplayName, - Description: team.Description, - Tags: []string{apiHubTag}, - Links: []encoding.Link{ - { - URL: fmt.Sprintf(taxonomiesLinkFormat, artifactName.ProjectID()), - Title: "API Hub Taxonomies", - // Icon: "", // requires backstage setup - }, - }, - } - group := encoding.Group{ - Type: "team", - } - if err := c.addEntity(metadata, group); err != nil { - return err - } - } - } - } - return nil - }) -} - -func (c *catalog) createAPIs(ctx context.Context) error { - project, err := names.ParseProject("projects/" + c.config.Project) - if err != nil { - return err - } - return visitor.ListAPIs(ctx, c.client, project.Api("-"), c.filter, func(ctx context.Context, a *rpc.Api) error { - log.FromContext(ctx).Infof("publishing %s", a.Name) - - apiName, _ := names.ParseApi(a.Name) - name := apiName.ApiID - metadata := encoding.Metadata{ - Name: name, - Namespace: project.ProjectID, - Title: a.DisplayName, - Description: a.Description, - Labels: a.Labels, // TODO: not viewable in backstage - Annotations: a.Annotations, // TODO: not viewable in backstage - Tags: []string{apiHubTag}, - Links: []encoding.Link{ // TODO: not viewable in backstage - { - URL: fmt.Sprintf(apiLinkFormat, apiName.ApiID, project.ProjectID), - Title: "API Hub", - }, - }, - } - var style, lifecycle, definition, owner string - - owner = a.Labels["apihub-team"] - style = strings.TrimPrefix(a.Labels["apihub-style"], "apihub-") - lifecycle = a.Labels["apihub-lifecycle"] - // TODO: add contact as user? - // primaryContact = a.Labels["apihub-primary-contact"] - // primaryContactDescription = a.Labels["apihub-primary-contact-description"] - - av, err := recommendedOrLatestVersion(ctx, c.client, a) - if err != nil { - return err - } - if av != nil { - lifecycle = av.State - - as, err := primaryOrLatestSpec(ctx, c.client, av) - if err != nil { - return err - } - if as != nil && as.MimeType != "application/x.proto+zip" { // no binary - // overrides API style as Backstage actually just expects spec style - // TODO: full mime list - if strings.Contains(as.MimeType, "openapi") || strings.Contains(as.MimeType, "yaml") { - style = "openapi" - } else if strings.Contains(as.MimeType, "proto") || strings.Contains(as.MimeType, "grpc") { - style = "grpc" - } - definition = string(as.Contents) - } - // the following doesn't work to write the spec content in a separate local file, - // perhaps related to: https://github.com/backstage/backstage/issues/14372 - // if as != nil && len(as.Contents) > 0 { - // file := filepath.Join(c.root, metadata.Name+".spec") - // if strings.Contains(as.MimeType, "yaml") || strings.Contains(as.MimeType, "openapi") { - // definition = "$yaml: " + file - // } else if strings.Contains(as.MimeType, "json") { - // definition = "$json: " + file - // } else if strings.Contains(as.MimeType, "text") { - // definition = "$text: " + file - // } - // if err := os.MkdirAll(filepath.Dir(file), os.FileMode(0755)); err != nil { // rwx,rx,rx - // return err - // } - // if err := os.WriteFile(file, as.Contents, os.FileMode(0644)); err != nil { - // return err - // } - // } - } - - api := encoding.Api{ - Type: required(style), // backstage well-known types: openapi, asyncapi, graphql, grpc - Lifecycle: required(lifecycle), // backstage well-known types: experimental, production, deprecated - Owner: required(owner), - Definition: required(definition), - } - return c.addEntity(metadata, api) - }) -} - -func (c *catalog) writeYAML(file string, data *encoding.Envelope) error { - file = filepath.Join(c.root, file) - if err := os.MkdirAll(filepath.Dir(file), os.FileMode(0755)); err != nil { // rwx,rx,rx - return err - } - - out, err := os.OpenFile(file, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, os.FileMode(0644)) // rw,r,r - if err != nil { - return err - } - defer out.Close() - enc := yaml.NewEncoder(out) - return enc.Encode(data) -} - -func (c *catalog) addEntity(metadata encoding.Metadata, entity interface{}) error { - envelope, err := encoding.NewEnvelope(metadata, entity) - if err != nil { - return err - } - file := metadata.Name + ".yaml" - c.filesByKind[envelope.Kind] = append(c.filesByKind[envelope.Kind], file) - return c.writeYAML(filepath.Join(envelope.Kind+"s", file), envelope) -} - -func (c *catalog) writeCatalog() error { - subCatalogs := []string{} - - for k, fs := range c.filesByKind { - relativeFs := []string{} - for _, f := range fs { - relativeFs = append(relativeFs, "./"+f) - } - location := encoding.Location{ - Targets: relativeFs, - } - pluralKinds := k + "s" - catalog := fmt.Sprintf("All-%s.yaml", pluralKinds) - subCatalogs = append(subCatalogs, "./"+filepath.Join(pluralKinds, catalog)) - metadata := encoding.Metadata{ - Name: "APIHub-" + pluralKinds, - Description: fmt.Sprintf("API Hub %s for Backstage.io", pluralKinds), - } - envelope, err := encoding.NewEnvelope(metadata, location) - if err != nil { - return err - } - file := filepath.Join(pluralKinds, catalog) - if err := c.writeYAML(file, envelope); err != nil { - return err - } - } - - location := encoding.Location{ - Targets: subCatalogs, - } - metadata := encoding.Metadata{ - Name: "APIHub-" + c.config.Project, - Description: fmt.Sprintf("API Hub project %s export for Backstage.io", c.config.Project), - } - envelope, err := encoding.NewEnvelope(metadata, location) - if err != nil { - return err - } - return c.writeYAML("APIHub-Catalog.yaml", envelope) -} diff --git a/cmd/registry-connect/publish/backstage/catalog.go b/cmd/registry-connect/publish/backstage/catalog.go new file mode 100644 index 00000000..d1e1383d --- /dev/null +++ b/cmd/registry-connect/publish/backstage/catalog.go @@ -0,0 +1,417 @@ +// Copyright 2023 Google LLC. All Rights Reserved. +// +// 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 backstage + +import ( + "context" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/apigee/registry-experimental/cmd/registry-connect/publish/backstage/encoding" + "github.com/apigee/registry/pkg/application/apihub" + "github.com/apigee/registry/pkg/connection" + "github.com/apigee/registry/pkg/log" + "github.com/apigee/registry/pkg/mime" + "github.com/apigee/registry/pkg/names" + "github.com/apigee/registry/pkg/visitor" + "github.com/apigee/registry/rpc" + "google.golang.org/protobuf/proto" + "gopkg.in/yaml.v3" +) + +const ( + apiLinkFormat = "https://pantheon.corp.google.com/apigee/hub/apis/%s/overview?project=%s" + taxonomiesLinkFormat = "https://pantheon.corp.google.com/apigee/hub/settings/taxonomies?project=%s" +) + +type catalog struct { + client connection.RegistryClient + config connection.Config + filter string + root string + entitiesByKind map[string][]*encoding.Envelope +} + +func (c *catalog) Run(ctx context.Context) error { + c.entitiesByKind = map[string][]*encoding.Envelope{} + + if err := c.createGroups(ctx); err != nil { + return err + } + if err := c.createAPIs(ctx); err != nil { + return err + } + return c.writeCatalog() +} + +func (c *catalog) apigeeOwner() (group *encoding.Envelope, err error) { + return c.createGroup("apg-owner", ownerName, ownerDesc) +} + +func (c *catalog) createDeployment(d *rpc.ApiDeployment) (deployment *encoding.Envelope, err error) { + var org, env, gateway, owner *encoding.Envelope + if owner, err = c.apigeeOwner(); err != nil { + return + } + + var orgName, envName string + if envLabel := d.Annotations["apigee-environment"]; envLabel != "" { + splits := strings.Split(envLabel, "/") + orgName = splits[1] + envName = splits[3] + } + if orgName != "" && envName != "" { + if org, err = c.addEntity(&encoding.Metadata{ + Name: "apigee-org-" + orgName, + Title: "Apigee Org " + orgName, + Description: "Apigee Org " + orgName, + }, &encoding.Domain{ + Owner: requiredRef(owner.Reference()), + }); err != nil { + return + } + + if env, err = c.addEntity(&encoding.Metadata{ + Name: "apg-env-" + orgName + "-" + envName, + Title: "Apigee Env " + orgName + " " + envName, + Description: "Apigee Env " + envName + " in Org " + orgName, + }, &encoding.System{ + Owner: requiredRef(owner.Reference()), + Domain: org.Reference(), + }); err != nil { + return + } + + if gateway, err = c.addEntity(&encoding.Metadata{ + Name: "apg-gw-" + orgName, + Title: "Apigee Gateway " + orgName, + Description: "Apigee Gateway in Org " + orgName + ", Env: " + envName, + }, &encoding.Component{ + Type: "Service", + Lifecycle: "production", + Owner: requiredRef(owner.Reference()), + System: env.Reference(), + }); err != nil { + return + } + } + + depName, _ := names.ParseDeployment(d.Name) + depId := depName.ApiID + "-" + depName.DeploymentID + deployment, err = c.addEntity(&encoding.Metadata{ + Name: "apg-dep-" + depId, + Title: "Apigee Deployment " + firstOf(d.DisplayName, depId), + Description: "Apigee Deployment " + firstOf(d.DisplayName, depId) + " of API " + depName.ApiID, + Labels: d.Labels, + }, &encoding.Component{ + Type: "Service", + Lifecycle: required(""), // TODO: nothing to map from API Hub? + Owner: requiredRef(owner.Reference()), + System: env.Reference(), + SubComponentOf: gateway.Reference(), + }) + _ = gateway + return +} + +func (c *catalog) createGroups(ctx context.Context) error { + taxonomiesName, err := names.ParseArtifact(c.config.FQName("artifacts/apihub-taxonomies")) + if err != nil { + return err + } + return visitor.GetArtifact(ctx, c.client, taxonomiesName, true, func(ctx context.Context, a *rpc.Artifact) error { + message, err := mime.MessageForMimeType(a.GetMimeType()) + if err != nil { + return err + } + if err := proto.Unmarshal(a.GetContents(), message); err != nil { + return err + } + artifactName, _ := names.ParseArtifact(a.Name) + taxonomies := message.(*apihub.TaxonomyList) + for _, t := range taxonomies.GetTaxonomies() { + if t.Id == "apihub-team" { + for _, team := range t.Elements { + group, err := c.createGroup("apg-"+team.Id, team.DisplayName, team.Description) + if err != nil { + return err + } + group.Metadata.Links = []encoding.Link{ + { + URL: fmt.Sprintf(taxonomiesLinkFormat, artifactName.ProjectID()), + Title: "API Hub Taxonomies", + }, + } + } + } + } + return nil + }) +} + +func (c *catalog) createGroup(name, title, description string) (*encoding.Envelope, error) { + if name == "" { + return nil, nil + } + return c.addEntity(&encoding.Metadata{ + Name: name, + Title: firstOf(title, name), + Description: description, + }, &encoding.Group{ + Type: "team", + }) +} + +func (c *catalog) createAPIs(ctx context.Context) error { + project, err := names.ParseProject("projects/" + c.config.Project) + if err != nil { + return err + } + return visitor.ListAPIs(ctx, c.client, project.Api("-"), c.filter, func(ctx context.Context, a *rpc.Api) error { + log.FromContext(ctx).Infof("publishing %s", a.Name) + + var specContents string + style := strings.TrimPrefix(a.Labels["apihub-style"], "apihub-") + if style == "" { + if _, ok := a.Annotations["apigee-proxy"]; ok { + style = "apigee-proxy" + } else if _, ok := a.Annotations["apigee-product"]; ok { + style = "apigee-product" + } + } + lifecycle := a.Labels["apihub-lifecycle"] + + primaryContact, err := c.createGroup(a.Labels["apihub-primary-contact"], a.Labels["apihub-primary-contact"], a.Labels["apihub-primary-contact-description"]) + if err != nil { + return err + } + + // TODO: denormalize or take one? one for now. + av, err := recommendedOrLatestVersion(ctx, c.client, a) + if err != nil { + return err + } + if av != nil { + if av.State != "" { + lifecycle = av.State + } + + var specs []*rpc.ApiSpec + vName, _ := names.ParseVersion(av.Name) + err = visitor.ListSpecs(ctx, c.client, vName.Spec("-"), "", true, func(ctx context.Context, as *rpc.ApiSpec) error { + specs = append(specs, as) + return nil + }) + if err != nil { + return err + } + + // TODO: denormalize or take one? one for now. + var as *rpc.ApiSpec + for _, s := range specs { // take primary + if av.PrimarySpec == s.Name { + as = s + } + } + if as == nil { // or take first + if len(specs) > 0 { + as = specs[0] + } + } + + if as != nil && as.MimeType != "application/x.proto+zip" { // no binary + // Backstage well-known types: openapi, asyncapi, graphql, grpc + if strings.Contains(as.MimeType, "openapi") || strings.Contains(as.MimeType, "yaml") { + style = "openapi" + } else if strings.Contains(as.MimeType, "proto") || strings.Contains(as.MimeType, "grpc") { + style = "grpc" + } else if strings.Contains(as.MimeType, "asyncapi") { + style = "asyncapi" + } else if strings.Contains(as.MimeType, "graphql") { + style = "graphql" + } + specContents = string(as.Contents) + } + + apiName, _ := names.ParseApi(a.Name) + apiHubLinks := []encoding.Link{{ + URL: fmt.Sprintf(apiLinkFormat, apiName.ApiID, c.config.Project), + Title: "API Hub", + }} + api, err := c.addEntity(&encoding.Metadata{ + Name: "apg-" + apiName.ApiID, + Title: "Apigee " + firstOf(a.DisplayName, apiName.ApiID), + Description: firstOf(a.Description, a.DisplayName), + Labels: a.Labels, // note: labels and links are not viewable in default backstage API plugin + Links: apiHubLinks, + }, &encoding.Api{ + Type: required(style), + Lifecycle: required(lifecycle), // backstage well-known types: experimental, production, deprecated + Owner: requiredRef(primaryContact.Reference()), + Definition: required(specContents), + }) + if err != nil { + return err + } + + err = visitor.ListDeployments(ctx, c.client, apiName.Deployment("-"), "", func(ctx context.Context, d *rpc.ApiDeployment) error { + env, err := c.createDeployment(d) + dep := env.Spec.(*encoding.Component) + dep.ProvidesApis = append(dep.ProvidesApis, api.Reference()) + env.Metadata.Links = append(env.Metadata.Links, apiHubLinks...) + return err + }) + return err + } + return err + }) +} + +func (c *catalog) writeYAML(file string, data *encoding.Envelope) error { + file = filepath.Join(c.root, file) + if err := os.MkdirAll(filepath.Dir(file), os.FileMode(0755)); err != nil { // rwx,rx,rx + return err + } + + out, err := os.OpenFile(file, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, os.FileMode(0644)) // rw,r,r + if err != nil { + return err + } + defer out.Close() + enc := yaml.NewEncoder(out) + return enc.Encode(data) +} + +func (c *catalog) defaultNamespace() string { + return c.config.Project +} + +// will create entity if Reference (kind, namespace, name) doesn't exist, +// otherwise will return pointer to existing entity +// namespace will default, an "apiHub" tag will be added +// name and namespace may be modified to be valid +func (c *catalog) addEntity(metadata *encoding.Metadata, spec encoding.Spec) (*encoding.Envelope, error) { + if metadata.Namespace == "" { + metadata.Namespace = c.defaultNamespace() + } + metadata.Tags = append(metadata.Tags, "apihub") + envelope, err := encoding.NewEnvelope(metadata, spec) + if err != nil { + return nil, err + } + if env := c.findEntity(envelope.Kind, envelope.Metadata.Namespace, envelope.Metadata.Name); env != nil { + return env, nil + } + c.entitiesByKind[envelope.Kind] = append(c.entitiesByKind[envelope.Kind], envelope) + return envelope, nil +} + +func (c *catalog) findEntity(kind, namespace, name string) *encoding.Envelope { + for _, env := range c.entitiesByKind[kind] { + if env.Metadata.Namespace == encoding.SafeName(namespace) && env.Metadata.Name == encoding.SafeName(name) { + return env + } + } + return nil +} + +func (c *catalog) writeCatalog() error { + subCatalogs := []string{} + + for k, entities := range c.entitiesByKind { + files := []string{} + pluralKind := strings.ToLower(k) + "s" + for _, entity := range entities { + safeName := encoding.SafeName(string(entity.Reference())) + fileName := strings.ToLower(safeName) + ".yaml" + if err := c.writeYAML(filepath.Join(pluralKind, fileName), entity); err != nil { + return err + } + files = append(files, "./"+fileName) + } + + kindCatalog, err := encoding.NewEnvelope(&encoding.Metadata{ + Name: "apihub-" + pluralKind, + Description: fmt.Sprintf("API Hub %s for Backstage.io", pluralKind), + }, &encoding.Location{ + Targets: files, + }) + if err != nil { + return err + } + + fileName := filepath.Join(pluralKind, fmt.Sprintf("all-%s.yaml", pluralKind)) + if err := c.writeYAML(fileName, kindCatalog); err != nil { + return err + } + subCatalogs = append(subCatalogs, "./"+fileName) + } + + catalog, err := encoding.NewEnvelope(&encoding.Metadata{ + Name: "apihub-" + strings.ToLower(c.config.Project), + Description: fmt.Sprintf("API Hub project %s export for Backstage.io", c.config.Project), + }, &encoding.Location{ + Targets: subCatalogs, + }) + if err != nil { + return err + } + return c.writeYAML("apihub-catalog.yaml", catalog) +} + +func recommendedOrLatestVersion(ctx context.Context, client connection.RegistryClient, a *rpc.Api) (*rpc.ApiVersion, error) { + n, _ := names.ParseApi(a.Name) + versionName := n.Version("-") + + if a.RecommendedVersion != "" { + rv, err := names.ParseVersion(a.RecommendedVersion) + if err != nil { + return nil, err + } + versionName = rv + } + + var version *rpc.ApiVersion + err := visitor.ListVersions(ctx, client, versionName, "", func(ctx context.Context, av *rpc.ApiVersion) error { + version = av + return nil + }) + return version, err +} + +func required(value string) string { + if value == "" { + return "unknown" + } + return value +} + +func requiredRef(ref encoding.Reference) encoding.Reference { + if ref == "" { + return "unknown" + } + return ref +} + +func firstOf(args ...string) string { + for _, a := range args { + if a != "" { + return a + } + } + return "" +} diff --git a/cmd/registry-connect/publish/backstage/encoding/api.go b/cmd/registry-connect/publish/backstage/encoding/api.go index 25fb86a6..0447632d 100644 --- a/cmd/registry-connect/publish/backstage/encoding/api.go +++ b/cmd/registry-connect/publish/backstage/encoding/api.go @@ -15,9 +15,9 @@ package encoding type Api struct { - Type string `yaml:"type"` - Lifecycle string `yaml:"lifecycle"` - Owner string `yaml:"owner"` - Definition string `yaml:"definition"` - System string `yaml:"system,omitempty"` + Type string `yaml:"type"` + Lifecycle string `yaml:"lifecycle"` + Owner Reference `yaml:"owner"` + Definition string `yaml:"definition"` + System Reference `yaml:"system,omitempty"` } diff --git a/cmd/registry-connect/publish/backstage/encoding/common.go b/cmd/registry-connect/publish/backstage/encoding/common.go index aee55d7a..4f351621 100644 --- a/cmd/registry-connect/publish/backstage/encoding/common.go +++ b/cmd/registry-connect/publish/backstage/encoding/common.go @@ -16,19 +16,39 @@ package encoding import ( "fmt" + "regexp" + "strings" ) // https://backstage.io/docs/features/software-catalog/descriptor-format#overall-shape-of-an-entity const BackstageV1alpha1 = "backstage.io/v1alpha1" +type Spec interface{} + // https://backstage.io/docs/features/software-catalog/descriptor-format#common-to-all-kinds-the-envelope type Envelope struct { - ApiVersion string `yaml:"apiVersion,omitempty"` - Kind string `yaml:"kind,omitempty"` - Metadata Metadata `yaml:"metadata,omitempty"` - Relations Relations `yaml:"relations,omitempty"` - Spec interface{} `yaml:"spec,omitempty"` + ApiVersion string `yaml:"apiVersion,omitempty"` + Kind string `yaml:"kind,omitempty"` + Metadata *Metadata `yaml:"metadata,omitempty"` + Relations []Relation `yaml:"relations,omitempty"` + Spec Spec `yaml:"spec,omitempty"` +} + +// https://backstage.io/docs/features/software-catalog/references +// format: [:][/] +func (e *Envelope) Reference() Reference { + if e == nil { + return "" + } + var kind, namespace string + if e.Kind != "" { + kind = e.Kind + ":" + } + if e.Metadata.Namespace != "" { + namespace = e.Metadata.Namespace + "/" + } + return Reference(kind + namespace + e.Metadata.Name) } // https://backstage.io/docs/features/software-catalog/descriptor-format#common-to-all-kinds-the-metadata @@ -52,7 +72,7 @@ type Link struct { } // https://backstage.io/docs/features/software-catalog/descriptor-format#common-to-all-kinds-relations -type Relations struct { +type Relation struct { Target Reference `yaml:"target,omitempty"` Type string `yaml:"type,omitempty"` } @@ -61,23 +81,51 @@ type Relations struct { // [:][/] type Reference string -func NewEnvelope(metadata Metadata, spec interface{}) (*Envelope, error) { - var kind string - switch t := spec.(type) { - case Api: - kind = "API" - case Group: - kind = "Group" - case Location: - kind = "Location" - default: - return nil, fmt.Errorf("invalid spec type: %#v", t) +// will automatically fix name and namespace using SafeName +func NewEnvelope(metadata *Metadata, spec Spec) (*Envelope, error) { + metadata.Name = SafeName(metadata.Name) + metadata.Namespace = SafeName(metadata.Namespace) + kind, err := Kind(spec) + if err != nil { + return nil, err } return &Envelope{ ApiVersion: BackstageV1alpha1, Kind: kind, Metadata: metadata, - Relations: Relations{}, + Relations: []Relation{}, Spec: spec, }, nil } + +func Kind(spec Spec) (string, error) { + switch t := spec.(type) { + case *Api: + return "API", nil + case *Component: + return "Component", nil + case *Domain: + return "Domain", nil + case *Group: + return "Group", nil + case *Location: + return "Location", nil + case *System: + return "System", nil + case *User: + return "User", nil + default: + return "", fmt.Errorf("invalid spec type: %#v", t) + } +} + +// Strings of length at least 1, and at most 63 +// Must consist of sequences of [a-z0-9A-Z] possibly separated by one of [-_.] +func SafeName(str string) string { + str = regexp.MustCompile(`[^a-z0-9A-Z-.]+`).ReplaceAllString(str, "_") + if len(str) > 63 { + str = str[0:63] + } + str = strings.TrimRight(str, "-._") + return str +} diff --git a/cmd/registry-connect/publish/backstage/encoding/component.go b/cmd/registry-connect/publish/backstage/encoding/component.go new file mode 100644 index 00000000..ceed8f69 --- /dev/null +++ b/cmd/registry-connect/publish/backstage/encoding/component.go @@ -0,0 +1,26 @@ +// Copyright 2023 Google LLC. All Rights Reserved. +// +// 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 encoding + +type Component struct { + Type string `yaml:"type"` + Lifecycle string `yaml:"lifecycle"` + Owner Reference `yaml:"owner"` + System Reference `yaml:"system,omitempty"` + SubComponentOf Reference `yaml:"subcomponentOf,omitempty"` + ProvidesApis []Reference `yaml:"providesApis,omitempty"` + ConsumesApis []Reference `yaml:"consumesApis,omitempty"` + DependsOn []Reference `yaml:"dependsOn,omitempty"` +} diff --git a/cmd/registry-connect/publish/backstage/encoding/domain.go b/cmd/registry-connect/publish/backstage/encoding/domain.go new file mode 100644 index 00000000..6f9d67f4 --- /dev/null +++ b/cmd/registry-connect/publish/backstage/encoding/domain.go @@ -0,0 +1,19 @@ +// Copyright 2023 Google LLC. All Rights Reserved. +// +// 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 encoding + +type Domain struct { + Owner Reference `yaml:"owner"` +} diff --git a/cmd/registry-connect/publish/backstage/encoding/system.go b/cmd/registry-connect/publish/backstage/encoding/system.go new file mode 100644 index 00000000..95c7b650 --- /dev/null +++ b/cmd/registry-connect/publish/backstage/encoding/system.go @@ -0,0 +1,20 @@ +// Copyright 2023 Google LLC. All Rights Reserved. +// +// 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 encoding + +type System struct { + Owner Reference `yaml:"owner"` + Domain Reference `yaml:"domain,omitempty"` +} diff --git a/cmd/registry-connect/publish/backstage/encoding/user.go b/cmd/registry-connect/publish/backstage/encoding/user.go new file mode 100644 index 00000000..5a5d328e --- /dev/null +++ b/cmd/registry-connect/publish/backstage/encoding/user.go @@ -0,0 +1,20 @@ +// Copyright 2023 Google LLC. All Rights Reserved. +// +// 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 encoding + +type User struct { + MemberOf []Reference `yaml:"memberOf"` + Profile interface{} `yaml:"profile,omitempty"` +}