mirror of https://github.com/knative/client.git
404 lines
13 KiB
Go
404 lines
13 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 (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"k8s.io/apimachinery/pkg/util/sets"
|
|
"k8s.io/cli-runtime/pkg/genericclioptions"
|
|
"knative.dev/serving/pkg/apis/serving"
|
|
|
|
"knative.dev/client/pkg/kn/commands/revision"
|
|
"knative.dev/client/pkg/printers"
|
|
clientservingv1 "knative.dev/client/pkg/serving/v1"
|
|
|
|
"github.com/spf13/cobra"
|
|
corev1 "k8s.io/api/core/v1"
|
|
"knative.dev/pkg/apis"
|
|
servingv1 "knative.dev/serving/pkg/apis/serving/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
|
|
|
|
// 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 {
|
|
revision *servingv1.Revision
|
|
|
|
// traffic stuff
|
|
percent int64
|
|
tag string
|
|
latestTraffic *bool
|
|
|
|
configurationGeneration int
|
|
|
|
// status info
|
|
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]
|
|
|
|
var describe_example = `
|
|
# Describe service 'svc' in human friendly format
|
|
kn service describe svc
|
|
|
|
# Describe service 'svc' in YAML format
|
|
kn service describe svc -o yaml
|
|
|
|
# Print only service URL
|
|
kn service describe svc -o url
|
|
|
|
# Describe the services in offline mode instead of kubernetes cluster (Beta)
|
|
kn service describe test -n test-ns --target=/user/knfiles
|
|
kn service describe test --target=/user/knfiles/test.yaml
|
|
kn service describe test --target=/user/knfiles/test.json`
|
|
|
|
// 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 of a service",
|
|
Example: describe_example,
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
if len(args) != 1 {
|
|
return errors.New("'service describe' requires the service name given as single argument")
|
|
}
|
|
serviceName := args[0]
|
|
|
|
namespace, err := p.GetNamespace(cmd)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
client, err := newServingClient(p, namespace, cmd.Flag("target").Value.String())
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
service, err := client.GetService(cmd.Context(), serviceName)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Print out machine readable output if requested
|
|
if machineReadablePrintFlags.OutputFlagSpecified() {
|
|
out := cmd.OutOrStdout()
|
|
if strings.ToLower(*machineReadablePrintFlags.OutputFormat) == "url" {
|
|
fmt.Fprintf(out, "%s\n", extractURL(service))
|
|
return nil
|
|
}
|
|
printer, err := machineReadablePrintFlags.ToPrinter()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return printer.PrintObj(service, out)
|
|
}
|
|
|
|
printDetails, err = cmd.Flags().GetBool("verbose")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
revisionDescs, err := getRevisionDescriptions(cmd.Context(), client, service, printDetails)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
return describe(cmd.OutOrStdout(), service, revisionDescs, printDetails)
|
|
},
|
|
}
|
|
flags := command.Flags()
|
|
commands.AddNamespaceFlags(flags, false)
|
|
commands.AddGitOpsFlags(flags)
|
|
flags.BoolP("verbose", "v", false, "More output.")
|
|
machineReadablePrintFlags.AddFlags(command)
|
|
command.Flag("output").Usage = fmt.Sprintf("Output format. One of: %s.", strings.Join(append(machineReadablePrintFlags.AllowedFormats(), "url"), "|"))
|
|
return command
|
|
}
|
|
|
|
// Main action describing the service
|
|
func describe(w io.Writer, service *servingv1.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 *servingv1.Service) {
|
|
commands.WriteMetadata(dw, &service.ObjectMeta, printDetails)
|
|
dw.WriteAttribute("URL", extractURL(service))
|
|
if printDetails {
|
|
if service.Status.Address != nil {
|
|
url := service.Status.Address.URL
|
|
dw.WriteAttribute("Cluster", url.String())
|
|
}
|
|
}
|
|
if service.Spec.Template.Spec.ServiceAccountName != "" {
|
|
dw.WriteAttribute("Service Account", service.Spec.Template.Spec.ServiceAccountName)
|
|
}
|
|
if service.Spec.Template.Spec.ImagePullSecrets != nil {
|
|
dw.WriteAttribute("Image Pull Secret", service.Spec.Template.Spec.ImagePullSecrets[0].Name)
|
|
}
|
|
}
|
|
|
|
// Write out revisions associated with this service. By default only active
|
|
// target revisions are printed, but with --verbose 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 {
|
|
ready := apis.Condition{
|
|
Type: apis.ConditionReady,
|
|
Status: corev1.ConditionUnknown,
|
|
}
|
|
for _, cond := range revisionDesc.revision.Status.Conditions {
|
|
if cond.Type == apis.ConditionReady {
|
|
ready = cond
|
|
break
|
|
}
|
|
}
|
|
section := revSection.WriteColsLn(formatBullet(revisionDesc.percent, ready.Status), revisionHeader(revisionDesc))
|
|
if ready.Status == corev1.ConditionFalse {
|
|
section.WriteAttribute("Error", ready.Reason)
|
|
}
|
|
revision.WriteImage(section, revisionDesc.revision)
|
|
revision.WriteReplicas(section, revisionDesc.revision)
|
|
if printDetails {
|
|
revision.WritePort(section, revisionDesc.revision)
|
|
revision.WriteEnv(section, revisionDesc.revision, printDetails)
|
|
revision.WriteEnvFrom(section, revisionDesc.revision, printDetails)
|
|
revision.WriteScale(section, revisionDesc.revision)
|
|
revision.WriteConcurrencyOptions(section, revisionDesc.revision)
|
|
revision.WriteResources(section, revisionDesc.revision)
|
|
}
|
|
}
|
|
}
|
|
|
|
// ======================================================================================
|
|
// Helper functions
|
|
|
|
// Format the revision name along with its generation. Use colors if enabled.
|
|
func revisionHeader(desc *revisionDesc) string {
|
|
header := desc.revision.Name
|
|
if desc.latestTraffic != nil && *desc.latestTraffic {
|
|
header = fmt.Sprintf("@latest (%s)", desc.revision.Name)
|
|
} else if desc.latestReady {
|
|
header = desc.revision.Name + " (current @latest)"
|
|
} else if desc.latestCreated {
|
|
header = desc.revision.Name + " (latest created)"
|
|
}
|
|
if desc.tag != "" {
|
|
header = fmt.Sprintf("%s #%s", header, desc.tag)
|
|
}
|
|
return header + " " +
|
|
"[" + strconv.Itoa(desc.configurationGeneration) + "]" +
|
|
" " +
|
|
"(" + commands.Age(desc.revision.CreationTimestamp.Time) + ")"
|
|
}
|
|
|
|
// Format target percentage that it fits in the revision table
|
|
func formatBullet(percentage int64, status corev1.ConditionStatus) string {
|
|
symbol := "+"
|
|
switch status {
|
|
case corev1.ConditionTrue:
|
|
if percentage > 0 {
|
|
symbol = "%"
|
|
}
|
|
case corev1.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(ctx context.Context, client clientservingv1.KnServingClient, service *servingv1.Service, withDetails bool) ([]*revisionDesc, error) {
|
|
revisionsSeen := sets.NewString()
|
|
revisionDescs := []*revisionDesc{}
|
|
|
|
trafficTargets := service.Status.Traffic
|
|
var err error
|
|
for i := range trafficTargets {
|
|
target := trafficTargets[i]
|
|
revision, err := extractRevisionFromTarget(ctx, client, target)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("cannot extract revision from service %s: %w", 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(ctx, client, service, revisionsSeen, revisionDescs); err != nil {
|
|
return nil, err
|
|
}
|
|
if withDetails {
|
|
if revisionDescs, err = completeWithUntargetedRevisions(ctx, 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(ctx context.Context, client clientservingv1.KnServingClient, service *servingv1.Service, revisionsSeen sets.String, descs []*revisionDesc) ([]*revisionDesc, error) {
|
|
for _, revisionName := range []string{service.Status.LatestCreatedRevisionName, service.Status.LatestReadyRevisionName} {
|
|
if revisionName == "" || revisionsSeen.Has(revisionName) {
|
|
continue
|
|
}
|
|
revisionsSeen.Insert(revisionName)
|
|
rev, err := client.GetRevision(ctx, 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(ctx context.Context, client clientservingv1.KnServingClient, service *servingv1.Service, revisionsSeen sets.String, descs []*revisionDesc) ([]*revisionDesc, error) {
|
|
revisions, err := client.ListRevisions(ctx, clientservingv1.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 servingv1.Revision, target *servingv1.TrafficTarget, service *servingv1.Service) (*revisionDesc, error) {
|
|
generation, err := strconv.ParseInt(revision.Labels[serving.ConfigurationGenerationLabelKey], 0, 0)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("cannot extract configuration generation for revision %s: %w", revision.Name, err)
|
|
}
|
|
revisionDesc := revisionDesc{
|
|
revision: &revision,
|
|
configurationGeneration: int(generation),
|
|
latestCreated: revision.Name == service.Status.LatestCreatedRevisionName,
|
|
latestReady: revision.Name == service.Status.LatestReadyRevisionName,
|
|
}
|
|
|
|
addTargetInfo(&revisionDesc, target)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &revisionDesc, nil
|
|
}
|
|
|
|
func addTargetInfo(desc *revisionDesc, target *servingv1.TrafficTarget) {
|
|
if target != nil {
|
|
if target.Percent != nil {
|
|
desc.percent = *target.Percent
|
|
}
|
|
desc.latestTraffic = target.LatestRevision
|
|
desc.tag = target.Tag
|
|
}
|
|
}
|
|
|
|
func extractRevisionFromTarget(ctx context.Context, client clientservingv1.KnServingClient, target servingv1.TrafficTarget) (*servingv1.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(ctx, configurationName)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
revisionName = configuration.Status.LatestCreatedRevisionName
|
|
}
|
|
return client.GetRevision(ctx, revisionName)
|
|
}
|
|
|
|
func extractURL(service *servingv1.Service) string {
|
|
return service.Status.URL.String()
|
|
}
|