diff --git a/cli/cmd/profile.go b/cli/cmd/profile.go index 58aa192f5..9cc796a88 100644 --- a/cli/cmd/profile.go +++ b/cli/cmd/profile.go @@ -1,16 +1,20 @@ package cmd import ( + "encoding/json" "errors" "fmt" + "io" "os" + sp "github.com/linkerd/linkerd2/controller/gen/apis/serviceprofile/v1alpha2" pkgcmd "github.com/linkerd/linkerd2/pkg/cmd" "github.com/linkerd/linkerd2/pkg/healthcheck" "github.com/linkerd/linkerd2/pkg/k8s" "github.com/linkerd/linkerd2/pkg/profiles" "github.com/spf13/cobra" "k8s.io/apimachinery/pkg/util/validation" + "sigs.k8s.io/yaml" ) type profileOptions struct { @@ -20,6 +24,7 @@ type profileOptions struct { openAPI string proto string ignoreCluster bool + output string } func newProfileOptions() *profileOptions { @@ -29,6 +34,7 @@ func newProfileOptions() *profileOptions { openAPI: "", proto: "", ignoreCluster: false, + output: "yaml", } } @@ -112,16 +118,21 @@ func newCmdProfile() *cobra.Command { } } + var profile *sp.ServiceProfile if options.template { - return profiles.RenderProfileTemplate(options.namespace, options.name, clusterDomain, os.Stdout) + return profiles.RenderProfileTemplate(options.namespace, options.name, clusterDomain, os.Stdout, options.output) } else if options.openAPI != "" { - return profiles.RenderOpenAPI(options.openAPI, options.namespace, options.name, clusterDomain, os.Stdout) + profile, err = profiles.RenderOpenAPI(options.openAPI, options.namespace, options.name, clusterDomain) } else if options.proto != "" { - return profiles.RenderProto(options.proto, options.namespace, options.name, clusterDomain, os.Stdout) + profile, err = profiles.RenderProto(options.proto, options.namespace, options.name, clusterDomain) + } else { + return errors.New("one of --template, --open-api, or --proto must be specified") + } + if err != nil { + return err } - // we should never get here - return errors.New("Unexpected error") + return writeProfile(profile, os.Stdout, options.output) }, } @@ -130,6 +141,23 @@ func newCmdProfile() *cobra.Command { cmd.PersistentFlags().StringVarP(&options.namespace, "namespace", "n", options.namespace, "Namespace of the service") cmd.PersistentFlags().StringVar(&options.proto, "proto", options.proto, "Output a service profile based on the given Protobuf spec file") cmd.PersistentFlags().BoolVar(&options.ignoreCluster, "ignore-cluster", options.ignoreCluster, "Output a service profile through offline generation") - + cmd.PersistentFlags().StringVarP(&options.output, "output", "o", options.output, "Output format. One of: yaml, json") return cmd } + +func writeProfile(profile *sp.ServiceProfile, w io.Writer, format string) error { + var output []byte + var err error + if format == "yaml" { + output, err = yaml.Marshal(profile) + } else if format == "json" { + output, err = json.Marshal(profile) + } else { + return fmt.Errorf("unknown output format: %s", format) + } + if err != nil { + return fmt.Errorf("Error writing Service Profile: %w", err) + } + _, err = w.Write(output) + return err +} diff --git a/cli/cmd/profile_test.go b/cli/cmd/profile_test.go index 7b4ae0c96..463f068ad 100644 --- a/cli/cmd/profile_test.go +++ b/cli/cmd/profile_test.go @@ -14,7 +14,7 @@ import ( func TestParseProfile(t *testing.T) { var buf bytes.Buffer - err := profiles.RenderProfileTemplate("myns", "mysvc", "mycluster.local", &buf) + err := profiles.RenderProfileTemplate("myns", "mysvc", "mycluster.local", &buf, "yaml") if err != nil { t.Fatalf("Error rendering service profile template: %v", err) } diff --git a/pkg/profiles/openapi.go b/pkg/profiles/openapi.go index dc0f9f1c6..58c7fde51 100644 --- a/pkg/profiles/openapi.go +++ b/pkg/profiles/openapi.go @@ -21,31 +21,31 @@ const ( // RenderOpenAPI reads an OpenAPI spec file and renders the corresponding // ServiceProfile to a buffer, given a namespace, service, and control plane // namespace. -func RenderOpenAPI(fileName, namespace, name, clusterDomain string, w io.Writer) error { +func RenderOpenAPI(fileName, namespace, name, clusterDomain string) (*sp.ServiceProfile, error) { input, err := readFile(fileName) if err != nil { - return err + return nil, err } bytes, err := io.ReadAll(input) if err != nil { - return fmt.Errorf("Error reading file: %w", err) + return nil, fmt.Errorf("Error reading file: %w", err) } json, err := yaml.YAMLToJSON(bytes) if err != nil { - return fmt.Errorf("Error parsing yaml: %w", err) + return nil, fmt.Errorf("Error parsing yaml: %w", err) } swagger := spec.Swagger{} err = swagger.UnmarshalJSON(json) if err != nil { - return fmt.Errorf("Error parsing OpenAPI spec: %w", err) + return nil, fmt.Errorf("Error parsing OpenAPI spec: %w", err) } profile := swaggerToServiceProfile(swagger, namespace, name, clusterDomain) - return writeProfile(profile, w) + return &profile, nil } func swaggerToServiceProfile(swagger spec.Swagger, namespace, name, clusterDomain string) sp.ServiceProfile { diff --git a/pkg/profiles/profiles.go b/pkg/profiles/profiles.go index 994104d6f..fd4684b6d 100644 --- a/pkg/profiles/profiles.go +++ b/pkg/profiles/profiles.go @@ -15,6 +15,7 @@ import ( "github.com/linkerd/linkerd2/pkg/k8s" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/util/validation" + yamlDecoder "k8s.io/apimachinery/pkg/util/yaml" "sigs.k8s.io/yaml" ) @@ -210,7 +211,7 @@ func buildConfig(namespace, service, clusterDomain string) *profileTemplateConfi // RenderProfileTemplate renders a ServiceProfile template to a buffer, given a // namespace, service, and control plane namespace. -func RenderProfileTemplate(namespace, service, clusterDomain string, w io.Writer) error { +func RenderProfileTemplate(namespace, service, clusterDomain string, w io.Writer, format string) error { config := buildConfig(namespace, service, clusterDomain) template, err := template.New("profile").Parse(Template) if err != nil { @@ -222,8 +223,20 @@ func RenderProfileTemplate(namespace, service, clusterDomain string, w io.Writer return err } - _, err = w.Write(buf.Bytes()) - return err + if format == "json" { + bytes, err := yamlDecoder.ToJSON(buf.Bytes()) + if err != nil { + return err + } + _, err = w.Write(append(bytes, '\n')) + return err + } + if format == "yaml" { + _, err = w.Write(buf.Bytes()) + return err + } + + return fmt.Errorf("unknown output format: %s", format) } func readFile(fileName string) (io.Reader, error) { @@ -233,15 +246,6 @@ func readFile(fileName string) (io.Reader, error) { return os.Open(filepath.Clean(fileName)) } -func writeProfile(profile sp.ServiceProfile, w io.Writer) error { - output, err := yaml.Marshal(profile) - if err != nil { - return fmt.Errorf("Error writing Service Profile: %w", err) - } - _, err = w.Write(output) - return err -} - // PathToRegex converts a path into a regex. func PathToRegex(path string) string { escaped := regexp.QuoteMeta(path) diff --git a/pkg/profiles/profiles_fuzzer.go b/pkg/profiles/profiles_fuzzer.go index c2e38539e..1db029302 100644 --- a/pkg/profiles/profiles_fuzzer.go +++ b/pkg/profiles/profiles_fuzzer.go @@ -42,11 +42,9 @@ func FuzzRenderProto(data []byte) int { if err != nil { return 0 } - w, err := os.OpenFile("/dev/null", os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0600) + _, err = RenderProto(protofile.Name(), namespace, name, clusterDomain) if err != nil { return 0 } - defer w.Close() - _ = RenderProto(protofile.Name(), namespace, name, clusterDomain, w) return 1 } diff --git a/pkg/profiles/proto.go b/pkg/profiles/proto.go index 26c3d48ea..e86aa0a2a 100644 --- a/pkg/profiles/proto.go +++ b/pkg/profiles/proto.go @@ -2,7 +2,6 @@ package profiles import ( "fmt" - "io" "net/http" "regexp" "strings" @@ -15,20 +14,15 @@ import ( // RenderProto reads a protobuf definition file and renders the corresponding // ServiceProfile to a buffer, given a namespace, service, and control plane // namespace. -func RenderProto(fileName, namespace, name, clusterDomain string, w io.Writer) error { +func RenderProto(fileName, namespace, name, clusterDomain string) (*sp.ServiceProfile, error) { input, err := readFile(fileName) if err != nil { - return err + return nil, err } parser := proto.NewParser(input) - profile, err := protoToServiceProfile(parser, namespace, name, clusterDomain) - if err != nil { - return err - } - - return writeProfile(*profile, w) + return protoToServiceProfile(parser, namespace, name, clusterDomain) } func protoToServiceProfile(parser *proto.Parser, namespace, name, clusterDomain string) (*sp.ServiceProfile, error) { diff --git a/viz/cmd/profile.go b/viz/cmd/profile.go index ed33fb5ce..ba7016c61 100644 --- a/viz/cmd/profile.go +++ b/viz/cmd/profile.go @@ -3,6 +3,7 @@ package cmd import ( "bufio" "context" + "encoding/json" "errors" "fmt" "io" @@ -36,12 +37,14 @@ type profileOptions struct { tap string tapDuration time.Duration tapRouteLimit uint + output string } func newProfileOptions() *profileOptions { return &profileOptions{ tapDuration: 5 * time.Second, tapRouteLimit: 20, + output: "yaml", } } @@ -136,13 +139,14 @@ func newCmdProfile() *cobra.Command { if cd := values.ClusterDomain; cd != "" { clusterDomain = cd } - return renderTapOutputProfile(cmd.Context(), k8sAPI, options.tap, options.namespace, options.name, clusterDomain, options.tapDuration, int(options.tapRouteLimit), os.Stdout) + return renderTapOutputProfile(cmd.Context(), k8sAPI, options.tap, options.namespace, options.name, clusterDomain, options.tapDuration, int(options.tapRouteLimit), options.output, os.Stdout) }, } cmd.PersistentFlags().StringVar(&options.tap, "tap", options.tap, "Output a service profile based on tap data for the given target resource") cmd.PersistentFlags().DurationVar(&options.tapDuration, "tap-duration", options.tapDuration, "Duration over which tap data is collected (for example: \"10s\", \"1m\", \"10m\")") cmd.PersistentFlags().UintVar(&options.tapRouteLimit, "tap-route-limit", options.tapRouteLimit, "Max number of routes to add to the profile") cmd.PersistentFlags().StringVarP(&options.namespace, "namespace", "n", options.namespace, "Namespace of the service") + cmd.PersistentFlags().StringVarP(&options.output, "output", "o", options.output, "Output format. One of: yaml, json") pkgcmd.ConfigureNamespaceFlagCompletion( cmd, []string{"namespace"}, @@ -153,7 +157,7 @@ func newCmdProfile() *cobra.Command { // renderTapOutputProfile performs a tap on the desired resource and generates // a service profile with routes pre-populated from the tap data // Only inbound tap traffic is considered. -func renderTapOutputProfile(ctx context.Context, k8sAPI *k8s.KubernetesAPI, tapResource, namespace, name, clusterDomain string, tapDuration time.Duration, routeLimit int, w io.Writer) error { +func renderTapOutputProfile(ctx context.Context, k8sAPI *k8s.KubernetesAPI, tapResource, namespace, name, clusterDomain string, tapDuration time.Duration, routeLimit int, format string, w io.Writer) error { requestParams := pkg.TapRequestParams{ Resource: tapResource, Namespace: namespace, @@ -167,7 +171,14 @@ func renderTapOutputProfile(ctx context.Context, k8sAPI *k8s.KubernetesAPI, tapR if err != nil { return err } - output, err := yaml.Marshal(profile) + var output []byte + if format == "yaml" { + output, err = yaml.Marshal(profile) + } else if format == "json" { + output, err = json.Marshal(profile) + } else { + return errors.New("output format must be one of yaml or json") + } if err != nil { return fmt.Errorf("Error writing Service Profile: %w", err) } diff --git a/web/srv/handlers.go b/web/srv/handlers.go index e3c9e84a3..e6ac5e7c5 100644 --- a/web/srv/handlers.go +++ b/web/srv/handlers.go @@ -74,7 +74,7 @@ func (h *handler) handleProfileDownload(w http.ResponseWriter, req *http.Request } profileYaml := &bytes.Buffer{} - err := profiles.RenderProfileTemplate(namespace, service, h.clusterDomain, profileYaml) + err := profiles.RenderProfileTemplate(namespace, service, h.clusterDomain, profileYaml, "yaml") if err != nil { log.Error(err)