Add json output format support to linkerd profile command (#12611)

Add an`-o/--output` flag to the `linkerd profile` command which outputs ServiceProfile manifests.  The supported output formats are yaml (default) and json.  Both of these formats are supported by kubectl and either one can be piped into `kubectl apply`.

Signed-off-by: Alex Leong <alex@buoyant.io>
This commit is contained in:
Alex Leong 2024-05-21 16:37:14 -07:00 committed by GitHub
parent 10b1a7af6a
commit 5a67e83ff5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 76 additions and 41 deletions

View File

@ -1,16 +1,20 @@
package cmd package cmd
import ( import (
"encoding/json"
"errors" "errors"
"fmt" "fmt"
"io"
"os" "os"
sp "github.com/linkerd/linkerd2/controller/gen/apis/serviceprofile/v1alpha2"
pkgcmd "github.com/linkerd/linkerd2/pkg/cmd" pkgcmd "github.com/linkerd/linkerd2/pkg/cmd"
"github.com/linkerd/linkerd2/pkg/healthcheck" "github.com/linkerd/linkerd2/pkg/healthcheck"
"github.com/linkerd/linkerd2/pkg/k8s" "github.com/linkerd/linkerd2/pkg/k8s"
"github.com/linkerd/linkerd2/pkg/profiles" "github.com/linkerd/linkerd2/pkg/profiles"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"k8s.io/apimachinery/pkg/util/validation" "k8s.io/apimachinery/pkg/util/validation"
"sigs.k8s.io/yaml"
) )
type profileOptions struct { type profileOptions struct {
@ -20,6 +24,7 @@ type profileOptions struct {
openAPI string openAPI string
proto string proto string
ignoreCluster bool ignoreCluster bool
output string
} }
func newProfileOptions() *profileOptions { func newProfileOptions() *profileOptions {
@ -29,6 +34,7 @@ func newProfileOptions() *profileOptions {
openAPI: "", openAPI: "",
proto: "", proto: "",
ignoreCluster: false, ignoreCluster: false,
output: "yaml",
} }
} }
@ -112,16 +118,21 @@ func newCmdProfile() *cobra.Command {
} }
} }
var profile *sp.ServiceProfile
if options.template { 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 != "" { } 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 != "" { } 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 writeProfile(profile, os.Stdout, options.output)
return errors.New("Unexpected error")
}, },
} }
@ -130,6 +141,23 @@ func newCmdProfile() *cobra.Command {
cmd.PersistentFlags().StringVarP(&options.namespace, "namespace", "n", options.namespace, "Namespace of the service") 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().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().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 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
}

View File

