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
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
}

View File

@ -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)
}

View File

@ -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 {

View File

@ -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)

View File

@ -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
}

View File

@ -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) {

View File

@ -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)
}

View File

@ -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)