client/pkg/kn/commands/service/describe.go

628 lines
18 KiB
Go

// 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/serving/pkg/apis/serving/v1alpha1"
"github.com/spf13/cobra"
corev1 "k8s.io/api/core/v1"
v1 "k8s.io/api/core/v1"
"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
// 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 int64
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
commands.WriteConditions(dw, service.Status.Conditions, printDetails)
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) {
commands.WriteMetadata(dw, &service.ObjectMeta, printDetails)
dw.WriteAttribute("URL", extractURL(service))
if service.Status.Address != nil {
url := service.Status.Address.GetURL()
dw.WriteAttribute("Address", url.String())
}
}
// 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) {
revSection := dw.WriteAttribute("Revisions", "")
dw.Flush()
for _, revisionDesc := range revisions {
section := revSection.WriteColsLn(formatBullet(revisionDesc.percent, revisionDesc.ready), revisionHeader(revisionDesc))
if revisionDesc.ready == v1.ConditionFalse {
section.WriteAttribute("Error", revisionDesc.reason)
}
section.WriteAttribute("Image", getImageDesc(revisionDesc))
if printDetails {
if revisionDesc.port != nil {
section.WriteAttribute("Port", strconv.FormatInt(int64(*revisionDesc.port), 10))
}
writeSliceDesc(section, revisionDesc.env, l("Env"), "")
// Scale spec if given
if revisionDesc.maxScale != nil || revisionDesc.minScale != nil {
section.WriteAttribute("Scale", formatScale(revisionDesc.minScale, revisionDesc.maxScale))
}
// Concurrency specs if given
if revisionDesc.concurrencyLimit != nil || revisionDesc.concurrencyTarget != nil {
writeConcurrencyOptions(section, revisionDesc)
}
// Resources if given
writeResources(section, "Memory", revisionDesc.requestsMemory, revisionDesc.limitsMemory)
writeResources(section, "CPU", revisionDesc.requestsCPU, revisionDesc.limitsCPU)
}
}
}
func writeConcurrencyOptions(dw printers.PrefixWriter, desc *revisionDesc) {
section := dw.WriteAttribute("Concurrency", "")
if desc.concurrencyLimit != nil {
section.WriteAttribute("Limit", strconv.FormatInt(*desc.concurrencyLimit, 10))
}
if desc.concurrencyTarget != nil {
section.WriteAttribute("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) + "]" +
" " +
"(" + commands.Age(desc.creationTimestamp) + ")"
}
// 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
}
// 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, s []string, label string, labelPrefix string) {
if len(s) == 0 {
return
}
if printDetails {
l := labelPrefix + label
for _, value := range s {
dw.WriteColsLn(l, value)
l = labelPrefix
}
return
}
joined := strings.Join(s, ", ")
if len(joined) > commands.TruncateAt {
joined = joined[:commands.TruncateAt-4] + " ..."
}
dw.WriteAttribute(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.WriteAttribute(label, value)
}
// Format target percentage that it fits in the revision table
func formatBullet(percentage int64, 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)
}
// 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 != nil {
desc.concurrencyLimit = revision.Spec.ContainerConcurrency
}
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
}