mirror of https://github.com/linkerd/linkerd2.git
532 lines
13 KiB
Go
532 lines
13 KiB
Go
package profiles
|
|
|
|
import (
|
|
"bytes"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"text/template"
|
|
"time"
|
|
|
|
"github.com/golang/protobuf/ptypes/duration"
|
|
pb "github.com/linkerd/linkerd2-proxy-api/go/destination"
|
|
sp "github.com/linkerd/linkerd2/controller/gen/apis/serviceprofile/v1alpha1" // TODO: pkg/profiles should not depend on controller/gen
|
|
"github.com/linkerd/linkerd2/pkg/k8s"
|
|
"github.com/linkerd/linkerd2/pkg/util"
|
|
log "github.com/sirupsen/logrus"
|
|
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
|
"k8s.io/apimachinery/pkg/util/validation"
|
|
"sigs.k8s.io/yaml"
|
|
)
|
|
|
|
type profileTemplateConfig struct {
|
|
ServiceNamespace string
|
|
ServiceName string
|
|
ClusterZone string
|
|
}
|
|
|
|
var (
|
|
// DefaultRetryBudget is used for routes which do not specify one.
|
|
DefaultRetryBudget = pb.RetryBudget{
|
|
MinRetriesPerSecond: 10,
|
|
RetryRatio: 0.2,
|
|
Ttl: &duration.Duration{
|
|
Seconds: 10,
|
|
},
|
|
}
|
|
// serviceProfileMeta is the TypeMeta for the ServiceProfile custom resource.
|
|
serviceProfileMeta = metav1.TypeMeta{
|
|
APIVersion: k8s.ServiceProfileAPIVersion,
|
|
Kind: k8s.ServiceProfileKind,
|
|
}
|
|
// DefaultServiceProfile is used for services with no service profile.
|
|
DefaultServiceProfile = pb.DestinationProfile{
|
|
Routes: []*pb.Route{},
|
|
RetryBudget: &DefaultRetryBudget,
|
|
}
|
|
// DefaultRouteTimeout is the default timeout for routes that do not specify
|
|
// one.
|
|
DefaultRouteTimeout = 10 * time.Second
|
|
|
|
minStatus uint32 = 100
|
|
maxStatus uint32 = 599
|
|
|
|
clusterZoneSuffix = "svc.cluster.local"
|
|
|
|
errRequestMatchField = errors.New("A request match must have a field set")
|
|
errResponseMatchField = errors.New("A response match must have a field set")
|
|
)
|
|
|
|
func toDuration(d time.Duration) *duration.Duration {
|
|
return &duration.Duration{
|
|
Seconds: int64(d / time.Second),
|
|
Nanos: int32(d % time.Second),
|
|
}
|
|
}
|
|
|
|
// ToServiceProfile returns a Proxy API DestinationProfile, given a
|
|
// ServiceProfile.
|
|
func ToServiceProfile(profile *sp.ServiceProfile) (*pb.DestinationProfile, error) {
|
|
routes := make([]*pb.Route, 0)
|
|
for _, route := range profile.Spec.Routes {
|
|
pbRoute, err := ToRoute(profile, route)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
routes = append(routes, pbRoute)
|
|
}
|
|
budget := DefaultRetryBudget
|
|
if profile.Spec.RetryBudget != nil {
|
|
budget.MinRetriesPerSecond = profile.Spec.RetryBudget.MinRetriesPerSecond
|
|
budget.RetryRatio = profile.Spec.RetryBudget.RetryRatio
|
|
ttl, err := time.ParseDuration(profile.Spec.RetryBudget.TTL)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
budget.Ttl = toDuration(ttl)
|
|
}
|
|
return &pb.DestinationProfile{
|
|
Routes: routes,
|
|
RetryBudget: &budget,
|
|
}, nil
|
|
}
|
|
|
|
// ToRoute returns a Proxy API Route, given a ServiceProfile Route.
|
|
func ToRoute(profile *sp.ServiceProfile, route *sp.RouteSpec) (*pb.Route, error) {
|
|
cond, err := ToRequestMatch(route.Condition)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
rcs := make([]*pb.ResponseClass, 0)
|
|
for _, rc := range route.ResponseClasses {
|
|
pbRc, err := ToResponseClass(rc)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
rcs = append(rcs, pbRc)
|
|
}
|
|
timeout := DefaultRouteTimeout
|
|
if route.Timeout != "" {
|
|
timeout, err = time.ParseDuration(route.Timeout)
|
|
if err != nil {
|
|
log.Errorf(
|
|
"failed to parse duration for route '%s' in service profile '%s' in namespace '%s': %s",
|
|
route.Name,
|
|
profile.Name,
|
|
profile.Namespace,
|
|
err,
|
|
)
|
|
timeout = DefaultRouteTimeout
|
|
}
|
|
}
|
|
ret := pb.Route{
|
|
Condition: cond,
|
|
ResponseClasses: rcs,
|
|
MetricsLabels: map[string]string{"route": route.Name},
|
|
IsRetryable: route.IsRetryable,
|
|
Timeout: toDuration(timeout),
|
|
}
|
|
return &ret, nil
|
|
}
|
|
|
|
// ToResponseClass returns a Proxy API ResponseClass, given a ServiceProfile
|
|
// ResponseClass.
|
|
func ToResponseClass(rc *sp.ResponseClass) (*pb.ResponseClass, error) {
|
|
cond, err := ToResponseMatch(rc.Condition)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &pb.ResponseClass{
|
|
Condition: cond,
|
|
IsFailure: rc.IsFailure,
|
|
}, nil
|
|
}
|
|
|
|
// ToResponseMatch returns a Proxy API ResponseMatch, given a ServiceProfile
|
|
// ResponseMatch.
|
|
func ToResponseMatch(rspMatch *sp.ResponseMatch) (*pb.ResponseMatch, error) {
|
|
if rspMatch == nil {
|
|
return nil, errors.New("missing response match")
|
|
}
|
|
err := ValidateResponseMatch(rspMatch)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
matches := make([]*pb.ResponseMatch, 0)
|
|
|
|
if rspMatch.All != nil {
|
|
all := make([]*pb.ResponseMatch, 0)
|
|
for _, m := range rspMatch.All {
|
|
pbM, err := ToResponseMatch(m)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
all = append(all, pbM)
|
|
}
|
|
matches = append(matches, &pb.ResponseMatch{
|
|
Match: &pb.ResponseMatch_All{
|
|
All: &pb.ResponseMatch_Seq{
|
|
Matches: all,
|
|
},
|
|
},
|
|
})
|
|
}
|
|
|
|
if rspMatch.Any != nil {
|
|
any := make([]*pb.ResponseMatch, 0)
|
|
for _, m := range rspMatch.Any {
|
|
pbM, err := ToResponseMatch(m)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
any = append(any, pbM)
|
|
}
|
|
matches = append(matches, &pb.ResponseMatch{
|
|
Match: &pb.ResponseMatch_Any{
|
|
Any: &pb.ResponseMatch_Seq{
|
|
Matches: any,
|
|
},
|
|
},
|
|
})
|
|
}
|
|
|
|
if rspMatch.Status != nil {
|
|
matches = append(matches, &pb.ResponseMatch{
|
|
Match: &pb.ResponseMatch_Status{
|
|
Status: &pb.HttpStatusRange{
|
|
Max: rspMatch.Status.Max,
|
|
Min: rspMatch.Status.Min,
|
|
},
|
|
},
|
|
})
|
|
}
|
|
|
|
if rspMatch.Not != nil {
|
|
not, err := ToResponseMatch(rspMatch.Not)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
matches = append(matches, &pb.ResponseMatch{
|
|
Match: &pb.ResponseMatch_Not{
|
|
Not: not,
|
|
},
|
|
})
|
|
}
|
|
|
|
if len(matches) == 0 {
|
|
return nil, errResponseMatchField
|
|
}
|
|
if len(matches) == 1 {
|
|
return matches[0], nil
|
|
}
|
|
return &pb.ResponseMatch{
|
|
Match: &pb.ResponseMatch_All{
|
|
All: &pb.ResponseMatch_Seq{
|
|
Matches: matches,
|
|
},
|
|
},
|
|
}, nil
|
|
}
|
|
|
|
// ToRequestMatch returns a Proxy API RequestMatch, given a ServiceProfile
|
|
// RequestMatch.
|
|
func ToRequestMatch(reqMatch *sp.RequestMatch) (*pb.RequestMatch, error) {
|
|
if reqMatch == nil {
|
|
return nil, errors.New("missing request match")
|
|
}
|
|
err := ValidateRequestMatch(reqMatch)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
matches := make([]*pb.RequestMatch, 0)
|
|
|
|
if reqMatch.All != nil {
|
|
all := make([]*pb.RequestMatch, 0)
|
|
for _, m := range reqMatch.All {
|
|
pbM, err := ToRequestMatch(m)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
all = append(all, pbM)
|
|
}
|
|
matches = append(matches, &pb.RequestMatch{
|
|
Match: &pb.RequestMatch_All{
|
|
All: &pb.RequestMatch_Seq{
|
|
Matches: all,
|
|
},
|
|
},
|
|
})
|
|
}
|
|
|
|
if reqMatch.Any != nil {
|
|
any := make([]*pb.RequestMatch, 0)
|
|
for _, m := range reqMatch.Any {
|
|
pbM, err := ToRequestMatch(m)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
any = append(any, pbM)
|
|
}
|
|
matches = append(matches, &pb.RequestMatch{
|
|
Match: &pb.RequestMatch_Any{
|
|
Any: &pb.RequestMatch_Seq{
|
|
Matches: any,
|
|
},
|
|
},
|
|
})
|
|
}
|
|
|
|
if reqMatch.Method != "" {
|
|
matches = append(matches, &pb.RequestMatch{
|
|
Match: &pb.RequestMatch_Method{
|
|
Method: util.ParseMethod(reqMatch.Method),
|
|
},
|
|
})
|
|
}
|
|
|
|
if reqMatch.Not != nil {
|
|
not, err := ToRequestMatch(reqMatch.Not)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
matches = append(matches, &pb.RequestMatch{
|
|
Match: &pb.RequestMatch_Not{
|
|
Not: not,
|
|
},
|
|
})
|
|
}
|
|
|
|
if reqMatch.PathRegex != "" {
|
|
matches = append(matches, &pb.RequestMatch{
|
|
Match: &pb.RequestMatch_Path{
|
|
Path: &pb.PathMatch{
|
|
Regex: reqMatch.PathRegex,
|
|
},
|
|
},
|
|
})
|
|
}
|
|
|
|
if len(matches) == 0 {
|
|
return nil, errRequestMatchField
|
|
}
|
|
if len(matches) == 1 {
|
|
return matches[0], nil
|
|
}
|
|
return &pb.RequestMatch{
|
|
Match: &pb.RequestMatch_All{
|
|
All: &pb.RequestMatch_Seq{
|
|
Matches: matches,
|
|
},
|
|
},
|
|
}, nil
|
|
}
|
|
|
|
// Validate validates the structure of a ServiceProfile. This code is a superset
|
|
// of the validation provided by the `openAPIV3Schema`, defined in the
|
|
// ServiceProfile CRD.
|
|
// openAPIV3Schema validates:
|
|
// - types of non-recursive fields
|
|
// - presence of required fields
|
|
// This function validates:
|
|
// - types of all fields
|
|
// - presence of required fields
|
|
// - presence of unknown fields
|
|
// - recursive fields
|
|
func Validate(data []byte) error {
|
|
var serviceProfile sp.ServiceProfile
|
|
err := yaml.UnmarshalStrict(data, &serviceProfile)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to validate ServiceProfile: %s", err)
|
|
}
|
|
|
|
errs := validation.IsDNS1123Subdomain(serviceProfile.Name)
|
|
if len(errs) > 0 {
|
|
return fmt.Errorf("ServiceProfile \"%s\" has invalid name: %s", serviceProfile.Name, errs[0])
|
|
}
|
|
|
|
if len(serviceProfile.Spec.Routes) == 0 {
|
|
return fmt.Errorf("ServiceProfile \"%s\" has no routes", serviceProfile.Name)
|
|
}
|
|
|
|
for _, route := range serviceProfile.Spec.Routes {
|
|
if route.Name == "" {
|
|
return fmt.Errorf("ServiceProfile \"%s\" has a route with no name", serviceProfile.Name)
|
|
}
|
|
if route.Timeout != "" {
|
|
_, err := time.ParseDuration(route.Timeout)
|
|
if err != nil {
|
|
return fmt.Errorf("ServiceProfile \"%s\" has a route with an invalid timeout: %s", serviceProfile.Name, err)
|
|
}
|
|
}
|
|
if route.Condition == nil {
|
|
return fmt.Errorf("ServiceProfile \"%s\" has a route with no condition", serviceProfile.Name)
|
|
}
|
|
err := ValidateRequestMatch(route.Condition)
|
|
if err != nil {
|
|
return fmt.Errorf("ServiceProfile \"%s\" has a route with an invalid condition: %s", serviceProfile.Name, err)
|
|
}
|
|
for _, rc := range route.ResponseClasses {
|
|
if rc.Condition == nil {
|
|
return fmt.Errorf("ServiceProfile \"%s\" has a response class with no condition", serviceProfile.Name)
|
|
}
|
|
err = ValidateResponseMatch(rc.Condition)
|
|
if err != nil {
|
|
return fmt.Errorf("ServiceProfile \"%s\" has a response class with an invalid condition: %s", serviceProfile.Name, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
rb := serviceProfile.Spec.RetryBudget
|
|
if rb != nil {
|
|
if rb.RetryRatio < 0 {
|
|
return fmt.Errorf("ServiceProfile \"%s\" RetryBudget RetryRatio must be non-negative: %f", serviceProfile.Name, rb.RetryRatio)
|
|
}
|
|
|
|
if rb.TTL == "" {
|
|
return fmt.Errorf("ServiceProfile \"%s\" RetryBudget missing TTL field", serviceProfile.Name)
|
|
}
|
|
|
|
_, err := time.ParseDuration(rb.TTL)
|
|
if err != nil {
|
|
return fmt.Errorf("ServiceProfile \"%s\" RetryBudget: %s", serviceProfile.Name, err)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// ValidateRequestMatch validates whether a ServiceProfile RequestMatch has at
|
|
// least one field set.
|
|
func ValidateRequestMatch(reqMatch *sp.RequestMatch) error {
|
|
matchKindSet := false
|
|
if reqMatch.All != nil {
|
|
matchKindSet = true
|
|
for _, child := range reqMatch.All {
|
|
err := ValidateRequestMatch(child)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
if reqMatch.Any != nil {
|
|
matchKindSet = true
|
|
for _, child := range reqMatch.Any {
|
|
err := ValidateRequestMatch(child)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
if reqMatch.Method != "" {
|
|
matchKindSet = true
|
|
}
|
|
if reqMatch.Not != nil {
|
|
matchKindSet = true
|
|
err := ValidateRequestMatch(reqMatch.Not)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if reqMatch.PathRegex != "" {
|
|
matchKindSet = true
|
|
}
|
|
|
|
if !matchKindSet {
|
|
return errRequestMatchField
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// ValidateResponseMatch validates whether a ServiceProfile ResponseMatch has at
|
|
// least one field set, and sanity checks the Status Range.
|
|
func ValidateResponseMatch(rspMatch *sp.ResponseMatch) error {
|
|
matchKindSet := false
|
|
if rspMatch.All != nil {
|
|
matchKindSet = true
|
|
for _, child := range rspMatch.All {
|
|
err := ValidateResponseMatch(child)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
if rspMatch.Any != nil {
|
|
matchKindSet = true
|
|
for _, child := range rspMatch.Any {
|
|
err := ValidateResponseMatch(child)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
if rspMatch.Status != nil {
|
|
if rspMatch.Status.Min != 0 && (rspMatch.Status.Min < minStatus || rspMatch.Status.Min > maxStatus) {
|
|
return fmt.Errorf("Range minimum must be between %d and %d, inclusive", minStatus, maxStatus)
|
|
} else if rspMatch.Status.Max != 0 && (rspMatch.Status.Max < minStatus || rspMatch.Status.Max > maxStatus) {
|
|
return fmt.Errorf("Range maximum must be between %d and %d, inclusive", minStatus, maxStatus)
|
|
} else if rspMatch.Status.Max != 0 && rspMatch.Status.Min != 0 && rspMatch.Status.Max < rspMatch.Status.Min {
|
|
return errors.New("Range maximum cannot be smaller than minimum")
|
|
}
|
|
matchKindSet = true
|
|
}
|
|
if rspMatch.Not != nil {
|
|
matchKindSet = true
|
|
err := ValidateResponseMatch(rspMatch.Not)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
if !matchKindSet {
|
|
return errResponseMatchField
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func buildConfig(namespace, service string) *profileTemplateConfig {
|
|
return &profileTemplateConfig{
|
|
ServiceNamespace: namespace,
|
|
ServiceName: service,
|
|
ClusterZone: clusterZoneSuffix,
|
|
}
|
|
}
|
|
|
|
// RenderProfileTemplate renders a ServiceProfile template to a buffer, given a
|
|
// namespace, service, and control plane namespace.
|
|
func RenderProfileTemplate(namespace, service string, w io.Writer) error {
|
|
config := buildConfig(namespace, service)
|
|
template, err := template.New("profile").Parse(Template)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
buf := &bytes.Buffer{}
|
|
err = template.Execute(buf, config)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
_, err = w.Write(buf.Bytes())
|
|
return err
|
|
}
|
|
|
|
func readFile(fileName string) (io.Reader, error) {
|
|
if fileName == "-" {
|
|
return os.Stdin, nil
|
|
}
|
|
return os.Open(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: %s", err)
|
|
}
|
|
_, err = w.Write(output)
|
|
return err
|
|
}
|