// Copyright © 2019 The Knative Authors // // 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 service import ( "errors" "fmt" "io" "regexp" "sort" "strconv" "strings" "time" "k8s.io/apimachinery/pkg/util/sets" "k8s.io/cli-runtime/pkg/genericclioptions" "knative.dev/serving/pkg/apis/autoscaling" "knative.dev/serving/pkg/apis/serving" "knative.dev/client/pkg/printers" client_serving "knative.dev/client/pkg/serving" serving_kn_v1alpha1 "knative.dev/client/pkg/serving/v1alpha1" "knative.dev/pkg/apis" "knative.dev/pkg/apis/duck/v1beta1" "knative.dev/serving/pkg/apis/serving/v1alpha1" "github.com/spf13/cobra" corev1 "k8s.io/api/core/v1" v1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/util/duration" "knative.dev/client/pkg/kn/commands" ) // Command for printing out a description of a service, meant to be consumed by humans // It will show information about the service itself, but also a summary // about the associated revisions. // Whether to print extended information var printDetails bool // Max length When to truncate long strings (when not "all" mode switched on) const truncateAt = 100 // Matching image digest var imageDigestRegexp = regexp.MustCompile(`(?i)sha256:([0-9a-f]{64})`) // View object for collecting revision related information in the context // of a Service. These are plain data types which can be directly used // for printing out type revisionDesc struct { name string configuration string configurationGeneration int creationTimestamp time.Time // traffic stuff percent int tag string latestTraffic *bool // basic revision stuff logURL string timeoutSeconds *int64 image string userImage string imageDigest string env []string port *int32 // concurrency options maxScale *int minScale *int concurrencyTarget *int concurrencyLimit *int64 // resource options requestsMemory string requestsCPU string limitsMemory string limitsCPU string // status info ready corev1.ConditionStatus reason string latestCreated bool latestReady bool } // [REMOVE COMMENT WHEN MOVING TO 0.7.0] // For transition to v1beta1 this command uses the migration approach as described // in https://docs.google.com/presentation/d/1mOhnhy8kA4-K9Necct-NeIwysxze_FUule-8u5ZHmwA/edit#slide=id.p // With serving 0.6.0 we are at step #1 // I.e we first look at new fields of the v1alpha1 API before falling back to the original ones. // As this command does not do any writes/updates, it's just a matter of fallbacks. // [/REMOVE COMMENT WHEN MOVING TO 0.7.0] // NewServiceDescribeCommand returns a new command for describing a service. func NewServiceDescribeCommand(p *commands.KnParams) *cobra.Command { // For machine readable output machineReadablePrintFlags := genericclioptions.NewPrintFlags("") command := &cobra.Command{ Use: "describe NAME", Short: "Show details for a given service", RunE: func(cmd *cobra.Command, args []string) error { if len(args) < 1 { return errors.New("no service name provided") } if len(args) > 1 { return errors.New("more than one service name provided") } serviceName := args[0] namespace, err := p.GetNamespace(cmd) if err != nil { return err } client, err := p.NewClient(namespace) if err != nil { return err } service, err := client.GetService(serviceName) if err != nil { return err } // Print out machine readable output if requested if machineReadablePrintFlags.OutputFlagSpecified() { printer, err := machineReadablePrintFlags.ToPrinter() if err != nil { return err } return printer.PrintObj(service, cmd.OutOrStdout()) } printDetails, err = cmd.Flags().GetBool("verbose") if err != nil { return err } revisionDescs, err := getRevisionDescriptions(client, service, printDetails) if err != nil { return err } return describe(cmd.OutOrStdout(), service, revisionDescs, printDetails) }, } flags := command.Flags() commands.AddNamespaceFlags(flags, false) flags.BoolP("verbose", "v", false, "More output.") machineReadablePrintFlags.AddFlags(command) return command } // Main action describing the service func describe(w io.Writer, service *v1alpha1.Service, revisions []*revisionDesc, printDetails bool) error { dw := printers.NewPrefixWriter(w) // Service info writeService(dw, service) dw.WriteLine() if err := dw.Flush(); err != nil { return err } // Revisions summary info writeRevisions(dw, revisions, printDetails) dw.WriteLine() if err := dw.Flush(); err != nil { return err } // Condition info writeConditions(dw, service) if err := dw.Flush(); err != nil { return err } return nil } // Write out main service information. Use colors for major items. func writeService(dw printers.PrefixWriter, service *v1alpha1.Service) { dw.WriteColsLn(printers.Level0, l("Name"), service.Name) dw.WriteColsLn(printers.Level0, l("Namespace"), service.Namespace) dw.WriteColsLn(printers.Level0, l("URL"), extractURL(service)) if service.Status.Address != nil { url := service.Status.Address.GetURL() dw.WriteColsLn(printers.Level0, l("Address"), url.String()) } writeMapDesc(dw, printers.Level0, service.Labels, l("Labels"), "") writeMapDesc(dw, printers.Level0, service.Annotations, l("Annotations"), "") dw.WriteColsLn(printers.Level0, l("Age"), age(service.CreationTimestamp.Time)) } // Write out revisions associated with this service. By default only active // target revisions are printed, but with --all also inactive revisions // created by this services are shown func writeRevisions(dw printers.PrefixWriter, revisions []*revisionDesc, printDetails bool) { dw.WriteColsLn(printers.Level0, l("Revisions")) for _, revisionDesc := range revisions { dw.WriteColsLn(printers.Level1, formatBullet(revisionDesc.percent, revisionDesc.ready), revisionHeader(revisionDesc)) if revisionDesc.ready == v1.ConditionFalse { dw.WriteColsLn(printers.Level1, "", l("Error"), revisionDesc.reason) } dw.WriteColsLn(printers.Level1, "", l("Image"), getImageDesc(revisionDesc)) if printDetails { if revisionDesc.port != nil { dw.WriteColsLn(printers.Level1, "", l("Port"), strconv.FormatInt(int64(*revisionDesc.port), 10)) } writeSliceDesc(dw, printers.Level1, revisionDesc.env, l("Env"), "\t") // Scale spec if given if revisionDesc.maxScale != nil || revisionDesc.minScale != nil { dw.WriteColsLn(printers.Level1, "", l("Scale"), formatScale(revisionDesc.minScale, revisionDesc.maxScale)) } // Concurrency specs if given if revisionDesc.concurrencyLimit != nil || revisionDesc.concurrencyTarget != nil { writeConcurrencyOptions(dw, revisionDesc) } // Resources if given writeResources(dw, "Memory", revisionDesc.requestsMemory, revisionDesc.limitsMemory) writeResources(dw, "CPU", revisionDesc.requestsCPU, revisionDesc.limitsCPU) } } } // Print out a table with conditions. Use green for 'ok', and red for 'nok' if color is enabled func writeConditions(dw printers.PrefixWriter, service *v1alpha1.Service) { dw.WriteColsLn(printers.Level0, l("Conditions")) maxLen := getMaxTypeLen(service.Status.Conditions) formatHeader := "%-2s %-" + strconv.Itoa(maxLen) + "s %6s %-s\n" formatRow := "%-2s %-" + strconv.Itoa(maxLen) + "s %6s %-s\n" dw.Write(printers.Level1, formatHeader, "OK", "TYPE", "AGE", "REASON") for _, condition := range service.Status.Conditions { ok := formatStatus(condition.Status) reason := condition.Reason if printDetails && reason != "" { reason = fmt.Sprintf("%s (%s)", reason, condition.Message) } dw.Write(printers.Level1, formatRow, ok, formatConditionType(condition), age(condition.LastTransitionTime.Inner.Time), reason) } } func writeConcurrencyOptions(dw printers.PrefixWriter, desc *revisionDesc) { dw.WriteColsLn(printers.Level2, "", l("Concurrency")) if desc.concurrencyLimit != nil { dw.WriteColsLn(printers.Level3, "", "", l("Limit"), strconv.FormatInt(*desc.concurrencyLimit, 10)) } if desc.concurrencyTarget != nil { dw.WriteColsLn(printers.Level3, "", "", l("Target"), strconv.Itoa(*desc.concurrencyTarget)) } } // ====================================================================================== // Helper functions // Format label (extracted so that color could be added more easily to all labels) func l(label string) string { return label + ":" } // Format scale in the format "min ... max" with max = ∞ if not set func formatScale(minScale *int, maxScale *int) string { ret := "0" if minScale != nil { ret = strconv.Itoa(*minScale) } ret += " ... " if maxScale != nil { ret += strconv.Itoa(*maxScale) } else { ret += "∞" } return ret } // Format the revision name along with its generation. Use colors if enabled. func revisionHeader(desc *revisionDesc) string { header := desc.name if desc.latestTraffic != nil && *desc.latestTraffic { header = fmt.Sprintf("@latest (%s)", desc.name) } else if desc.latestReady { header = desc.name + " (current @latest)" } else if desc.latestCreated { header = desc.name + " (latest created)" } if desc.tag != "" { header = fmt.Sprintf("%s #%s", header, desc.tag) } return header + " " + "[" + strconv.Itoa(desc.configurationGeneration) + "]" + " " + "(" + age(desc.creationTimestamp) + ")" } // Used for conditions table to do own formatting for the table, // as the tabbed writer doesn't work nicely with colors func getMaxTypeLen(conditions v1beta1.Conditions) int { max := 0 for _, condition := range conditions { if len(condition.Type) > max { max = len(condition.Type) } } return max } // Color the type of the conditions func formatConditionType(condition apis.Condition) string { return string(condition.Type) } // Status in ASCII format func formatStatus(status corev1.ConditionStatus) string { switch status { case v1.ConditionTrue: return "++" case v1.ConditionFalse: return "!!" default: return "??" } } // Return either image name with tag or together with its resolved digest func getImageDesc(desc *revisionDesc) string { image := desc.image // Check if the user image is likely a more user-friendly description pinnedDesc := "at" if desc.userImage != "" && desc.imageDigest != "" { parts := strings.Split(image, "@") // Check if the user image refers to the same thing. if strings.HasPrefix(desc.userImage, parts[0]) { pinnedDesc = "pinned to" image = desc.userImage } } if desc.imageDigest != "" { return fmt.Sprintf("%s (%s %s)", image, pinnedDesc, shortenDigest(desc.imageDigest)) } return image } // Extract pure sha sum and shorten to 8 digits, // as the digest should to be user consumable. Use the resource via `kn service get` // to get to the full sha func shortenDigest(digest string) string { match := imageDigestRegexp.FindStringSubmatch(digest) if len(match) > 1 { return string(match[1][:6]) } return digest } var boringDomains = map[string]bool{ "serving.knative.dev": true, "client.knative.dev": true, "kubectl.kubernetes.io": true, } // Write a map either compact in a single line (possibly truncated) or, if printDetails is set, // over multiple line, one line per key-value pair. The output is sorted by keys. func writeMapDesc(dw printers.PrefixWriter, indent int, m map[string]string, label string, labelPrefix string) { if len(m) == 0 { return } var keys []string for k := range m { parts := strings.Split(k, "/") if printDetails || len(parts) <= 1 || !boringDomains[parts[0]] { keys = append(keys, k) } } if len(keys) == 0 { return } sort.Strings(keys) if printDetails { l := labelPrefix + label for _, key := range keys { dw.WriteColsLn(indent, l, key+"="+m[key]) l = labelPrefix } return } dw.WriteColsLn(indent, label, joinAndTruncate(keys, m)) } // Writer a slice compact (printDetails == false) in one line, or over multiple line // with key-value line-by-line (printDetails == true) func writeSliceDesc(dw printers.PrefixWriter, indent int, s []string, label string, labelPrefix string) { if len(s) == 0 { return } if printDetails { l := labelPrefix + label for _, value := range s { dw.WriteColsLn(indent, l, value) l = labelPrefix } return } joined := strings.Join(s, ", ") if len(joined) > truncateAt { joined = joined[:truncateAt-4] + " ..." } dw.WriteColsLn(indent, labelPrefix+label, joined) } // Write request ... limits or only one of them func writeResources(dw printers.PrefixWriter, label string, request string, limit string) { value := "" if request != "" && limit != "" { value = request + " ... " + limit } else if request != "" { value = request } else if limit != "" { value = limit } if value == "" { return } dw.WriteColsLn(printers.Level2, "", l(label), value) } // Join to key=value pair, comma separated, and truncate if longer than a limit func joinAndTruncate(sortedKeys []string, m map[string]string) string { ret := "" for _, key := range sortedKeys { ret += fmt.Sprintf("%s=%s, ", key, m[key]) if len(ret) > truncateAt { break } } // cut of two latest chars ret = strings.TrimRight(ret, ", ") if len(ret) <= truncateAt { return ret } return string(ret[:truncateAt-4]) + " ..." } // Format target percentage that it fits in the revision table func formatBullet(percentage int, status corev1.ConditionStatus) string { symbol := "+" switch status { case v1.ConditionTrue: if percentage > 0 { symbol = "%" } case v1.ConditionFalse: symbol = "!" default: symbol = "?" } if percentage == 0 { return fmt.Sprintf(" %s", symbol) } return fmt.Sprintf("%3d%s", percentage, symbol) } func age(t time.Time) string { if t.IsZero() { return "" } return duration.ShortHumanDuration(time.Now().Sub(t)) } // Call the backend to query revisions for the given service and build up // the view objects used for output func getRevisionDescriptions(client serving_kn_v1alpha1.KnServingClient, service *v1alpha1.Service, withDetails bool) ([]*revisionDesc, error) { revisionsSeen := sets.NewString() revisionDescs := []*revisionDesc{} trafficTargets := service.Status.Traffic var err error for _, target := range trafficTargets { revision, err := extractRevisionFromTarget(client, target) if err != nil { return nil, fmt.Errorf("cannot extract revision from service %s: %v", service.Name, err) } revisionsSeen.Insert(revision.Name) desc, err := newRevisionDesc(revision, &target, service) if err != nil { return nil, err } revisionDescs = append(revisionDescs, desc) } if revisionDescs, err = completeWithLatestRevisions(client, service, revisionsSeen, revisionDescs); err != nil { return nil, err } if withDetails { if revisionDescs, err = completeWithUntargetedRevisions(client, service, revisionsSeen, revisionDescs); err != nil { return nil, err } } return orderByConfigurationGeneration(revisionDescs), nil } // Order the list of revisions so that the newest revisions are at the top func orderByConfigurationGeneration(descs []*revisionDesc) []*revisionDesc { sort.SliceStable(descs, func(i, j int) bool { return descs[i].configurationGeneration > descs[j].configurationGeneration }) return descs } func completeWithLatestRevisions(client serving_kn_v1alpha1.KnServingClient, service *v1alpha1.Service, revisionsSeen sets.String, descs []*revisionDesc) ([]*revisionDesc, error) { for _, revisionName := range []string{service.Status.LatestCreatedRevisionName, service.Status.LatestReadyRevisionName} { if revisionsSeen.Has(revisionName) { continue } revisionsSeen.Insert(revisionName) rev, err := client.GetRevision(revisionName) if err != nil { return nil, err } newDesc, err := newRevisionDesc(rev, nil, service) if err != nil { return nil, err } descs = append(descs, newDesc) } return descs, nil } func completeWithUntargetedRevisions(client serving_kn_v1alpha1.KnServingClient, service *v1alpha1.Service, revisionsSeen sets.String, descs []*revisionDesc) ([]*revisionDesc, error) { revisions, err := client.ListRevisions(serving_kn_v1alpha1.WithService(service.Name)) if err != nil { return nil, err } for _, revision := range revisions.Items { if revisionsSeen.Has(revision.Name) { continue } revisionsSeen.Insert(revision.Name) newDesc, err := newRevisionDesc(&revision, nil, service) if err != nil { return nil, err } descs = append(descs, newDesc) } return descs, nil } func newRevisionDesc(revision *v1alpha1.Revision, target *v1alpha1.TrafficTarget, service *v1alpha1.Service) (*revisionDesc, error) { container := extractContainer(revision) generation, err := strconv.ParseInt(revision.Labels[serving.ConfigurationGenerationLabelKey], 0, 0) if err != nil { return nil, fmt.Errorf("cannot extract configuration generation for revision %s: %v", revision.Name, err) } revisionDesc := revisionDesc{ name: revision.Name, logURL: revision.Status.LogURL, timeoutSeconds: revision.Spec.TimeoutSeconds, userImage: revision.Annotations[client_serving.UserImageAnnotationKey], imageDigest: revision.Status.ImageDigest, creationTimestamp: revision.CreationTimestamp.Time, configurationGeneration: int(generation), configuration: revision.Labels[serving.ConfigurationLabelKey], latestCreated: revision.Name == service.Status.LatestCreatedRevisionName, latestReady: revision.Name == service.Status.LatestReadyRevisionName, } addStatusInfo(&revisionDesc, revision) addTargetInfo(&revisionDesc, target) addContainerInfo(&revisionDesc, container) addResourcesInfo(&revisionDesc, container) err = addConcurrencyAndScaleInfo(&revisionDesc, revision) if err != nil { return nil, err } return &revisionDesc, nil } func addStatusInfo(desc *revisionDesc, revision *v1alpha1.Revision) { for _, condition := range revision.Status.Conditions { if condition.Type == "Ready" { desc.reason = condition.Reason desc.ready = condition.Status } } } func addTargetInfo(desc *revisionDesc, target *v1alpha1.TrafficTarget) { if target != nil { desc.percent = target.Percent desc.latestTraffic = target.LatestRevision desc.tag = target.Tag } } func addContainerInfo(desc *revisionDesc, container *v1.Container) { addImage(desc, container) addEnv(desc, container) addPort(desc, container) } func addResourcesInfo(desc *revisionDesc, container *v1.Container) { requests := container.Resources.Requests if !requests.Memory().IsZero() { desc.requestsMemory = requests.Memory().String() } if !requests.Cpu().IsZero() { desc.requestsCPU = requests.Cpu().String() } limits := container.Resources.Limits if !limits.Memory().IsZero() { desc.limitsMemory = limits.Memory().String() } if !limits.Cpu().IsZero() { desc.limitsCPU = limits.Cpu().String() } } func addConcurrencyAndScaleInfo(desc *revisionDesc, revision *v1alpha1.Revision) error { min, err := annotationAsInt(revision, autoscaling.MinScaleAnnotationKey) if err != nil { return err } desc.minScale = min max, err := annotationAsInt(revision, autoscaling.MaxScaleAnnotationKey) if err != nil { return err } desc.maxScale = max target, err := annotationAsInt(revision, autoscaling.TargetAnnotationKey) if err != nil { return err } desc.concurrencyTarget = target if revision.Spec.ContainerConcurrency != 0 { limit := int64(revision.Spec.ContainerConcurrency) desc.concurrencyLimit = &limit } return nil } func annotationAsInt(revision *v1alpha1.Revision, annotationKey string) (*int, error) { annos := revision.Annotations if val, ok := annos[annotationKey]; ok { valInt, err := strconv.Atoi(val) if err != nil { return nil, err } return &valInt, nil } return nil, nil } func addEnv(desc *revisionDesc, container *v1.Container) { envVars := make([]string, 0, len(container.Env)) for _, env := range container.Env { var value string if env.ValueFrom != nil { value = "[ref]" } else { value = env.Value } envVars = append(envVars, fmt.Sprintf("%s=%s", env.Name, value)) } desc.env = envVars } func addPort(desc *revisionDesc, container *v1.Container) { if len(container.Ports) > 0 { port := container.Ports[0].ContainerPort desc.port = &port } } func addImage(desc *revisionDesc, container *v1.Container) { desc.image = container.Image } func extractContainer(revision *v1alpha1.Revision) *v1.Container { if revision.Spec.Containers != nil && len(revision.Spec.Containers) > 0 { return &revision.Spec.Containers[0] } return revision.Spec.DeprecatedContainer } func extractRevisionFromTarget(client serving_kn_v1alpha1.KnServingClient, target v1alpha1.TrafficTarget) (*v1alpha1.Revision, error) { var revisionName = target.RevisionName if revisionName == "" { configurationName := target.ConfigurationName if configurationName == "" { return nil, fmt.Errorf("neither RevisionName nor ConfigurationName set") } configuration, err := client.GetConfiguration(configurationName) if err != nil { return nil, err } revisionName = configuration.Status.LatestCreatedRevisionName } return client.GetRevision(revisionName) } func extractURL(service *v1alpha1.Service) string { status := service.Status if status.URL != nil { return status.URL.String() } return status.DeprecatedDomain }