@ -14,7 +14,7 @@ import (
func TestParseProfile(t *testing.T) { func TestParseProfile(t *testing.T) {
var buf bytes.Buffer var buf bytes.Buffer
err := profiles.RenderProfileTemplate("myns", "mysvc", "mycluster.local", &buf) err := profiles.RenderProfileTemplate("myns", "mysvc", "mycluster.local", &buf, "yaml")
if err != nil { if err != nil {
t.Fatalf("Error rendering service profile template: %v", err) t.Fatalf("Error rendering service profile template: %v", err)
} }

View File

@ -21,31 +21,31 @@ const (
// RenderOpenAPI reads an OpenAPI spec file and renders the corresponding // RenderOpenAPI reads an OpenAPI spec file and renders the corresponding
// ServiceProfile to a buffer, given a namespace, service, and control plane // ServiceProfile to a buffer, given a namespace, service, and control plane
// namespace. // 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) input, err := readFile(fileName)
if err != nil { if err != nil {
return err return nil, err
} }
bytes, err := io.ReadAll(input) bytes, err := io.ReadAll(input)
if err != nil { 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) json, err := yaml.YAMLToJSON(bytes)
if err != nil { if err != nil {
return fmt.Errorf("Error parsing yaml: %w", err) return nil, fmt.Errorf("Error parsing yaml: %w", err)
} }
swagger := spec.Swagger{} swagger := spec.Swagger{}
err = swagger.UnmarshalJSON(json) err = swagger.UnmarshalJSON(json)
if err != nil { 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) profile := swaggerToServiceProfile(swagger, namespace, name, clusterDomain)
return writeProfile(profile, w) return &profile, nil
} }
func swaggerToServiceProfile(swagger spec.Swagger, namespace, name, clusterDomain string) sp.ServiceProfile { func swaggerToServiceProfile(swagger spec.Swagger, namespace, name, clusterDomain string) sp.ServiceProfile {

View File

@ -15,6 +15,7 @@ import (
"github.com/linkerd/linkerd2/pkg/k8s" "github.com/linkerd/linkerd2/pkg/k8s"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/util/validation" "k8s.io/apimachinery/pkg/util/validation"
yamlDecoder "k8s.io/apimachinery/pkg/util/yaml"
"sigs.k8s.io/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 // RenderProfileTemplate renders a ServiceProfile template to a buffer, given a
// namespace, service, and control plane namespace. // 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) config := buildConfig(namespace, service, clusterDomain)
template, err := template.New("profile").Parse(Template) template, err := template.New("profile").Parse(Template)
if err != nil { if err != nil {
@ -222,8 +223,20 @@ func RenderProfileTemplate(namespace, service, clusterDomain string, w io.Writer
return err 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()) _, err = w.Write(buf.Bytes())
return err return err
}
return fmt.Errorf("unknown output format: %s", format)
} }
func readFile(fileName string) (io.Reader, error) { func readFile(fileName string) (io.Reader, error) {
@ -233,15 +246,6 @@ func readFile(fileName string) (io.Reader, error) {
return os.Open(filepath.Clean(fileName)) 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. // PathToRegex converts a path into a regex.
func PathToRegex(path string) string { func PathToRegex(path string) string {
escaped := regexp.QuoteMeta(path) escaped := regexp.QuoteMeta(path)

View File

@ -42,11 +42,9 @@ func FuzzRenderProto(data []byte) int {
if err != nil { if err != nil {
return 0 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 { if err != nil {
return 0 return 0
} }
defer w.Close()
_ = RenderProto(protofile.Name(), namespace, name, clusterDomain, w)
return 1 return 1
} }

View File

@ -2,7 +2,6 @@ package profiles
import ( import (
"fmt" "fmt"
"io"
"net/http" "net/http"
"regexp" "regexp"
"strings" "strings"
@ -15,20 +14,15 @@ import (
// RenderProto reads a protobuf definition file and renders the corresponding // RenderProto reads a protobuf definition file and renders the corresponding
// ServiceProfile to a buffer, given a namespace, service, and control plane // ServiceProfile to a buffer, given a namespace, service, and control plane
// namespace. // 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) input, err := readFile(fileName)
if err != nil { if err != nil {
return err return nil, err
} }
parser := proto.NewParser(input) parser := proto.NewParser(input)
profile, err := protoToServiceProfile(parser, namespace, name, clusterDomain) return protoToServiceProfile(parser, namespace, name, clusterDomain)
if err != nil {
return err
}
return writeProfile(*profile, w)
} }
func protoToServiceProfile(parser *proto.Parser, namespace, name, clusterDomain string) (*sp.ServiceProfile, error) { func protoToServiceProfile(parser *proto.Parser, namespace, name, clusterDomain string) (*sp.ServiceProfile, error) {

View File

@ -3,6 +3,7 @@ package cmd
import ( import (
"bufio" "bufio"
"context" "context"
"encoding/json"
"errors" "errors"
"fmt" "fmt"
"io" "io"
@ -36,12 +37,14 @@ type profileOptions struct {
tap string tap string
tapDuration time.Duration tapDuration time.Duration
tapRouteLimit uint tapRouteLimit uint
output string
} }
func newProfileOptions() *profileOptions { func newProfileOptions() *profileOptions {
return &profileOptions{ return &profileOptions{
tapDuration: 5 * time.Second, tapDuration: 5 * time.Second,
tapRouteLimit: 20, tapRouteLimit: 20,
output: "yaml",
} }
} }
@ -136,13 +139,14 @@ func newCmdProfile() *cobra.Command {
if cd := values.ClusterDomain; cd != "" { if cd := values.ClusterDomain; cd != "" {
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().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().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().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.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( pkgcmd.ConfigureNamespaceFlagCompletion(
cmd, []string{"namespace"}, cmd, []string{"namespace"},
@ -153,7 +157,7 @@ func newCmdProfile() *cobra.Command {
// renderTapOutputProfile performs a tap on the desired resource and generates // renderTapOutputProfile performs a tap on the desired resource and generates
// a service profile with routes pre-populated from the tap data // a service profile with routes pre-populated from the tap data
// Only inbound tap traffic is considered. // 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{ requestParams := pkg.TapRequestParams{
Resource: tapResource, Resource: tapResource,
Namespace: namespace, Namespace: namespace,
@ -167,7 +171,14 @@ func renderTapOutputProfile(ctx context.Context, k8sAPI *k8s.KubernetesAPI, tapR
if err != nil { if err != nil {
return err 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 { if err != nil {
return fmt.Errorf("Error writing Service Profile: %w", err) return fmt.Errorf("Error writing Service Profile: %w", err)
} }

View File

@ -74,7 +74,7 @@ func (h *handler) handleProfileDownload(w http.ResponseWriter, req *http.Request
} }
profileYaml := &bytes.Buffer{} profileYaml := &bytes.Buffer{}
err := profiles.RenderProfileTemplate(namespace, service, h.clusterDomain, profileYaml) err := profiles.RenderProfileTemplate(namespace, service, h.clusterDomain, profileYaml, "yaml")
if err != nil { if err != nil {
log.Error(err) log.Error(err)