blob: ab0146fdda0e82e23db7cb3532ee7e4bad1401dd [file] [log] [blame]
/*
* Copyright 2019-present Ciena Corporation
*
* 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 commands
import (
"encoding/json"
"errors"
"fmt"
"github.com/Shopify/sarama"
"github.com/fullstorydev/grpcurl"
"github.com/golang/protobuf/ptypes/any"
flags "github.com/jessevdk/go-flags"
"github.com/jhump/protoreflect/desc"
"github.com/jhump/protoreflect/dynamic"
"github.com/opencord/voltctl/pkg/filter"
"github.com/opencord/voltctl/pkg/format"
"github.com/opencord/voltctl/pkg/model"
"log"
"os"
"os/signal"
"strings"
"time"
)
/*
* The "message listen" command supports two types of output:
* 1) A summary output where a row is displayed for each message received. For the summary
* format, DEFAULT_MESSAGE_FORMAT contains the default list of columns that will be
* display and can be overridden at runtime.
* 2) A body output where the full grpcurl or json body is output for each message received.
*
* These two modes are switched by using the "-b" / "--body" flag.
*
* The summary mode has the potential to aggregate data together from multiple parts of the
* message. For example, it currently aggregates the InterAdapterHeader contents together with
* the InterContainerHeader contents.
*
* Similar to "event listen", the "message listen" command operates in a streaming mode, rather
* than collecting a list of results and then emitting them at program exit. This is done to
* facilitate options such as "-F" / "--follow" where the program is intended to operate
* continuously. This means that automatically calculating column widths is not practical, and
* a set of Fixed widths (MessageHeaderDefaultWidths) are predefined.
*
* As there are multiple kafka topics that can be listened to, specifying a topic is a
* mandatory positional argument for the `message listen` command. Common topics include:
* * openolt
* * brcm_openonu_adapter
* * rwcore
* * core-pair-1
*/
const (
DEFAULT_MESSAGE_FORMAT = "table{{.Id}}\t{{.Type}}\t{{.FromTopic}}\t{{.ToTopic}}\t{{.KeyTopic}}\t{{.InterAdapterType}}"
)
type MessageListenOpts struct {
Format string `long:"format" value-name:"FORMAT" default:"" description:"Format to use to output structured data"`
// nolint: staticcheck
OutputAs string `short:"o" long:"outputas" default:"table" choice:"table" choice:"json" choice:"yaml" description:"Type of output to generate"`
Filter string `short:"f" long:"filter" default:"" value-name:"FILTER" description:"Only display results that match filter"`
Follow bool `short:"F" long:"follow" description:"Continue to consume until CTRL-C is pressed"`
ShowBody bool `short:"b" long:"show-body" description:"Show body of messages rather than only a header summary"`
Count int `short:"c" long:"count" default:"-1" value-name:"LIMIT" description:"Limit the count of messages that will be printed"`
Now bool `short:"n" long:"now" description:"Stop printing messages when current time is reached"`
Timeout int `short:"t" long:"idle" default:"900" value-name:"SECONDS" description:"Timeout if no message received within specified seconds"`
Since string `short:"s" long:"since" default:"" value-name:"TIMESTAMP" description:"Do not show entries before timestamp"`
Args struct {
Topic string
} `positional-args:"yes" required:"yes"`
}
type MessageOpts struct {
MessageListen MessageListenOpts `command:"listen"`
}
var interAdapterOpts = MessageOpts{}
/* MessageHeader is a set of fields extracted
* from voltha.MessageHeader as well as useful other
* places such as InterAdapterHeader. These are fields that
* will be summarized in list mode and/or can be filtered
* on.
*/
type MessageHeader struct {
Id string `json:"id"`
Type string `json:"type"`
FromTopic string `json:"from_topic"`
ToTopic string `json:"to_topic"`
KeyTopic string `json:"key_topic"`
Timestamp time.Time `json:"timestamp"`
InterAdapterType string `json:"inter_adapter_type"` // interadapter header
ToDeviceId string `json:"to_device_id"` // interadapter header
ProxyDeviceId string `json:"proxy_device_id"` //interadapter header
}
/* Fixed widths because we output in a continuous streaming
* mode rather than a table-based dump at the end.
*/
type MessageHeaderWidths struct {
Id int
Type int
FromTopic int
ToTopic int
KeyTopic int
InterAdapterType int
ToDeviceId int
ProxyDeviceId int
Timestamp int
}
var DefaultMessageWidths MessageHeaderWidths = MessageHeaderWidths{
Id: 32,
Type: 10,
FromTopic: 16,
ToTopic: 16,
KeyTopic: 10,
Timestamp: 10,
InterAdapterType: 14,
ToDeviceId: 10,
ProxyDeviceId: 10,
}
func RegisterMessageCommands(parent *flags.Parser) {
if _, err := parent.AddCommand("message", "message commands", "Commands for observing messages between components", &interAdapterOpts); err != nil {
Error.Fatalf("Unable to register message commands with voltctl command parser: %s", err.Error())
}
}
// Find the any.Any field named by "fieldName" in the dynamic Message m.
// Create a new dynamic message using the bytes from the Any
// Return the new dynamic message and the type name
func DeserializeAny(icFile *desc.FileDescriptor, m *dynamic.Message, fieldName string) (*dynamic.Message, string, error) {
f, err := m.TryGetFieldByName(fieldName)
if err != nil {
return nil, "", err
}
a := f.(*any.Any)
embeddedType := strings.SplitN(a.TypeUrl, "/", 2)[1] // example type.googleapis.com/voltha.InterContainerRequestBody
embeddedBytes := a.Value
md := icFile.FindMessage(embeddedType)
embeddedM := dynamic.NewMessage(md)
err = embeddedM.Unmarshal(embeddedBytes)
if err != nil {
return nil, "", err
}
return embeddedM, embeddedType, nil
}
// Extract the header, as well as a few other items that might be of interest
func DecodeInterContainerHeader(icFile *desc.FileDescriptor, md *desc.MessageDescriptor, b []byte, ts time.Time, f grpcurl.Formatter) (*MessageHeader, error) {
m := dynamic.NewMessage(md)
if err := m.Unmarshal(b); err != nil {
return nil, err
}
headerIntf, err := m.TryGetFieldByName("header")
if err != nil {
return nil, err
}
header := headerIntf.(*dynamic.Message)
idIntf, err := header.TryGetFieldByName("id")
if err != nil {
return nil, err
}
id := idIntf.(string)
typeIntf, err := header.TryGetFieldByName("type")
if err != nil {
return nil, err
}
msgType := typeIntf.(int32)
fromTopicIntf, err := header.TryGetFieldByName("from_topic")
if err != nil {
return nil, err
}
fromTopic := fromTopicIntf.(string)
toTopicIntf, err := header.TryGetFieldByName("to_topic")
if err != nil {
return nil, err
}
toTopic := toTopicIntf.(string)
keyTopicIntf, err := header.TryGetFieldByName("key_topic")
if err != nil {
return nil, err
}
keyTopic := keyTopicIntf.(string)
timestampIntf, err := header.TryGetFieldByName("timestamp")
if err != nil {
return nil, err
}
timestamp, err := DecodeTimestamp(timestampIntf)
if err != nil {
return nil, err
}
// Pull some additional data out of the InterAdapterHeader, if it
// is embedded inside the InterContainerMessage
var iaMessageTypeStr string
var toDeviceId string
var proxyDeviceId string
body, bodyKind, err := DeserializeAny(icFile, m, "body")
if err != nil {
return nil, err
}
switch bodyKind {
case "voltha.InterContainerRequestBody":
argListIntf, err := body.TryGetFieldByName("args")
if err != nil {
return nil, err
}
argList := argListIntf.([]interface{})
for _, argIntf := range argList {
arg := argIntf.(*dynamic.Message)
keyIntf, err := arg.TryGetFieldByName("key")
if err != nil {
return nil, err
}
key := keyIntf.(string)
if key == "msg" {
argBody, argBodyKind, err := DeserializeAny(icFile, arg, "value")
if err != nil {
return nil, err
}
switch argBodyKind {
case "voltha.InterAdapterMessage":
iaHeaderIntf, err := argBody.TryGetFieldByName("header")
if err != nil {
return nil, err
}
iaHeader := iaHeaderIntf.(*dynamic.Message)
iaMessageTypeIntf, err := iaHeader.TryGetFieldByName("type")
if err != nil {
return nil, err
}
iaMessageType := iaMessageTypeIntf.(int32)
iaMessageTypeStr = model.GetEnumString(iaHeader, "type", iaMessageType)
toDeviceIdIntf, err := iaHeader.TryGetFieldByName("to_device_id")
if err != nil {
return nil, err
}
toDeviceId = toDeviceIdIntf.(string)
proxyDeviceIdIntf, err := iaHeader.TryGetFieldByName("proxy_device_id")
if err != nil {
return nil, err
}
proxyDeviceId = proxyDeviceIdIntf.(string)
}
}
}
}
icHeader := MessageHeader{Id: id,
Type: model.GetEnumString(header, "type", msgType),
FromTopic: fromTopic,
ToTopic: toTopic,
KeyTopic: keyTopic,
Timestamp: timestamp,
InterAdapterType: iaMessageTypeStr,
ProxyDeviceId: proxyDeviceId,
ToDeviceId: toDeviceId,
}
return &icHeader, nil
}
// Print the full message, either in JSON or in GRPCURL-human-readable format,
// depending on which grpcurl formatter is passed in.
func PrintInterContainerMessage(f grpcurl.Formatter, md *desc.MessageDescriptor, b []byte) error {
m := dynamic.NewMessage(md)
err := m.Unmarshal(b)
if err != nil {
return err
}
s, err := f(m)
if err != nil {
return err
}
fmt.Println(s)
return nil
}
// Print just the enriched InterContainerHeader. This is either in JSON format, or in
// table format.
func PrintInterContainerHeader(outputAs string, outputFormat string, hdr *MessageHeader) error {
if outputAs == "json" {
asJson, err := json.Marshal(hdr)
if err != nil {
return fmt.Errorf("Error marshalling JSON: %v", err)
} else {
fmt.Printf("%s\n", asJson)
}
} else {
f := format.Format(outputFormat)
output, err := f.ExecuteFixedWidth(DefaultMessageWidths, false, *hdr)
if err != nil {
return err
}
fmt.Printf("%s\n", output)
}
return nil
}
// Get the FileDescriptor that has the InterContainer protos
func GetInterContainerDescriptorFile() (*desc.FileDescriptor, error) {
descriptor, err := GetDescriptorSource()
if err != nil {
return nil, err
}
// get the symbol for voltha.InterContainerMessage
iaSymbol, err := descriptor.FindSymbol("voltha.InterContainerMessage")
if err != nil {
return nil, err
}
icFile := iaSymbol.GetFile()
return icFile, nil
}
// Start output, print any column headers or other start characters
func (options *MessageListenOpts) StartOutput(outputFormat string) error {
if options.OutputAs == "json" {
fmt.Println("[")
} else if (options.OutputAs == "table") && !options.ShowBody {
f := format.Format(outputFormat)
output, err := f.ExecuteFixedWidth(DefaultMessageWidths, true, nil)
if err != nil {
return err
}
fmt.Println(output)
}
return nil
}
// Finish output, print any column footers or other end characters
func (options *MessageListenOpts) FinishOutput() {
if options.OutputAs == "json" {
fmt.Println("]")
}
}
func (options *MessageListenOpts) Execute(args []string) error {
ProcessGlobalOptions()
if GlobalConfig.Kafka == "" {
return errors.New("Kafka address is not specified")
}
icFile, err := GetInterContainerDescriptorFile()
if err != nil {
return err
}
icMessage := icFile.FindMessage("voltha.InterContainerMessage")
config := sarama.NewConfig()
config.ClientID = "go-kafka-consumer"
config.Consumer.Return.Errors = true
config.Version = sarama.V1_0_0_0
brokers := []string{GlobalConfig.Kafka}
client, err := sarama.NewClient(brokers, config)
if err != nil {
return err
}
defer func() {
if err := client.Close(); err != nil {
panic(err)
}
}()
consumer, consumerErrors, highwaterMarks, err := startInterContainerConsumer([]string{options.Args.Topic}, client)
if err != nil {
return err
}
highwater := highwaterMarks[options.Args.Topic]
signals := make(chan os.Signal, 1)
signal.Notify(signals, os.Interrupt)
// Count how many message processed
consumeCount := 0
// Count how many messages were printed
count := 0
var grpcurlFormatter grpcurl.Formatter
// need a descriptor source, any method will do
descriptor, _, err := GetMethod("device-list")
if err != nil {
return err
}
jsonFormatter := grpcurl.NewJSONFormatter(false, grpcurl.AnyResolverFromDescriptorSource(descriptor))
if options.ShowBody {
if options.OutputAs == "json" {
grpcurlFormatter = jsonFormatter
} else {
grpcurlFormatter = grpcurl.NewTextFormatter(false)
}
}
var headerFilter *filter.Filter
if options.Filter != "" {
headerFilterVal, err := filter.Parse(options.Filter)
if err != nil {
return fmt.Errorf("Failed to parse filter: %v", err)
}
headerFilter = &headerFilterVal
}
outputFormat := CharReplacer.Replace(options.Format)
if outputFormat == "" {
outputFormat = GetCommandOptionWithDefault("intercontainer-listen", "format", DEFAULT_MESSAGE_FORMAT)
}
err = options.StartOutput(outputFormat)
if err != nil {
return err
}
var since *time.Time
if options.Since != "" {
since, err = ParseSince(options.Since)
if err != nil {
return err
}
}
// Get signnal for finish
doneCh := make(chan struct{})
go func() {
tStart := time.Now()
Loop:
for {
// Initialize the idle timeout timer
timeoutTimer := time.NewTimer(time.Duration(options.Timeout) * time.Second)
select {
case msg := <-consumer:
consumeCount++
hdr, err := DecodeInterContainerHeader(icFile, icMessage, msg.Value, msg.Timestamp, jsonFormatter)
if err != nil {
log.Printf("Error decoding header %v\n", err)
continue
}
if headerFilter != nil && !headerFilter.Evaluate(*hdr) {
// skip printing message
} else if since != nil && hdr.Timestamp.Before(*since) {
// it's too old
} else {
// comma separated between this message and predecessor
if count > 0 {
if options.OutputAs == "json" {
fmt.Println(",")
}
}
// Print it
if options.ShowBody {
if err := PrintInterContainerMessage(grpcurlFormatter, icMessage, msg.Value); err != nil {
log.Printf("%v\n", err)
}
} else {
if err := PrintInterContainerHeader(options.OutputAs, outputFormat, hdr); err != nil {
log.Printf("%v\n", err)
}
}
// Check to see if we've hit the "count" threshold the user specified
count++
if (options.Count > 0) && (count >= options.Count) {
log.Println("Count reached")
doneCh <- struct{}{}
break Loop
}
// Check to see if we've hit the "now" threshold the user specified
if (options.Now) && (!hdr.Timestamp.Before(tStart)) {
log.Println("Now timestamp reached")
doneCh <- struct{}{}
break Loop
}
}
// If we're not in follow mode, see if we hit the highwater mark
if !options.Follow && !options.Now && (msg.Offset >= highwater) {
log.Println("High water reached")
doneCh <- struct{}{}
break Loop
}
// Reset the timeout timer
if !timeoutTimer.Stop() {
<-timeoutTimer.C
}
case consumerError := <-consumerErrors:
log.Printf("Received consumerError topic=%v, partition=%v, err=%v\n", string(consumerError.Topic), string(consumerError.Partition), consumerError.Err)
doneCh <- struct{}{}
case <-signals:
doneCh <- struct{}{}
case <-timeoutTimer.C:
log.Printf("Idle timeout\n")
doneCh <- struct{}{}
}
}
}()
<-doneCh
options.FinishOutput()
log.Printf("Consumed %d messages. Printed %d messages", consumeCount, count)
return nil
}
// Consume message from Sarama and send them out on a channel.
// Supports multiple topics.
// Taken from Sarama example consumer.
func startInterContainerConsumer(topics []string, client sarama.Client) (chan *sarama.ConsumerMessage, chan *sarama.ConsumerError, map[string]int64, error) {
master, err := sarama.NewConsumerFromClient(client)
if err != nil {
return nil, nil, nil, err
}
consumers := make(chan *sarama.ConsumerMessage)
errors := make(chan *sarama.ConsumerError)
highwater := make(map[string]int64)
for _, topic := range topics {
if strings.Contains(topic, "__consumer_offsets") {
continue
}
partitions, _ := master.Partitions(topic)
// TODO: Add support for multiple partitions
if len(partitions) > 1 {
log.Printf("WARNING: %d partitions on topic %s but we only listen to the first one\n", len(partitions), topic)
}
hw, err := client.GetOffset("openolt", partitions[0], sarama.OffsetNewest)
if err != nil {
return nil, nil, nil, fmt.Errorf("Error in consume() getting highwater: Topic %v Partitions: %v", topic, partitions)
}
highwater[topic] = hw
consumer, err := master.ConsumePartition(topic, partitions[0], sarama.OffsetOldest)
if nil != err {
return nil, nil, nil, fmt.Errorf("Error in consume(): Topic %v Partitions: %v", topic, partitions)
}
log.Println(" Start consuming topic ", topic)
go func(topic string, consumer sarama.PartitionConsumer) {
for {
select {
case consumerError := <-consumer.Errors():
errors <- consumerError
case msg := <-consumer.Messages():
consumers <- msg
}
}
}(topic, consumer)
}
return consumers, errors, highwater, nil
}