From 5789d1c14dfc5e8059d297183532291355e148c8 Mon Sep 17 00:00:00 2001 From: Connor Settle Date: Thu, 14 Nov 2024 17:19:50 +0000 Subject: [PATCH 1/6] Provide custom text formatter logger without colours --- jbpf | 2 +- pkg/common/options.go | 46 ++++- pkg/common/uncolored_text_formatter.go | 266 +++++++++++++++++++++++++ 3 files changed, 308 insertions(+), 6 deletions(-) create mode 100644 pkg/common/uncolored_text_formatter.go diff --git a/jbpf b/jbpf index 5709617..9a25032 160000 --- a/jbpf +++ b/jbpf @@ -1 +1 @@ -Subproject commit 5709617993d3fd719f62f12893c6cfa50556509f +Subproject commit 9a25032f83b3d4038211d3042f9509a13438528a diff --git a/pkg/common/options.go b/pkg/common/options.go index e4b3c3d..2968e41 100644 --- a/pkg/common/options.go +++ b/pkg/common/options.go @@ -4,6 +4,10 @@ package common import ( "errors" + "fmt" + "io" + "os" + "strings" "github.com/sirupsen/logrus" "github.com/spf13/pflag" @@ -19,6 +23,8 @@ func NewGeneralOptions(flags *pflag.FlagSet) *GeneralOptions { // NewGeneralOptionsFromLogger creates a new GeneralOptions from a logger func NewGeneralOptionsFromLogger(logger *logrus.Logger) *GeneralOptions { opts := &GeneralOptions{ + file: "", + formatter: "TextFormatter", Logger: logger, logLevel: logger.Level.String(), reportCaller: logger.ReportCaller, @@ -28,6 +34,8 @@ func NewGeneralOptionsFromLogger(logger *logrus.Logger) *GeneralOptions { // GeneralOptions contains the general options for the jbpf cli type GeneralOptions struct { + file string + formatter string logLevel string reportCaller bool @@ -36,6 +44,8 @@ type GeneralOptions struct { func (opts *GeneralOptions) addToFlags(flags *pflag.FlagSet) { flags.BoolVar(&opts.reportCaller, "log-report-caller", false, "show report caller in logs") + flags.StringVar(&opts.file, "log-file", "", "if set, will write logs to file as well as terminal") + flags.StringVar(&opts.formatter, "log-formatter", "TextFormatter", "logger formatter, set to UncoloredTextFormatter, JSONFormatter or TextFormatter") flags.StringVar(&opts.logLevel, "log-level", "info", "log level, set to: panic, fatal, error, warn, info, debug or trace") } @@ -48,13 +58,39 @@ func (opts *GeneralOptions) Parse() error { // GetLogger returns a logger based on the options func (opts *GeneralOptions) getLogger() (*logrus.Logger, error) { - logger := logrus.New() logLev, err := logrus.ParseLevel(opts.logLevel) if err != nil { - return logger, err + return nil, err } - logger.SetReportCaller(opts.reportCaller) - logger.SetLevel(logLev) - return logger, nil + var formatter logrus.Formatter + switch strings.ToLower(opts.formatter) { + case "uncoloredtextformatter": + formatter = new(UncoloredTextFormatter) + case "jsonformatter": + formatter = new(logrus.JSONFormatter) + case "textformatter": + formatter = new(logrus.TextFormatter) + default: + return nil, fmt.Errorf("invalid log formatter: %v", opts.formatter) + } + + var out io.Writer = os.Stderr + + if opts.file != "" { + file, err := os.OpenFile(opts.file, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0666) + if err != nil { + return nil, err + } + out = io.MultiWriter(os.Stderr, file) + } + + return &logrus.Logger{ + Out: out, + Formatter: formatter, + Hooks: make(logrus.LevelHooks), + Level: logLev, + ExitFunc: os.Exit, + ReportCaller: opts.reportCaller, + }, nil } diff --git a/pkg/common/uncolored_text_formatter.go b/pkg/common/uncolored_text_formatter.go new file mode 100644 index 0000000..9dbf8b9 --- /dev/null +++ b/pkg/common/uncolored_text_formatter.go @@ -0,0 +1,266 @@ +package common + +import ( + "bytes" + "fmt" + "runtime" + "sort" + "strconv" + "strings" + "time" + "unicode/utf8" + + "github.com/sirupsen/logrus" +) + +const ( + defaultTimestampFormat = time.RFC3339 +) + +var ( + baseTimestamp time.Time + levelTextMaxLength int +) + +func init() { + baseTimestamp = time.Now() + + for _, level := range logrus.AllLevels { + levelTextLength := utf8.RuneCount([]byte(level.String())) + if levelTextLength > levelTextMaxLength { + levelTextMaxLength = levelTextLength + } + } +} + +type fieldKey string + +// FieldMap allows customization of the key names for default fields. +type FieldMap map[fieldKey]string + +func (f FieldMap) resolve(key fieldKey) string { + if k, ok := f[key]; ok { + return k + } + + return string(key) +} + +// UncoloredTextFormatter formats logs into text +type UncoloredTextFormatter struct { + // Force quoting of all values + ForceQuote bool + + // DisableQuote disables quoting for all values. + // DisableQuote will have a lower priority than ForceQuote. + // If both of them are set to true, quote will be forced on all values. + DisableQuote bool + + // Override coloring based on CLICOLOR and CLICOLOR_FORCE. - https://bixense.com/clicolors/ + EnvironmentOverrideColors bool + + // Disable timestamp logging. useful when output is redirected to logging + // system that already adds timestamps. + DisableTimestamp bool + + // Enable logging the full timestamp when a TTY is attached instead of just + // the time passed since beginning of execution. + FullTimestamp bool + + // TimestampFormat to use for display when a full timestamp is printed. + // The format to use is the same than for time.Format or time.Parse from the standard + // library. + // The standard Library already provides a set of predefined format. + TimestampFormat string + + // The fields are sorted by default for a consistent output. For applications + // that log extremely frequently and don't use the JSON formatter this may not + // be desired. + DisableSorting bool + + // The keys sorting function, when uninitialized it uses sort.Strings. + SortingFunc func([]string) + + // Disables the truncation of the level text to 4 characters. + DisableLevelTruncation bool + + // PadLevelText Adds padding the level text so that all the levels output at the same length + // PadLevelText is a superset of the DisableLevelTruncation option + PadLevelText bool + + // QuoteEmptyFields will wrap empty fields in quotes if true + QuoteEmptyFields bool + + // FieldMap allows users to customize the names of keys for default fields. + // As an example: + // formatter := &UncoloredTextFormatter{ + // FieldMap: FieldMap{ + // FieldKeyTime: "@timestamp", + // FieldKeyLevel: "@level", + // FieldKeyMsg: "@message"}} + FieldMap FieldMap + + // CallerPrettyfier can be set by the user to modify the content + // of the function and file keys in the data when ReportCaller is + // activated. If any of the returned value is the empty string the + // corresponding key will be removed from fields. + CallerPrettyfier func(*runtime.Frame) (function string, file string) +} + +// Format renders a single log entry +func (f *UncoloredTextFormatter) Format(entry *logrus.Entry) ([]byte, error) { + data := make(logrus.Fields) + for k, v := range entry.Data { + data[k] = v + } + prefixFieldClashes(data, f.FieldMap, entry.HasCaller()) + keys := make([]string, 0, len(data)) + for k := range data { + keys = append(keys, k) + } + + if !f.DisableSorting { + if f.SortingFunc == nil { + sort.Strings(keys) + } + } + + var b *bytes.Buffer + if entry.Buffer != nil { + b = entry.Buffer + } else { + b = &bytes.Buffer{} + } + + timestampFormat := f.TimestampFormat + if timestampFormat == "" { + timestampFormat = defaultTimestampFormat + } + f.printToBuf(b, entry, keys, data, timestampFormat) + b.WriteByte('\n') + return b.Bytes(), nil +} + +func (f *UncoloredTextFormatter) printToBuf(b *bytes.Buffer, entry *logrus.Entry, keys []string, data logrus.Fields, timestampFormat string) { + levelText := strings.ToUpper(entry.Level.String()) + if !f.DisableLevelTruncation && !f.PadLevelText { + levelText = levelText[0:4] + } + if f.PadLevelText { + // Generates the format string used in the next line, for example "%-6s" or "%-7s". + // Based on the max level text length. + formatString := "%-" + strconv.Itoa(levelTextMaxLength) + "s" + // Formats the level text by appending spaces up to the max length, for example: + // - "INFO " + // - "WARNING" + levelText = fmt.Sprintf(formatString, levelText) + } + + // Remove a single newline if it already exists in the message to keep + // the behavior of logrus text_formatter the same as the stdlib log package + entry.Message = strings.TrimSuffix(entry.Message, "\n") + + caller := "" + if entry.HasCaller() { + funcVal := fmt.Sprintf("%s()", entry.Caller.Function) + fileVal := fmt.Sprintf("%s:%d", entry.Caller.File, entry.Caller.Line) + + if f.CallerPrettyfier != nil { + funcVal, fileVal = f.CallerPrettyfier(entry.Caller) + } + + if fileVal == "" { + caller = funcVal + } else if funcVal == "" { + caller = fileVal + } else { + caller = fileVal + " " + funcVal + } + } + + switch { + case f.DisableTimestamp: + fmt.Fprintf(b, "%s%s %-44s ", levelText, caller, entry.Message) + case !f.FullTimestamp: + fmt.Fprintf(b, "%s[%04d]%s %-44s ", levelText, int(entry.Time.Sub(baseTimestamp)/time.Second), caller, entry.Message) + default: + fmt.Fprintf(b, "%s[%s]%s %-44s ", levelText, entry.Time.Format(timestampFormat), caller, entry.Message) + } + for _, k := range keys { + v := data[k] + fmt.Fprintf(b, " %s=", k) + f.appendValue(b, v) + } +} + +func (f *UncoloredTextFormatter) needsQuoting(text string) bool { + if f.ForceQuote { + return true + } + if f.QuoteEmptyFields && len(text) == 0 { + return true + } + if f.DisableQuote { + return false + } + for _, ch := range text { + if !((ch >= 'a' && ch <= 'z') || + (ch >= 'A' && ch <= 'Z') || + (ch >= '0' && ch <= '9') || + ch == '-' || ch == '.' || ch == '_' || ch == '/' || ch == '@' || ch == '^' || ch == '+') { + return true + } + } + return false +} + +func (f *UncoloredTextFormatter) appendValue(b *bytes.Buffer, value interface{}) { + stringVal, ok := value.(string) + if !ok { + stringVal = fmt.Sprint(value) + } + + if !f.needsQuoting(stringVal) { + b.WriteString(stringVal) + } else { + b.WriteString(fmt.Sprintf("%q", stringVal)) + } +} + +func prefixFieldClashes(data logrus.Fields, fieldMap FieldMap, reportCaller bool) { + timeKey := fieldMap.resolve(logrus.FieldKeyTime) + if t, ok := data[timeKey]; ok { + data["fields."+timeKey] = t + delete(data, timeKey) + } + + msgKey := fieldMap.resolve(logrus.FieldKeyMsg) + if m, ok := data[msgKey]; ok { + data["fields."+msgKey] = m + delete(data, msgKey) + } + + levelKey := fieldMap.resolve(logrus.FieldKeyLevel) + if l, ok := data[levelKey]; ok { + data["fields."+levelKey] = l + delete(data, levelKey) + } + + logrusErrKey := fieldMap.resolve(logrus.FieldKeyLogrusError) + if l, ok := data[logrusErrKey]; ok { + data["fields."+logrusErrKey] = l + delete(data, logrusErrKey) + } + + // If reportCaller is not set, 'func' will not conflict. + if reportCaller { + funcKey := fieldMap.resolve(logrus.FieldKeyFunc) + if l, ok := data[funcKey]; ok { + data["fields."+funcKey] = l + } + fileKey := fieldMap.resolve(logrus.FieldKeyFile) + if l, ok := data[fileKey]; ok { + data["fields."+fileKey] = l + } + } +} From 97c44a8b4ad4f1ea3793aee640562d41ed2ab602 Mon Sep 17 00:00:00 2001 From: Connor Settle Date: Thu, 14 Nov 2024 19:45:31 +0000 Subject: [PATCH 2/6] Split input forwarder from the decoder --- README.md | 6 +- docs/design.md | 93 +++++++++ examples/first_example_ipc/README.md | 2 +- examples/first_example_ipc/load.sh | 2 +- .../{send_control.sh => send_input_msg.sh} | 3 +- examples/first_example_standalone/README.md | 2 +- examples/first_example_standalone/load.sh | 4 +- examples/first_example_standalone/run_app.sh | 2 +- .../first_example_standalone/run_decoder.sh | 2 +- .../{send_control.sh => send_input_msg.sh} | 3 +- examples/first_example_standalone/unload.sh | 2 +- pkg/cmd/decoder/control/control.go | 111 ---------- pkg/cmd/decoder/decoder.go | 2 - pkg/cmd/decoder/load/load.go | 41 ++-- pkg/cmd/decoder/run/run.go | 21 +- pkg/cmd/decoder/unload/unload.go | 23 +-- pkg/cmd/decoder/unload/unload_config.go | 78 ------- pkg/cmd/input/forward/forward.go | 190 ++++++++++++++++++ pkg/cmd/input/input.go | 23 +++ .../codeletset_config.go} | 50 +++-- pkg/jbpf/options.go | 5 - pkg/main.go | 4 +- pkg/schema/client.go | 4 +- pkg/schema/client_options.go | 24 --- pkg/schema/options.go | 15 +- pkg/schema/serve.go | 24 +-- pkg/schema/server.go | 65 +----- pkg/schema/server_options.go | 40 ---- 28 files changed, 404 insertions(+), 437 deletions(-) create mode 100644 docs/design.md rename examples/first_example_ipc/{send_control.sh => send_input_msg.sh} (53%) rename examples/first_example_standalone/{send_control.sh => send_input_msg.sh} (53%) delete mode 100644 pkg/cmd/decoder/control/control.go delete mode 100644 pkg/cmd/decoder/unload/unload_config.go create mode 100644 pkg/cmd/input/forward/forward.go create mode 100644 pkg/cmd/input/input.go rename pkg/{cmd/decoder/load/load_config.go => common/codeletset_config.go} (63%) delete mode 100644 pkg/schema/client_options.go delete mode 100644 pkg/schema/server_options.go diff --git a/README.md b/README.md index 80e45e9..7adb516 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,7 @@ # jbpf-protobuf +**NOTE: This project uses an experimental feature from jbpf. It is not meant to be used in production environments.** + This repository is a extension for [jbpf](https://github.com/microsoft/jbpf/) demonstrating how to utilize protobuf serialization as part of jbpf. Prerequisites: @@ -7,7 +9,7 @@ Prerequisites: * Go v1.23.2+ * Make * Pip -* Python +* Python3 The project utilizes [Nanopb](https://github.com/nanopb/nanopb) to generate C structures for given protobuf specs that use contiguous memory. It also generates serializer libraries that can be provided to jbpf, to encode output and decode input data to seamlessly integrate external data processing systems. @@ -34,7 +36,7 @@ docker build -t jbpf_protobuf_builder:latest -f deploy/Dockerfile . ## Running the examples -In order to run any of the samples, you'll need to build Janus. +In order to run any of the samples, you'll need to build jbpf. ```sh mkdir -p jbpf/build diff --git a/docs/design.md b/docs/design.md new file mode 100644 index 0000000..772a327 --- /dev/null +++ b/docs/design.md @@ -0,0 +1,93 @@ +# High level Architecture + +`jbpf_protobuf_cli` provides tooling to generate serialization assets for `jbpf` using protobuf. + +For complete details of each subcommand, see `./jbpf_protobuf_cli {SUBCOMMAND} --help`. + + +## Serde + +The `serde` subcommand generates assets from protobuf specs which can integrate with `jbpf`'s [serde functionality](../jbpf/docs/serde.md). + +Developers must write `.proto` file(s) defining the models that are to be serialized. Additionally they must provide [generator options](https://jpa.kapsi.fi/nanopb/docs/reference.html#generator-options) as defined by nanopb to ensure generated structs can be defined in C as contiguous memory structs. + + +### Simple example + +This example goes through generating serde assets for a simple protobuf schema. + +``` +// schema.proto +syntax = "proto2"; + +message my_struct { + required int32 value = 1; + required string name = 2; +} + +// schema.options +my_struct.name max_size:32 +``` + +```sh +# To see all flags and options available, see +./jbpf_protobuf_cli serde --help + +# Generate the jbpf serde assets for the above proto spec +./jbpf_protobuf_cli serde -s schema:my_struct +``` + +This will generate the following files: +* `schema:my_struct_serializer.c`: + ```c + #define PB_FIELD_32BIT 1 + #include + #include + #include + #include "schema.pb.h" + + const uint32_t proto_message_size = sizeof(my_struct); + + int jbpf_io_serialize(void* input_msg_buf, size_t input_msg_buf_size, char* serialized_data_buf, size_t serialized_data_buf_size) { + if (input_msg_buf_size != proto_message_size) + return -1; + + pb_ostream_t ostream = pb_ostream_from_buffer((uint8_t*)serialized_data_buf, serialized_data_buf_size); + if (!pb_encode(&ostream, my_struct_fields, input_msg_buf)) + return -1; + + return ostream.bytes_written; + } + + int jbpf_io_deserialize(char* serialized_data_buf, size_t serialized_data_buf_size, void* output_msg_buf, size_t output_msg_buf_size) { + if (output_msg_buf_size != proto_message_size) + return 0; + + pb_istream_t istream = pb_istream_from_buffer((uint8_t*)serialized_data_buf, serialized_data_buf_size); + return pb_decode(&istream, my_struct_fields, output_msg_buf); + } + ``` +* `schema:my_struct_serializer.so` is the compiled shared object library of `schema:my_struct_serializer.c`. +* `schema.pb` is the complied protobuf spec. +* `schema.pb.c` is the generated nanopb constant definitions. +* `schema.pb.h` is the generated nanopb headers file. + +When loading the codelet description you can provide the generated `{schema}:{message_name}_serializer.so` as the io_channel `serde.file_path`. + +Additionally, you can provide the `{schema}.pb` to a decoder to be able to dynamically decode/encode the protobuf messages. + +To see detailed usage, run `jbpf_protobuf_cli serde --help`. + +## Decoder + +The cli tool also provides a `decoder` subcommand which can be run locally to receive and print protobuf messages sent over a UDP channel. The examples [example_collect_control](../examples/first_example_ipc/example_collect_control.cpp) and [first_example_standalone](../examples/first_example_standalone/example_app.cpp) bind to a UDP socket on port 20788 to send output data from jbpf which matches the default UDP socket for the decoder. + +This is useful for debugging output from jbpf and provide an example of how someone might dynamically decode output from jbpf by providing `.pb` schemas along with the associated stream identifier. + +To see detailed usage, run `jbpf_protobuf_cli decoder --help`. + +## Input Forwarder + +The tool also provides the ability to dynamically send protobuf input to jbpf from an external entity. It uses a TCP socket to send input channel messages to a jbpf instance. The examples [example_collect_control](../examples/first_example_ipc/example_collect_control.cpp) and [first_example_standalone](../examples/first_example_standalone/example_app.cpp) bind to a TCP socket on port 20787 to receive input data for jbpf which matches the default TCP socket for the input forwarder. + +To see detailed usage, run `jbpf_protobuf_cli input forward --help`. diff --git a/examples/first_example_ipc/README.md b/examples/first_example_ipc/README.md index 82b4f56..2e243e1 100644 --- a/examples/first_example_ipc/README.md +++ b/examples/first_example_ipc/README.md @@ -95,7 +95,7 @@ INFO[0010] {"seqNo":7, "value":-7, "name":"instance 7"} streamUUID=00112233-445 To send a manual control message to the `example_app`, we run the command: ```sh -$ ./send_control.sh 101 +$ ./send_input_msg.sh 101 ``` This should trigger a message in the `example_app`: diff --git a/examples/first_example_ipc/load.sh b/examples/first_example_ipc/load.sh index b16b217..ff38b1f 100755 --- a/examples/first_example_ipc/load.sh +++ b/examples/first_example_ipc/load.sh @@ -2,6 +2,6 @@ set -e -$JBPFP_PATH/pkg/jbpf_protobuf_cli decoder load -c codeletset_load_request.yaml --decoder-control-ip 0.0.0.0 +$JBPFP_PATH/pkg/jbpf_protobuf_cli decoder load -c codeletset_load_request.yaml --decoder-api-ip 0.0.0.0 $JBPF_PATH/out/bin/jbpf_lcm_cli -l -c codeletset_load_request.yaml diff --git a/examples/first_example_ipc/send_control.sh b/examples/first_example_ipc/send_input_msg.sh similarity index 53% rename from examples/first_example_ipc/send_control.sh rename to examples/first_example_ipc/send_input_msg.sh index 05cff25..b00c4df 100755 --- a/examples/first_example_ipc/send_control.sh +++ b/examples/first_example_ipc/send_input_msg.sh @@ -1,5 +1,6 @@ #!/bin/sh -$JBPFP_PATH/pkg/jbpf_protobuf_cli decoder control \ +$JBPFP_PATH/pkg/jbpf_protobuf_cli input forward \ + -c codeletset_load_request.yaml \ --stream-id 11111111-1111-1111-1111-111111111111 \ --inline-json "{\"value\": $1}" diff --git a/examples/first_example_standalone/README.md b/examples/first_example_standalone/README.md index dbab1b5..a72011f 100644 --- a/examples/first_example_standalone/README.md +++ b/examples/first_example_standalone/README.md @@ -69,7 +69,7 @@ INFO[0010] {"seqNo":7, "value":-7, "name":"instance 7"} streamUUID=00112233-445 To send a manual control message to the `example_app`, we run the command: ```sh -$ ./send_control.sh 101 +$ ./send_input_msg.sh 101 ``` This should trigger a message in the `example_app`: diff --git a/examples/first_example_standalone/load.sh b/examples/first_example_standalone/load.sh index b16b217..30d425b 100755 --- a/examples/first_example_standalone/load.sh +++ b/examples/first_example_standalone/load.sh @@ -2,6 +2,6 @@ set -e -$JBPFP_PATH/pkg/jbpf_protobuf_cli decoder load -c codeletset_load_request.yaml --decoder-control-ip 0.0.0.0 +$JBPFP_PATH/pkg/jbpf_protobuf_cli decoder load -c codeletset_load_request.yaml --decoder-api-ip 0.0.0.0 -$JBPF_PATH/out/bin/jbpf_lcm_cli -l -c codeletset_load_request.yaml +sudo -E $JBPF_PATH/out/bin/jbpf_lcm_cli -l -c codeletset_load_request.yaml diff --git a/examples/first_example_standalone/run_app.sh b/examples/first_example_standalone/run_app.sh index 6b1b44e..1a91cd4 100755 --- a/examples/first_example_standalone/run_app.sh +++ b/examples/first_example_standalone/run_app.sh @@ -1,3 +1,3 @@ #!/bin/sh -LD_LIBRARY_PATH=$LD_LIBRARY_PATH:$JBPF_PATH/out/lib ./example_app +sudo LD_LIBRARY_PATH=$LD_LIBRARY_PATH:$JBPF_PATH/out/lib ./example_app diff --git a/examples/first_example_standalone/run_decoder.sh b/examples/first_example_standalone/run_decoder.sh index 48ccb33..0458541 100755 --- a/examples/first_example_standalone/run_decoder.sh +++ b/examples/first_example_standalone/run_decoder.sh @@ -1,3 +1,3 @@ #!/bin/sh -$JBPFP_PATH/pkg/jbpf_protobuf_cli decoder run --jbpf-enable +$JBPFP_PATH/pkg/jbpf_protobuf_cli decoder run diff --git a/examples/first_example_standalone/send_control.sh b/examples/first_example_standalone/send_input_msg.sh similarity index 53% rename from examples/first_example_standalone/send_control.sh rename to examples/first_example_standalone/send_input_msg.sh index 05cff25..b00c4df 100755 --- a/examples/first_example_standalone/send_control.sh +++ b/examples/first_example_standalone/send_input_msg.sh @@ -1,5 +1,6 @@ #!/bin/sh -$JBPFP_PATH/pkg/jbpf_protobuf_cli decoder control \ +$JBPFP_PATH/pkg/jbpf_protobuf_cli input forward \ + -c codeletset_load_request.yaml \ --stream-id 11111111-1111-1111-1111-111111111111 \ --inline-json "{\"value\": $1}" diff --git a/examples/first_example_standalone/unload.sh b/examples/first_example_standalone/unload.sh index 1b5e2b7..e519f7d 100755 --- a/examples/first_example_standalone/unload.sh +++ b/examples/first_example_standalone/unload.sh @@ -1,5 +1,5 @@ #!/bin/sh -$JBPF_PATH/out/bin/jbpf_lcm_cli -u -c codeletset_unload_request.yaml +sudo -E $JBPF_PATH/out/bin/jbpf_lcm_cli -u -c codeletset_unload_request.yaml $JBPFP_PATH/pkg/jbpf_protobuf_cli decoder unload -c codeletset_load_request.yaml diff --git a/pkg/cmd/decoder/control/control.go b/pkg/cmd/decoder/control/control.go deleted file mode 100644 index dd813e3..0000000 --- a/pkg/cmd/decoder/control/control.go +++ /dev/null @@ -1,111 +0,0 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. - -package control - -import ( - "encoding/json" - "errors" - "fmt" - "jbpf_protobuf_cli/common" - "jbpf_protobuf_cli/schema" - "os" - - "github.com/google/uuid" - "github.com/spf13/cobra" - "github.com/spf13/pflag" -) - -type runOptions struct { - schema *schema.ClientOptions - general *common.GeneralOptions - - filePath string - inlineJSON string - payload string - streamID string - streamUUID uuid.UUID -} - -func addToFlags(flags *pflag.FlagSet, opts *runOptions) { - flags.StringVarP(&opts.filePath, "file", "f", "", "path to file containing payload in JSON format") - flags.StringVarP(&opts.inlineJSON, "inline-json", "j", "", "inline payload in JSON format") - flags.StringVar(&opts.streamID, "stream-id", "00000000-0000-0000-0000-000000000000", "stream ID") -} - -func (o *runOptions) parse() error { - if (len(o.inlineJSON) > 0 && len(o.filePath) > 0) || (len(o.inlineJSON) == 0 && len(o.filePath) == 0) { - return errors.New("exactly one of --file or --inline-json can be specified") - } - - if len(o.filePath) != 0 { - if fi, err := os.Stat(o.filePath); err != nil { - return err - } else if fi.IsDir() { - return fmt.Errorf(`expected "%s" to be a file, got a directory`, o.filePath) - } - payload, err := os.ReadFile(o.filePath) - if err != nil { - return err - } - var deserializedPayload interface{} - err = json.Unmarshal(payload, &deserializedPayload) - if err != nil { - return err - } - o.payload = string(payload) - } else { - var deserializedPayload interface{} - err := json.Unmarshal([]byte(o.inlineJSON), &deserializedPayload) - if err != nil { - return err - } - o.payload = o.inlineJSON - } - - var err error - o.streamUUID, err = uuid.Parse(o.streamID) - if err != nil { - return err - } - - return nil -} - -// Command Load a schema to a local decoder -func Command(opts *common.GeneralOptions) *cobra.Command { - runOptions := &runOptions{ - schema: &schema.ClientOptions{}, - general: opts, - } - cmd := &cobra.Command{ - Use: "control", - Short: "Load a control message via a local decoder", - Long: "Load a control message via a local decoder", - RunE: func(cmd *cobra.Command, _ []string) error { - return run(cmd, runOptions) - }, - SilenceUsage: true, - } - addToFlags(cmd.PersistentFlags(), runOptions) - schema.AddClientOptionsToFlags(cmd.PersistentFlags(), runOptions.schema) - return cmd -} - -func run(cmd *cobra.Command, opts *runOptions) error { - if err := errors.Join( - opts.general.Parse(), - opts.schema.Parse(), - opts.parse(), - ); err != nil { - return err - } - - logger := opts.general.Logger - - client, err := schema.NewClient(cmd.Context(), logger, opts.schema) - if err != nil { - return err - } - - return client.SendControl(opts.streamUUID, string(opts.payload)) -} diff --git a/pkg/cmd/decoder/decoder.go b/pkg/cmd/decoder/decoder.go index 6a2a1e7..26ea3a3 100644 --- a/pkg/cmd/decoder/decoder.go +++ b/pkg/cmd/decoder/decoder.go @@ -3,7 +3,6 @@ package decoder import ( - "jbpf_protobuf_cli/cmd/decoder/control" "jbpf_protobuf_cli/cmd/decoder/load" "jbpf_protobuf_cli/cmd/decoder/run" "jbpf_protobuf_cli/cmd/decoder/unload" @@ -20,7 +19,6 @@ func Command(opts *common.GeneralOptions) *cobra.Command { Short: "Execute a decoder subcommand", } cmd.AddCommand( - control.Command(opts), load.Command(opts), unload.Command(opts), run.Command(opts), diff --git a/pkg/cmd/decoder/load/load.go b/pkg/cmd/decoder/load/load.go index d284d27..5074150 100644 --- a/pkg/cmd/decoder/load/load.go +++ b/pkg/cmd/decoder/load/load.go @@ -13,12 +13,12 @@ import ( ) type runOptions struct { - schema *schema.ClientOptions - general *common.GeneralOptions + decoderAPI *schema.Options + general *common.GeneralOptions compiledProtos map[string]*common.File configFiles []string - configs []DecoderLoadConfig + configs []common.CodeletsetConfig } func addToFlags(flags *pflag.FlagSet, opts *runOptions) { @@ -26,7 +26,7 @@ func addToFlags(flags *pflag.FlagSet, opts *runOptions) { } func (o *runOptions) parse() error { - configs, compiledProtos, err := fromFiles(o.configFiles...) + configs, compiledProtos, err := common.CodeletsetConfigFromFiles(o.configFiles...) if err != nil { return err } @@ -39,8 +39,8 @@ func (o *runOptions) parse() error { // Command Load a schema to a local decoder func Command(opts *common.GeneralOptions) *cobra.Command { runOptions := &runOptions{ - schema: &schema.ClientOptions{}, - general: opts, + decoderAPI: &schema.Options{}, + general: opts, } cmd := &cobra.Command{ Use: "load", @@ -52,14 +52,14 @@ func Command(opts *common.GeneralOptions) *cobra.Command { SilenceUsage: true, } addToFlags(cmd.PersistentFlags(), runOptions) - schema.AddClientOptionsToFlags(cmd.PersistentFlags(), runOptions.schema) + schema.AddOptionsToFlags(cmd.PersistentFlags(), runOptions.decoderAPI) return cmd } func run(cmd *cobra.Command, opts *runOptions) error { if err := errors.Join( opts.general.Parse(), - opts.schema.Parse(), + opts.decoderAPI.Parse(), opts.parse(), ); err != nil { return err @@ -67,7 +67,7 @@ func run(cmd *cobra.Command, opts *runOptions) error { logger := opts.general.Logger - client, err := schema.NewClient(cmd.Context(), logger, opts.schema) + client, err := schema.NewClient(cmd.Context(), logger, opts.decoderAPI) if err != nil { return err } @@ -76,28 +76,15 @@ func run(cmd *cobra.Command, opts *runOptions) error { for _, config := range opts.configs { for _, desc := range config.CodeletDescriptor { - for _, io := range desc.InIOChannel { - if existing, ok := schemas[io.Serde.Protobuf.protoPackageName]; ok { - existing.Streams[io.streamUUID] = io.Serde.Protobuf.MsgName - } else { - compiledProto := opts.compiledProtos[io.Serde.Protobuf.absPackagePath] - schemas[io.Serde.Protobuf.protoPackageName] = &schema.LoadRequest{ - CompiledProto: compiledProto.Data, - Streams: map[uuid.UUID]string{ - io.streamUUID: io.Serde.Protobuf.MsgName, - }, - } - } - } for _, io := range desc.OutIOChannel { - if existing, ok := schemas[io.Serde.Protobuf.protoPackageName]; ok { - existing.Streams[io.streamUUID] = io.Serde.Protobuf.MsgName + if existing, ok := schemas[io.Serde.Protobuf.ProtoPackageName]; ok { + existing.Streams[io.StreamUUID] = io.Serde.Protobuf.MsgName } else { - compiledProto := opts.compiledProtos[io.Serde.Protobuf.absPackagePath] - schemas[io.Serde.Protobuf.protoPackageName] = &schema.LoadRequest{ + compiledProto := opts.compiledProtos[io.Serde.Protobuf.AbsPackagePath] + schemas[io.Serde.Protobuf.ProtoPackageName] = &schema.LoadRequest{ CompiledProto: compiledProto.Data, Streams: map[uuid.UUID]string{ - io.streamUUID: io.Serde.Protobuf.MsgName, + io.StreamUUID: io.Serde.Protobuf.MsgName, }, } } diff --git a/pkg/cmd/decoder/run/run.go b/pkg/cmd/decoder/run/run.go index df508e5..1e96e0e 100644 --- a/pkg/cmd/decoder/run/run.go +++ b/pkg/cmd/decoder/run/run.go @@ -15,17 +15,17 @@ import ( ) type runOptions struct { - general *common.GeneralOptions - data *data.ServerOptions - schema *schema.ServerOptions + general *common.GeneralOptions + data *data.ServerOptions + decoderAPI *schema.Options } // Command Run decoder to collect, decode and print jbpf output func Command(opts *common.GeneralOptions) *cobra.Command { runOptions := &runOptions{ - general: opts, - data: &data.ServerOptions{}, - schema: &schema.ServerOptions{}, + general: opts, + data: &data.ServerOptions{}, + decoderAPI: &schema.Options{}, } cmd := &cobra.Command{ Use: "run", @@ -36,7 +36,7 @@ func Command(opts *common.GeneralOptions) *cobra.Command { }, SilenceUsage: true, } - schema.AddServerOptionsToFlags(cmd.PersistentFlags(), runOptions.schema) + schema.AddOptionsToFlags(cmd.PersistentFlags(), runOptions.decoderAPI) data.AddServerOptionsToFlags(cmd.PersistentFlags(), runOptions.data) return cmd } @@ -45,7 +45,7 @@ func run(cmd *cobra.Command, opts *runOptions) error { if err := errors.Join( opts.general.Parse(), opts.data.Parse(), - opts.schema.Parse(), + opts.decoderAPI.Parse(), ); err != nil { return err } @@ -54,10 +54,7 @@ func run(cmd *cobra.Command, opts *runOptions) error { store := schema.NewStore() - schemaServer, err := schema.NewServer(cmd.Context(), logger, opts.schema, store) - if err != nil { - return err - } + schemaServer := schema.NewServer(cmd.Context(), logger, opts.decoderAPI, store) dataServer, err := data.NewServer(cmd.Context(), logger, opts.data, store) if err != nil { diff --git a/pkg/cmd/decoder/unload/unload.go b/pkg/cmd/decoder/unload/unload.go index dfa4487..8abd819 100644 --- a/pkg/cmd/decoder/unload/unload.go +++ b/pkg/cmd/decoder/unload/unload.go @@ -17,11 +17,11 @@ const ( ) type runOptions struct { - schema *schema.ClientOptions - general *common.GeneralOptions + decoderAPI *schema.Options + general *common.GeneralOptions configFiles []string - configs []DecoderUnloadConfig + configs []common.CodeletsetConfig } func addToFlags(flags *pflag.FlagSet, opts *runOptions) { @@ -29,7 +29,7 @@ func addToFlags(flags *pflag.FlagSet, opts *runOptions) { } func (o *runOptions) parse() error { - configs, err := fromFiles(o.configFiles...) + configs, _, err := common.CodeletsetConfigFromFiles(o.configFiles...) if err != nil { return err } @@ -41,8 +41,8 @@ func (o *runOptions) parse() error { // Command Unload a schema from a local decoder func Command(opts *common.GeneralOptions) *cobra.Command { runOptions := &runOptions{ - schema: &schema.ClientOptions{}, - general: opts, + decoderAPI: &schema.Options{}, + general: opts, } cmd := &cobra.Command{ Use: "unload", @@ -54,14 +54,14 @@ func Command(opts *common.GeneralOptions) *cobra.Command { SilenceUsage: true, } addToFlags(cmd.PersistentFlags(), runOptions) - schema.AddClientOptionsToFlags(cmd.PersistentFlags(), runOptions.schema) + schema.AddOptionsToFlags(cmd.PersistentFlags(), runOptions.decoderAPI) return cmd } func run(cmd *cobra.Command, opts *runOptions) error { if err := errors.Join( opts.general.Parse(), - opts.schema.Parse(), + opts.decoderAPI.Parse(), opts.parse(), ); err != nil { return err @@ -69,7 +69,7 @@ func run(cmd *cobra.Command, opts *runOptions) error { logger := opts.general.Logger - client, err := schema.NewClient(cmd.Context(), logger, opts.schema) + client, err := schema.NewClient(cmd.Context(), logger, opts.decoderAPI) if err != nil { return err } @@ -78,11 +78,8 @@ func run(cmd *cobra.Command, opts *runOptions) error { for _, config := range opts.configs { for _, desc := range config.CodeletDescriptor { - for _, io := range desc.InIOChannel { - streamUUIDs = append(streamUUIDs, io.streamUUID) - } for _, io := range desc.OutIOChannel { - streamUUIDs = append(streamUUIDs, io.streamUUID) + streamUUIDs = append(streamUUIDs, io.StreamUUID) } } } diff --git a/pkg/cmd/decoder/unload/unload_config.go b/pkg/cmd/decoder/unload/unload_config.go deleted file mode 100644 index 886e50b..0000000 --- a/pkg/cmd/decoder/unload/unload_config.go +++ /dev/null @@ -1,78 +0,0 @@ -package unload - -import ( - "errors" - "fmt" - "jbpf_protobuf_cli/common" - - "github.com/google/uuid" - "gopkg.in/yaml.v3" -) - -// IOChannelConfig represents the configuration for an IO channel -type IOChannelConfig struct { - StreamID string `yaml:"stream_id"` - - streamUUID uuid.UUID -} - -// CodeletDescriptorConfig represents the configuration for a codelet descriptor -type CodeletDescriptorConfig struct { - InIOChannel []*IOChannelConfig `yaml:"in_io_channel"` - OutIOChannel []*IOChannelConfig `yaml:"out_io_channel"` -} - -// DecoderUnloadConfig represents the configuration for unloading a decoder -type DecoderUnloadConfig struct { - CodeletDescriptor []*CodeletDescriptorConfig `yaml:"codelet_descriptor"` -} - -func (io *IOChannelConfig) verify() error { - streamUUID, err := uuid.Parse(io.StreamID) - if err != nil { - return err - } - io.streamUUID = streamUUID - return nil -} - -func fromFiles(configs ...string) ([]DecoderUnloadConfig, error) { - out := make([]DecoderUnloadConfig, 0, len(configs)) - errs := make([]error, 0, len(configs)) - -configLoad: - for _, c := range configs { - f, err := common.NewFile(c) - if err != nil { - errs = append(errs, fmt.Errorf("failed to read file %s: %w", c, err)) - continue - } - var config DecoderUnloadConfig - if err := yaml.Unmarshal(f.Data, &config); err != nil { - errs = append(errs, fmt.Errorf("failed to unmarshal file %s: %w", c, err)) - continue - } - - for _, desc := range config.CodeletDescriptor { - for _, io := range desc.InIOChannel { - if err := io.verify(); err != nil { - errs = append(errs, fmt.Errorf("failed to verify in_io_channel in file %s: %w", c, err)) - continue configLoad - } - } - for _, io := range desc.OutIOChannel { - if err := io.verify(); err != nil { - errs = append(errs, fmt.Errorf("failed to verify out_io_channel in file %s: %w", c, err)) - continue configLoad - } - } - } - - out = append(out, config) - } - if err := errors.Join(errs...); err != nil { - return nil, err - } - - return out, nil -} diff --git a/pkg/cmd/input/forward/forward.go b/pkg/cmd/input/forward/forward.go new file mode 100644 index 0000000..16e9289 --- /dev/null +++ b/pkg/cmd/input/forward/forward.go @@ -0,0 +1,190 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. + +package forward + +import ( + "encoding/json" + "errors" + "fmt" + "jbpf_protobuf_cli/common" + "jbpf_protobuf_cli/jbpf" + "os" + + "github.com/google/uuid" + "github.com/sirupsen/logrus" + "github.com/spf13/cobra" + "github.com/spf13/pflag" + "google.golang.org/protobuf/encoding/protojson" + "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/reflect/protodesc" + "google.golang.org/protobuf/reflect/protoreflect" + "google.golang.org/protobuf/types/descriptorpb" + "google.golang.org/protobuf/types/dynamicpb" +) + +type runOptions struct { + jbpf *jbpf.Options + general *common.GeneralOptions + + compiledProtos map[string]*common.File + configFiles []string + configs []common.CodeletsetConfig + filePath string + inlineJSON string + payload string + streamID string + streamUUID uuid.UUID +} + +func addToFlags(flags *pflag.FlagSet, opts *runOptions) { + flags.StringArrayVarP(&opts.configFiles, "config", "c", []string{}, "configuration files to load") + flags.StringVar(&opts.streamID, "stream-id", "00000000-0000-0000-0000-000000000000", "stream ID") + flags.StringVarP(&opts.filePath, "file", "f", "", "path to file containing payload in JSON format") + flags.StringVarP(&opts.inlineJSON, "inline-json", "j", "", "inline payload in JSON format") +} + +func (o *runOptions) parse() error { + if (len(o.inlineJSON) > 0 && len(o.filePath) > 0) || (len(o.inlineJSON) == 0 && len(o.filePath) == 0) { + return errors.New("exactly one of --file or --inline-json can be specified") + } + + if len(o.filePath) != 0 { + if fi, err := os.Stat(o.filePath); err != nil { + return err + } else if fi.IsDir() { + return fmt.Errorf(`expected "%s" to be a file, got a directory`, o.filePath) + } + payload, err := os.ReadFile(o.filePath) + if err != nil { + return err + } + var deserializedPayload interface{} + err = json.Unmarshal(payload, &deserializedPayload) + if err != nil { + return err + } + o.payload = string(payload) + } else { + var deserializedPayload interface{} + err := json.Unmarshal([]byte(o.inlineJSON), &deserializedPayload) + if err != nil { + return err + } + o.payload = o.inlineJSON + } + + var err error + o.streamUUID, err = uuid.Parse(o.streamID) + if err != nil { + return err + } + + configs, compiledProtos, err := common.CodeletsetConfigFromFiles(o.configFiles...) + if err != nil { + return err + } + o.configs = configs + o.compiledProtos = compiledProtos + + return nil +} + +// Command Load a schema to a local decoder +func Command(opts *common.GeneralOptions) *cobra.Command { + runOptions := &runOptions{ + jbpf: &jbpf.Options{}, + general: opts, + } + cmd := &cobra.Command{ + Use: "forward", + Short: "Load a control message", + Long: "Load a control message", + RunE: func(cmd *cobra.Command, _ []string) error { + return run(cmd, runOptions) + }, + SilenceUsage: true, + } + addToFlags(cmd.PersistentFlags(), runOptions) + jbpf.AddOptionsToFlags(cmd.PersistentFlags(), runOptions.jbpf) + return cmd +} + +func run(_ *cobra.Command, opts *runOptions) error { + if err := errors.Join( + opts.general.Parse(), + opts.jbpf.Parse(), + opts.parse(), + ); err != nil { + return err + } + + logger := opts.general.Logger + + client, err := jbpf.NewClient(logger, opts.jbpf) + if err != nil { + return err + } + + msg, err := getMessageInstance(opts.configs, opts.compiledProtos, opts.streamUUID) + if err != nil { + return err + } + + err = protojson.Unmarshal([]byte(opts.payload), msg) + if err != nil { + logger.WithError(err).Error("error unmarshalling payload") + return err + } + + logger.WithFields(logrus.Fields{ + "msg": fmt.Sprintf("%T - \"%v\"", msg, msg), + }).Info("sending msg") + + payload, err := proto.Marshal(msg) + if err != nil { + return err + } + + out := append(opts.streamUUID[:], payload...) + + return client.Write(out) +} + +func getMessageInstance(configs []common.CodeletsetConfig, compiledProtos map[string]*common.File, streamUUID uuid.UUID) (*dynamicpb.Message, error) { + for _, config := range configs { + for _, desc := range config.CodeletDescriptor { + for _, io := range desc.InIOChannel { + fmt.Printf("%v == %v = %v\n", io.StreamUUID, streamUUID, io.StreamUUID == streamUUID) + if io.StreamUUID == streamUUID { + compiledProto := compiledProtos[io.Serde.Protobuf.AbsPackagePath] + + fds := &descriptorpb.FileDescriptorSet{} + if err := proto.Unmarshal(compiledProto.Data, fds); err != nil { + return nil, err + } + + pd, err := protodesc.NewFiles(fds) + if err != nil { + return nil, err + } + + msgName := protoreflect.FullName(io.Serde.Protobuf.MsgName) + var desc protoreflect.Descriptor + desc, err = pd.FindDescriptorByName(msgName) + if err != nil { + return nil, err + } + + md, ok := desc.(protoreflect.MessageDescriptor) + if !ok { + return nil, fmt.Errorf("failed to cast desc to protoreflect.MessageDescriptor, got %T", desc) + } + + return dynamicpb.NewMessage(md), nil + } + } + } + } + + return nil, fmt.Errorf("stream %s not found in any of the loaded schemas", streamUUID) +} diff --git a/pkg/cmd/input/input.go b/pkg/cmd/input/input.go new file mode 100644 index 0000000..01ff5f9 --- /dev/null +++ b/pkg/cmd/input/input.go @@ -0,0 +1,23 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. + +package input + +import ( + "jbpf_protobuf_cli/cmd/input/forward" + "jbpf_protobuf_cli/common" + + "github.com/spf13/cobra" +) + +// Command returns the decoder commands +func Command(opts *common.GeneralOptions) *cobra.Command { + cmd := &cobra.Command{ + Use: "input", + Long: "Execute a jbpf input subcommand.", + Short: "Execute a jbpf input subcommand", + } + cmd.AddCommand( + forward.Command(opts), + ) + return cmd +} diff --git a/pkg/cmd/decoder/load/load_config.go b/pkg/common/codeletset_config.go similarity index 63% rename from pkg/cmd/decoder/load/load_config.go rename to pkg/common/codeletset_config.go index a2fcdec..0f83f6a 100644 --- a/pkg/cmd/decoder/load/load_config.go +++ b/pkg/common/codeletset_config.go @@ -1,9 +1,8 @@ -package load +package common import ( "errors" "fmt" - "jbpf_protobuf_cli/common" "os" "path/filepath" "strings" @@ -14,11 +13,10 @@ import ( // ProtobufConfig represents the configuration for a protobuf message type ProtobufConfig struct { - MsgName string `yaml:"msg_name"` - PackagePath string `yaml:"package_path"` - - absPackagePath string - protoPackageName string + AbsPackagePath string `yaml:"-"` + MsgName string `yaml:"msg_name"` + PackagePath string `yaml:"package_path"` + ProtoPackageName string `yaml:"-"` } // SerdeConfig represents the configuration for serialize/deserialize @@ -28,10 +26,9 @@ type SerdeConfig struct { // IOChannelConfig represents the configuration for an IO channel type IOChannelConfig struct { - Serde *SerdeConfig `yaml:"serde"` - StreamID string `yaml:"stream_id"` - - streamUUID uuid.UUID + Serde *SerdeConfig `yaml:"serde"` + StreamID string `yaml:"stream_id"` + StreamUUID uuid.UUID `yaml:"-"` } // CodeletDescriptorConfig represents the configuration for a codelet descriptor @@ -40,49 +37,50 @@ type CodeletDescriptorConfig struct { OutIOChannel []*IOChannelConfig `yaml:"out_io_channel"` } -// DecoderLoadConfig represents the configuration for loading a decoder -type DecoderLoadConfig struct { +// CodeletsetConfig represents the configuration for loading a decoder +type CodeletsetConfig struct { CodeletDescriptor []*CodeletDescriptorConfig `yaml:"codelet_descriptor"` } -func (io *IOChannelConfig) verify(compiledProtos map[string]*common.File) error { +func (io *IOChannelConfig) verify(compiledProtos map[string]*File) error { streamUUID, err := uuid.Parse(io.StreamID) if err != nil { return err } - io.streamUUID = streamUUID + io.StreamUUID = streamUUID if io.Serde == nil || io.Serde.Protobuf == nil || io.Serde.Protobuf.PackagePath == "" { return fmt.Errorf("missing required field package_path") } - io.Serde.Protobuf.absPackagePath = os.ExpandEnv(io.Serde.Protobuf.PackagePath) - basename := filepath.Base(io.Serde.Protobuf.absPackagePath) - io.Serde.Protobuf.protoPackageName = strings.TrimSuffix(basename, filepath.Ext(basename)) + io.Serde.Protobuf.AbsPackagePath = os.ExpandEnv(io.Serde.Protobuf.PackagePath) + basename := filepath.Base(io.Serde.Protobuf.AbsPackagePath) + io.Serde.Protobuf.ProtoPackageName = strings.TrimSuffix(basename, filepath.Ext(basename)) - if _, ok := compiledProtos[io.Serde.Protobuf.absPackagePath]; !ok { - protoPkg, err := common.NewFile(io.Serde.Protobuf.absPackagePath) + if _, ok := compiledProtos[io.Serde.Protobuf.AbsPackagePath]; !ok { + protoPkg, err := NewFile(io.Serde.Protobuf.AbsPackagePath) if err != nil { return err } - compiledProtos[io.Serde.Protobuf.absPackagePath] = protoPkg + compiledProtos[io.Serde.Protobuf.AbsPackagePath] = protoPkg } return nil } -func fromFiles(configs ...string) ([]DecoderLoadConfig, map[string]*common.File, error) { - out := make([]DecoderLoadConfig, 0, len(configs)) - compiledProtos := make(map[string]*common.File) +// CodeletsetConfigFromFiles reads and unmarshals the given files into a slice of CodeletsetConfig +func CodeletsetConfigFromFiles(configs ...string) ([]CodeletsetConfig, map[string]*File, error) { + out := make([]CodeletsetConfig, 0, len(configs)) + compiledProtos := make(map[string]*File) errs := make([]error, 0, len(configs)) configLoad: for _, c := range configs { - f, err := common.NewFile(c) + f, err := NewFile(c) if err != nil { errs = append(errs, fmt.Errorf("failed to read file %s: %w", c, err)) continue } - var config DecoderLoadConfig + var config CodeletsetConfig if err := yaml.Unmarshal(f.Data, &config); err != nil { errs = append(errs, fmt.Errorf("failed to unmarshal file %s: %w", c, err)) continue diff --git a/pkg/jbpf/options.go b/pkg/jbpf/options.go index 31efcd9..5a981f9 100644 --- a/pkg/jbpf/options.go +++ b/pkg/jbpf/options.go @@ -19,7 +19,6 @@ const ( // Options is the options for the jbpf client type Options struct { - Enable bool ip string keepAlivePeriod time.Duration port uint16 @@ -31,7 +30,6 @@ func AddOptionsToFlags(flags *pflag.FlagSet, opts *Options) { return } - flags.BoolVar(&opts.Enable, "jbpf-enable", false, "whether to allow sending control messages to the jbpf TCP server") flags.DurationVar(&opts.keepAlivePeriod, optionsPrefix+"-keep-alive", 0, "time to keep alive the connection") flags.StringVar(&opts.ip, optionsPrefix+"-ip", defaultIP, "IP address of the jbpf TCP server") flags.Uint16Var(&opts.port, optionsPrefix+"-port", defaultPort, "port address of the jbpf TCP server") @@ -39,9 +37,6 @@ func AddOptionsToFlags(flags *pflag.FlagSet, opts *Options) { // Parse parses the options func (o *Options) Parse() error { - if !o.Enable { - return nil - } _, err := url.ParseRequestURI(fmt.Sprintf("%s://%s:%d", scheme, o.ip, o.port)) if err != nil { return err diff --git a/pkg/main.go b/pkg/main.go index 9d3bc55..1314376 100644 --- a/pkg/main.go +++ b/pkg/main.go @@ -3,6 +3,7 @@ package main import ( "context" "jbpf_protobuf_cli/cmd/decoder" + "jbpf_protobuf_cli/cmd/input" "jbpf_protobuf_cli/cmd/serde" "jbpf_protobuf_cli/common" "os" @@ -21,11 +22,12 @@ func main() { func cli() *cobra.Command { cmd := &cobra.Command{ Use: os.Args[0], - Long: "jbpf companion command line tool to generate protobuf assets and a local decoder to interact with a remote jbpf instance over sockets.", + Long: "jbpf companion command line tool to generate protobuf assets. Includes a decoder to receive output data over a UDP socket from a jbpf instance. Messages are then decoded and print as json. Also provides a mechanism to dispatch input control messages to a jbpf instance via a TCP socket.", } opts := common.NewGeneralOptions(cmd.PersistentFlags()) cmd.AddCommand( decoder.Command(opts), + input.Command(opts), serde.Command(opts), ) return cmd diff --git a/pkg/schema/client.go b/pkg/schema/client.go index b760d3a..2aa7c92 100644 --- a/pkg/schema/client.go +++ b/pkg/schema/client.go @@ -26,9 +26,9 @@ type Client struct { } // NewClient creates a new Client -func NewClient(ctx context.Context, logger *logrus.Logger, opts *ClientOptions) (*Client, error) { +func NewClient(ctx context.Context, logger *logrus.Logger, opts *Options) (*Client, error) { return &Client{ - baseURL: fmt.Sprintf("%s://%s:%d", controlScheme, opts.control.ip, opts.control.port), + baseURL: fmt.Sprintf("%s://%s:%d", controlScheme, opts.ip, opts.port), ctx: ctx, inner: &http.Client{}, logger: logger, diff --git a/pkg/schema/client_options.go b/pkg/schema/client_options.go deleted file mode 100644 index 5310ed5..0000000 --- a/pkg/schema/client_options.go +++ /dev/null @@ -1,24 +0,0 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. - -package schema - -import "github.com/spf13/pflag" - -// ClientOptions is the options for the decoder client -type ClientOptions struct { - control *controlOptions -} - -// AddClientOptionsToFlags adds the client options to the flags -func AddClientOptionsToFlags(flags *pflag.FlagSet, opts *ClientOptions) { - if opts.control == nil { - opts.control = &controlOptions{} - } - - addControlOptionsToFlags(flags, opts.control) -} - -// Parse parses the client options -func (o *ClientOptions) Parse() error { - return o.control.parse() -} diff --git a/pkg/schema/options.go b/pkg/schema/options.go index 75ef1f6..bbef271 100644 --- a/pkg/schema/options.go +++ b/pkg/schema/options.go @@ -13,22 +13,25 @@ const ( // DefaultControlPort is the default used for the local decoder server DefaultControlPort = uint16(20789) - controlPrefix = "decoder-control" + controlPrefix = "decoder-api" controlScheme = "http" defaultControlIP = "" ) -type controlOptions struct { +// Options for internal communication with the decoder +type Options struct { ip string port uint16 } -func addControlOptionsToFlags(flags *pflag.FlagSet, opts *controlOptions) { - flags.StringVar(&opts.ip, controlPrefix+"-ip", defaultControlIP, "IP address of the control HTTP server") - flags.Uint16Var(&opts.port, controlPrefix+"-port", DefaultControlPort, "port address of the control HTTP server") +// AddOptionsToFlags adds the options to the provided flag set +func AddOptionsToFlags(flags *pflag.FlagSet, opts *Options) { + flags.StringVar(&opts.ip, controlPrefix+"-ip", defaultControlIP, "IP address of the decoder HTTP server") + flags.Uint16Var(&opts.port, controlPrefix+"-port", DefaultControlPort, "port address of the decoder HTTP server") } -func (o *controlOptions) parse() error { +// Parse the options +func (o *Options) Parse() error { _, err := url.ParseRequestURI(fmt.Sprintf("%s://%s:%d", controlScheme, o.ip, o.port)) if err != nil { return err diff --git a/pkg/schema/serve.go b/pkg/schema/serve.go index 77f1fde..7f2a37e 100644 --- a/pkg/schema/serve.go +++ b/pkg/schema/serve.go @@ -87,30 +87,8 @@ func (s *Server) serveHTTP(ctx context.Context) error { } }) - if s.opts.jbpf.Enable { - http.HandleFunc("/control", func(w http.ResponseWriter, r *http.Request) { - switch r.Method { - case http.MethodPost: - body, err := readBodyAs[SendControlRequest](r) - if err != nil { - w.WriteHeader(http.StatusInternalServerError) - return - } - if err := s.SendControl(r.Context(), &body); err != nil { - w.WriteHeader(http.StatusInternalServerError) - return - } else { - w.WriteHeader(http.StatusOK) - } - - default: - w.WriteHeader(http.StatusMethodNotAllowed) - } - }) - } - srv := &http.Server{ - Addr: fmt.Sprintf("%s:%d", s.opts.control.ip, s.opts.control.port), + Addr: fmt.Sprintf("%s:%d", s.opts.ip, s.opts.port), Handler: nil, } diff --git a/pkg/schema/server.go b/pkg/schema/server.go index 9f54de1..f02db4e 100644 --- a/pkg/schema/server.go +++ b/pkg/schema/server.go @@ -7,11 +7,9 @@ import ( "crypto/sha1" "encoding/base64" "fmt" - "jbpf_protobuf_cli/jbpf" "path/filepath" "strings" - "google.golang.org/protobuf/encoding/protojson" "google.golang.org/protobuf/proto" "google.golang.org/protobuf/types/descriptorpb" @@ -21,32 +19,20 @@ import ( // Server is a server that implements the DynamicDecoderServer interface type Server struct { - ctx context.Context - jbpfClient *jbpf.Client - logger *logrus.Logger - opts *ServerOptions - store *Store + ctx context.Context + logger *logrus.Logger + opts *Options + store *Store } // NewServer returns a new Server -func NewServer(ctx context.Context, logger *logrus.Logger, opts *ServerOptions, store *Store) (*Server, error) { - var jbpfClient *jbpf.Client - var err error - - if opts.jbpf.Enable { - jbpfClient, err = jbpf.NewClient(logger, opts.jbpf) - if err != nil { - return nil, err - } - } - +func NewServer(ctx context.Context, logger *logrus.Logger, opts *Options, store *Store) *Server { return &Server{ - ctx: ctx, - jbpfClient: jbpfClient, - logger: logger, - opts: opts, - store: store, - }, nil + ctx: ctx, + logger: logger, + opts: opts, + store: store, + } } // Serve starts the server @@ -129,37 +115,6 @@ func (s *Server) AddStreamToSchemaAssociation(_ context.Context, req *AddSchemaA return nil } -// SendControl sends data to the jbpf agent -func (s *Server) SendControl(_ context.Context, req *SendControlRequest) error { - msg, err := s.store.GetProtoMsgInstance(req.StreamUUID) - if err != nil { - s.logger.WithError(err).Errorf("error creating instance of proto message %s", req.StreamUUID.String()) - return err - } - - err = protojson.Unmarshal([]byte(req.Payload), msg) - if err != nil { - s.logger.WithError(err).Error("error unmarshalling payload") - return err - } - - s.logger.WithFields(logrus.Fields{ - "msg": fmt.Sprintf("%T - \"%v\"", msg, msg), - }).Info("sending msg") - - payload, err := proto.Marshal(msg) - if err != nil { - return err - } - - out := append(req.StreamUUID[:], payload...) - if err := s.jbpfClient.Write(out); err != nil { - return err - } - - return nil -} - // DeleteStreamToSchemaAssociation removes the association between a stream and a schema func (s *Server) DeleteStreamToSchemaAssociation(_ context.Context, req uuid.UUID) error { l := s.logger.WithField("streamUUID", req.String()) diff --git a/pkg/schema/server_options.go b/pkg/schema/server_options.go deleted file mode 100644 index 8d3d663..0000000 --- a/pkg/schema/server_options.go +++ /dev/null @@ -1,40 +0,0 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. - -package schema - -import ( - "errors" - "jbpf_protobuf_cli/jbpf" - - "github.com/spf13/pflag" -) - -// ServerOptions is the options for the decoder server -type ServerOptions struct { - control *controlOptions - jbpf *jbpf.Options -} - -// AddServerOptionsToFlags adds the server options to the flags -func AddServerOptionsToFlags(flags *pflag.FlagSet, opts *ServerOptions) { - if opts == nil { - return - } - if opts.control == nil { - opts.control = &controlOptions{} - } - if opts.jbpf == nil { - opts.jbpf = &jbpf.Options{} - } - - addControlOptionsToFlags(flags, opts.control) - jbpf.AddOptionsToFlags(flags, opts.jbpf) -} - -// Parse parses the server options -func (o *ServerOptions) Parse() error { - return errors.Join( - o.control.parse(), - o.jbpf.Parse(), - ) -} From 5d746c43aefab6daef69b3f970b3cf1f9bb7e7d6 Mon Sep 17 00:00:00 2001 From: Connor Settle Date: Thu, 14 Nov 2024 21:17:33 +0000 Subject: [PATCH 3/6] Add basic arch diagram --- docs/design.md | 1 + docs/jbpf_arch.png | Bin 0 -> 30008 bytes examples/first_example_ipc/run_decoder.sh | 2 +- examples/first_example_standalone/load.sh | 2 +- examples/first_example_standalone/run_app.sh | 2 +- examples/first_example_standalone/unload.sh | 2 +- pkg/cmd/input/forward/forward.go | 73 ++++++++++--------- 7 files changed, 42 insertions(+), 40 deletions(-) create mode 100755 docs/jbpf_arch.png diff --git a/docs/design.md b/docs/design.md index 772a327..b938b29 100644 --- a/docs/design.md +++ b/docs/design.md @@ -4,6 +4,7 @@ For complete details of each subcommand, see `./jbpf_protobuf_cli {SUBCOMMAND} --help`. +![architecture](./jbpf_arch.png) ## Serde diff --git a/docs/jbpf_arch.png b/docs/jbpf_arch.png new file mode 100755 index 0000000000000000000000000000000000000000..72829c3ef35cd2d5bab7db75c1f0feca496b1909 GIT binary patch literal 30008 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 Lf0JRk@9e(;h8W#z literal 0 HcmV?d00001 diff --git a/examples/first_example_ipc/run_decoder.sh b/examples/first_example_ipc/run_decoder.sh index 48ccb33..d01e475 100755 --- a/examples/first_example_ipc/run_decoder.sh +++ b/examples/first_example_ipc/run_decoder.sh @@ -1,3 +1,3 @@ #!/bin/sh -$JBPFP_PATH/pkg/jbpf_protobuf_cli decoder run --jbpf-enable +$JBPFP_PATH/pkg/jbpf_protobuf_cli decoder diff --git a/examples/first_example_standalone/load.sh b/examples/first_example_standalone/load.sh index 30d425b..ff38b1f 100755 --- a/examples/first_example_standalone/load.sh +++ b/examples/first_example_standalone/load.sh @@ -4,4 +4,4 @@ set -e $JBPFP_PATH/pkg/jbpf_protobuf_cli decoder load -c codeletset_load_request.yaml --decoder-api-ip 0.0.0.0 -sudo -E $JBPF_PATH/out/bin/jbpf_lcm_cli -l -c codeletset_load_request.yaml +$JBPF_PATH/out/bin/jbpf_lcm_cli -l -c codeletset_load_request.yaml diff --git a/examples/first_example_standalone/run_app.sh b/examples/first_example_standalone/run_app.sh index 1a91cd4..6b1b44e 100755 --- a/examples/first_example_standalone/run_app.sh +++ b/examples/first_example_standalone/run_app.sh @@ -1,3 +1,3 @@ #!/bin/sh -sudo LD_LIBRARY_PATH=$LD_LIBRARY_PATH:$JBPF_PATH/out/lib ./example_app +LD_LIBRARY_PATH=$LD_LIBRARY_PATH:$JBPF_PATH/out/lib ./example_app diff --git a/examples/first_example_standalone/unload.sh b/examples/first_example_standalone/unload.sh index e519f7d..1b5e2b7 100755 --- a/examples/first_example_standalone/unload.sh +++ b/examples/first_example_standalone/unload.sh @@ -1,5 +1,5 @@ #!/bin/sh -sudo -E $JBPF_PATH/out/bin/jbpf_lcm_cli -u -c codeletset_unload_request.yaml +$JBPF_PATH/out/bin/jbpf_lcm_cli -u -c codeletset_unload_request.yaml $JBPFP_PATH/pkg/jbpf_protobuf_cli decoder unload -c codeletset_load_request.yaml diff --git a/pkg/cmd/input/forward/forward.go b/pkg/cmd/input/forward/forward.go index 16e9289..ec559d3 100644 --- a/pkg/cmd/input/forward/forward.go +++ b/pkg/cmd/input/forward/forward.go @@ -43,50 +43,20 @@ func addToFlags(flags *pflag.FlagSet, opts *runOptions) { flags.StringVarP(&opts.inlineJSON, "inline-json", "j", "", "inline payload in JSON format") } -func (o *runOptions) parse() error { - if (len(o.inlineJSON) > 0 && len(o.filePath) > 0) || (len(o.inlineJSON) == 0 && len(o.filePath) == 0) { - return errors.New("exactly one of --file or --inline-json can be specified") - } - - if len(o.filePath) != 0 { - if fi, err := os.Stat(o.filePath); err != nil { - return err - } else if fi.IsDir() { - return fmt.Errorf(`expected "%s" to be a file, got a directory`, o.filePath) - } - payload, err := os.ReadFile(o.filePath) - if err != nil { - return err - } - var deserializedPayload interface{} - err = json.Unmarshal(payload, &deserializedPayload) - if err != nil { - return err - } - o.payload = string(payload) - } else { - var deserializedPayload interface{} - err := json.Unmarshal([]byte(o.inlineJSON), &deserializedPayload) - if err != nil { - return err - } - o.payload = o.inlineJSON - } - - var err error - o.streamUUID, err = uuid.Parse(o.streamID) +func (o *runOptions) parse() (err error) { + o.payload, err = loadInlineJSONOrFromFile(o.inlineJSON, o.filePath) if err != nil { return err } - configs, compiledProtos, err := common.CodeletsetConfigFromFiles(o.configFiles...) + o.streamUUID, err = uuid.Parse(o.streamID) if err != nil { return err } - o.configs = configs - o.compiledProtos = compiledProtos - return nil + o.configs, o.compiledProtos, err = common.CodeletsetConfigFromFiles(o.configFiles...) + + return err } // Command Load a schema to a local decoder @@ -150,6 +120,37 @@ func run(_ *cobra.Command, opts *runOptions) error { return client.Write(out) } +func loadInlineJSONOrFromFile(inlineJSON, filePath string) (string, error) { + if (len(inlineJSON) > 0 && len(filePath) > 0) || (len(inlineJSON) == 0 && len(filePath) == 0) { + return "", errors.New("exactly one of --file or --inline-json can be specified") + } + + if len(filePath) != 0 { + if fi, err := os.Stat(filePath); err != nil { + return "", err + } else if fi.IsDir() { + return "", fmt.Errorf(`expected "%s" to be a file, got a directory`, filePath) + } + payload, err := os.ReadFile(filePath) + if err != nil { + return "", err + } + var deserializedPayload interface{} + err = json.Unmarshal(payload, &deserializedPayload) + if err != nil { + return "", err + } + return string(payload), nil + } + + var deserializedPayload interface{} + err := json.Unmarshal([]byte(inlineJSON), &deserializedPayload) + if err != nil { + return "", err + } + return inlineJSON, nil +} + func getMessageInstance(configs []common.CodeletsetConfig, compiledProtos map[string]*common.File, streamUUID uuid.UUID) (*dynamicpb.Message, error) { for _, config := range configs { for _, desc := range config.CodeletDescriptor { From 6b542d1094ea7825105e3775242b16af039b0d9c Mon Sep 17 00:00:00 2001 From: Connor Settle Date: Thu, 14 Nov 2024 21:19:25 +0000 Subject: [PATCH 4/6] Fix run decoder script --- examples/first_example_ipc/run_decoder.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/first_example_ipc/run_decoder.sh b/examples/first_example_ipc/run_decoder.sh index d01e475..0458541 100755 --- a/examples/first_example_ipc/run_decoder.sh +++ b/examples/first_example_ipc/run_decoder.sh @@ -1,3 +1,3 @@ #!/bin/sh -$JBPFP_PATH/pkg/jbpf_protobuf_cli decoder +$JBPFP_PATH/pkg/jbpf_protobuf_cli decoder run From 58c0b75fc32eefaf0c9692b5bd99bb3452b44158 Mon Sep 17 00:00:00 2001 From: Connor Settle Date: Fri, 15 Nov 2024 11:04:14 +0000 Subject: [PATCH 5/6] Fix unload and update jbpf --- examples/first_example_ipc/load.sh | 2 +- examples/first_example_standalone/load.sh | 2 +- jbpf | 2 +- pkg/cmd/decoder/load/load.go | 23 +-- pkg/cmd/decoder/unload/unload.go | 13 +- pkg/cmd/input/forward/forward.go | 19 ++- pkg/cmd/input/input.go | 2 +- pkg/common/codeletset_config.go | 191 +++++++++++++++------- pkg/common/codeletset_raw_config.go | 49 ++++++ pkg/schema/client.go | 12 +- pkg/schema/serve.go | 21 ++- pkg/schema/server.go | 4 +- 12 files changed, 239 insertions(+), 101 deletions(-) create mode 100644 pkg/common/codeletset_raw_config.go diff --git a/examples/first_example_ipc/load.sh b/examples/first_example_ipc/load.sh index ff38b1f..0269ff0 100755 --- a/examples/first_example_ipc/load.sh +++ b/examples/first_example_ipc/load.sh @@ -2,6 +2,6 @@ set -e -$JBPFP_PATH/pkg/jbpf_protobuf_cli decoder load -c codeletset_load_request.yaml --decoder-api-ip 0.0.0.0 +$JBPFP_PATH/pkg/jbpf_protobuf_cli decoder load -c codeletset_load_request.yaml $JBPF_PATH/out/bin/jbpf_lcm_cli -l -c codeletset_load_request.yaml diff --git a/examples/first_example_standalone/load.sh b/examples/first_example_standalone/load.sh index ff38b1f..0269ff0 100755 --- a/examples/first_example_standalone/load.sh +++ b/examples/first_example_standalone/load.sh @@ -2,6 +2,6 @@ set -e -$JBPFP_PATH/pkg/jbpf_protobuf_cli decoder load -c codeletset_load_request.yaml --decoder-api-ip 0.0.0.0 +$JBPFP_PATH/pkg/jbpf_protobuf_cli decoder load -c codeletset_load_request.yaml $JBPF_PATH/out/bin/jbpf_lcm_cli -l -c codeletset_load_request.yaml diff --git a/jbpf b/jbpf index 9a25032..d821e42 160000 --- a/jbpf +++ b/jbpf @@ -1 +1 @@ -Subproject commit 9a25032f83b3d4038211d3042f9509a13438528a +Subproject commit d821e42e56884ebdf5d66520ed2df30f136a90af diff --git a/pkg/cmd/decoder/load/load.go b/pkg/cmd/decoder/load/load.go index 5074150..03f88d5 100644 --- a/pkg/cmd/decoder/load/load.go +++ b/pkg/cmd/decoder/load/load.go @@ -18,22 +18,20 @@ type runOptions struct { compiledProtos map[string]*common.File configFiles []string - configs []common.CodeletsetConfig + configs []*common.CodeletsetConfig } func addToFlags(flags *pflag.FlagSet, opts *runOptions) { flags.StringArrayVarP(&opts.configFiles, "config", "c", []string{}, "configuration files to load") } -func (o *runOptions) parse() error { - configs, compiledProtos, err := common.CodeletsetConfigFromFiles(o.configFiles...) +func (o *runOptions) parse() (err error) { + o.configs, err = common.CodeletsetConfigFromFiles(o.configFiles...) if err != nil { - return err + return } - o.configs = configs - o.compiledProtos = compiledProtos - - return nil + o.compiledProtos, err = common.LoadCompiledProtos(o.configs, false, true) + return } // Command Load a schema to a local decoder @@ -77,11 +75,14 @@ func run(cmd *cobra.Command, opts *runOptions) error { for _, config := range opts.configs { for _, desc := range config.CodeletDescriptor { for _, io := range desc.OutIOChannel { - if existing, ok := schemas[io.Serde.Protobuf.ProtoPackageName]; ok { + if existing, ok := schemas[io.Serde.Protobuf.PackageName]; ok { existing.Streams[io.StreamUUID] = io.Serde.Protobuf.MsgName } else { - compiledProto := opts.compiledProtos[io.Serde.Protobuf.AbsPackagePath] - schemas[io.Serde.Protobuf.ProtoPackageName] = &schema.LoadRequest{ + compiledProto, ok := opts.compiledProtos[io.Serde.Protobuf.PackagePath] + if !ok { + return errors.New("compiled proto not found") + } + schemas[io.Serde.Protobuf.PackageName] = &schema.LoadRequest{ CompiledProto: compiledProto.Data, Streams: map[uuid.UUID]string{ io.StreamUUID: io.Serde.Protobuf.MsgName, diff --git a/pkg/cmd/decoder/unload/unload.go b/pkg/cmd/decoder/unload/unload.go index 8abd819..6bf82cf 100644 --- a/pkg/cmd/decoder/unload/unload.go +++ b/pkg/cmd/decoder/unload/unload.go @@ -21,21 +21,16 @@ type runOptions struct { general *common.GeneralOptions configFiles []string - configs []common.CodeletsetConfig + configs []*common.CodeletsetConfig } func addToFlags(flags *pflag.FlagSet, opts *runOptions) { flags.StringArrayVarP(&opts.configFiles, "config", "c", []string{}, "configuration files to unload") } -func (o *runOptions) parse() error { - configs, _, err := common.CodeletsetConfigFromFiles(o.configFiles...) - if err != nil { - return err - } - o.configs = configs - - return nil +func (o *runOptions) parse() (err error) { + o.configs, err = common.CodeletsetConfigFromFiles(o.configFiles...) + return } // Command Unload a schema from a local decoder diff --git a/pkg/cmd/input/forward/forward.go b/pkg/cmd/input/forward/forward.go index ec559d3..8b08e1d 100644 --- a/pkg/cmd/input/forward/forward.go +++ b/pkg/cmd/input/forward/forward.go @@ -28,7 +28,7 @@ type runOptions struct { compiledProtos map[string]*common.File configFiles []string - configs []common.CodeletsetConfig + configs []*common.CodeletsetConfig filePath string inlineJSON string payload string @@ -46,17 +46,21 @@ func addToFlags(flags *pflag.FlagSet, opts *runOptions) { func (o *runOptions) parse() (err error) { o.payload, err = loadInlineJSONOrFromFile(o.inlineJSON, o.filePath) if err != nil { - return err + return } o.streamUUID, err = uuid.Parse(o.streamID) if err != nil { - return err + return } - o.configs, o.compiledProtos, err = common.CodeletsetConfigFromFiles(o.configFiles...) + o.configs, err = common.CodeletsetConfigFromFiles(o.configFiles...) + if err != nil { + return + } - return err + o.compiledProtos, err = common.LoadCompiledProtos(o.configs, true, false) + return } // Command Load a schema to a local decoder @@ -151,13 +155,12 @@ func loadInlineJSONOrFromFile(inlineJSON, filePath string) (string, error) { return inlineJSON, nil } -func getMessageInstance(configs []common.CodeletsetConfig, compiledProtos map[string]*common.File, streamUUID uuid.UUID) (*dynamicpb.Message, error) { +func getMessageInstance(configs []*common.CodeletsetConfig, compiledProtos map[string]*common.File, streamUUID uuid.UUID) (*dynamicpb.Message, error) { for _, config := range configs { for _, desc := range config.CodeletDescriptor { for _, io := range desc.InIOChannel { - fmt.Printf("%v == %v = %v\n", io.StreamUUID, streamUUID, io.StreamUUID == streamUUID) if io.StreamUUID == streamUUID { - compiledProto := compiledProtos[io.Serde.Protobuf.AbsPackagePath] + compiledProto := compiledProtos[io.Serde.Protobuf.PackagePath] fds := &descriptorpb.FileDescriptorSet{} if err := proto.Unmarshal(compiledProto.Data, fds); err != nil { diff --git a/pkg/cmd/input/input.go b/pkg/cmd/input/input.go index 01ff5f9..1c3aa42 100644 --- a/pkg/cmd/input/input.go +++ b/pkg/cmd/input/input.go @@ -9,7 +9,7 @@ import ( "github.com/spf13/cobra" ) -// Command returns the decoder commands +// Command returns the input commands func Command(opts *common.GeneralOptions) *cobra.Command { cmd := &cobra.Command{ Use: "input", diff --git a/pkg/common/codeletset_config.go b/pkg/common/codeletset_config.go index 0f83f6a..fd0be50 100644 --- a/pkg/common/codeletset_config.go +++ b/pkg/common/codeletset_config.go @@ -8,104 +8,181 @@ import ( "strings" "github.com/google/uuid" - "gopkg.in/yaml.v3" ) // ProtobufConfig represents the configuration for a protobuf message type ProtobufConfig struct { - AbsPackagePath string `yaml:"-"` - MsgName string `yaml:"msg_name"` - PackagePath string `yaml:"package_path"` - ProtoPackageName string `yaml:"-"` + MsgName string + PackageName string + PackagePath string +} + +func newProtobufConfig(cfg *ProtobufRawConfig) (*ProtobufConfig, error) { + if len(cfg.MsgName) == 0 { + return nil, fmt.Errorf("missing required field serde.protobuf.msg_name") + } + if len(cfg.PackagePath) == 0 { + return nil, fmt.Errorf("missing required field serde.protobuf.package_path") + } + + packagePath := os.ExpandEnv(cfg.PackagePath) + basename := filepath.Base(packagePath) + + return &ProtobufConfig{ + MsgName: cfg.MsgName, + PackageName: strings.TrimSuffix(basename, filepath.Ext(basename)), + PackagePath: packagePath, + }, nil } // SerdeConfig represents the configuration for serialize/deserialize type SerdeConfig struct { - Protobuf *ProtobufConfig `yaml:"protobuf"` + Protobuf *ProtobufConfig +} + +func newSerdeConfig(cfg *SerdeRawConfig) (*SerdeConfig, error) { + if cfg.Protobuf == nil { + return nil, fmt.Errorf("missing required field serde.protobuf") + } + + protobuf, err := newProtobufConfig(cfg.Protobuf) + if err != nil { + return nil, err + } + + return &SerdeConfig{Protobuf: protobuf}, nil } // IOChannelConfig represents the configuration for an IO channel type IOChannelConfig struct { - Serde *SerdeConfig `yaml:"serde"` - StreamID string `yaml:"stream_id"` - StreamUUID uuid.UUID `yaml:"-"` + Serde *SerdeConfig + StreamUUID uuid.UUID +} + +func newIOChannelConfig(cfg *IOChannelRawConfig) (*IOChannelConfig, error) { + if cfg.Serde == nil { + return nil, fmt.Errorf("missing required field serde") + } + + serde, err := newSerdeConfig(cfg.Serde) + if err != nil { + return nil, err + } + + streamUUID, err := uuid.Parse(cfg.StreamID) + if err != nil { + return nil, err + } + + return &IOChannelConfig{ + Serde: serde, + StreamUUID: streamUUID, + }, nil } // CodeletDescriptorConfig represents the configuration for a codelet descriptor type CodeletDescriptorConfig struct { - InIOChannel []*IOChannelConfig `yaml:"in_io_channel"` - OutIOChannel []*IOChannelConfig `yaml:"out_io_channel"` + InIOChannel []*IOChannelConfig + OutIOChannel []*IOChannelConfig +} + +func newCodeletDescriptorConfig(cfg *CodeletDescriptorRawConfig) (*CodeletDescriptorConfig, error) { + inIOChannel := make([]*IOChannelConfig, 0, len(cfg.InIOChannel)) + for _, rawIO := range cfg.InIOChannel { + io, err := newIOChannelConfig(rawIO) + if err != nil { + return nil, err + } + inIOChannel = append(inIOChannel, io) + } + + outIOChannel := make([]*IOChannelConfig, 0, len(cfg.OutIOChannel)) + for _, rawIO := range cfg.OutIOChannel { + io, err := newIOChannelConfig(rawIO) + if err != nil { + return nil, err + } + outIOChannel = append(outIOChannel, io) + } + + return &CodeletDescriptorConfig{ + InIOChannel: inIOChannel, + OutIOChannel: outIOChannel, + }, nil } // CodeletsetConfig represents the configuration for loading a decoder type CodeletsetConfig struct { - CodeletDescriptor []*CodeletDescriptorConfig `yaml:"codelet_descriptor"` + CodeletDescriptor []*CodeletDescriptorConfig } -func (io *IOChannelConfig) verify(compiledProtos map[string]*File) error { - streamUUID, err := uuid.Parse(io.StreamID) - if err != nil { - return err - } - io.StreamUUID = streamUUID - if io.Serde == nil || io.Serde.Protobuf == nil || io.Serde.Protobuf.PackagePath == "" { - return fmt.Errorf("missing required field package_path") +func newCodeletSetConfig(cfg *CodeletsetRawConfig) (*CodeletsetConfig, error) { + codeletDescriptors := make([]*CodeletDescriptorConfig, 0, len(cfg.CodeletDescriptor)) + for _, rawDesc := range cfg.CodeletDescriptor { + desc, err := newCodeletDescriptorConfig(rawDesc) + if err != nil { + return nil, err + } + codeletDescriptors = append(codeletDescriptors, desc) } + return &CodeletsetConfig{CodeletDescriptor: codeletDescriptors}, nil +} - io.Serde.Protobuf.AbsPackagePath = os.ExpandEnv(io.Serde.Protobuf.PackagePath) - basename := filepath.Base(io.Serde.Protobuf.AbsPackagePath) - io.Serde.Protobuf.ProtoPackageName = strings.TrimSuffix(basename, filepath.Ext(basename)) +// LoadCompiledProtos loads the compiled protobuf files from the codeletset config +func LoadCompiledProtos(cfgs []*CodeletsetConfig, includeInIO, includeOutIO bool) (map[string]*File, error) { + compiledProtos := make(map[string]*File) - if _, ok := compiledProtos[io.Serde.Protobuf.AbsPackagePath]; !ok { - protoPkg, err := NewFile(io.Serde.Protobuf.AbsPackagePath) - if err != nil { - return err + for _, c := range cfgs { + for _, desc := range c.CodeletDescriptor { + if includeInIO { + for _, io := range desc.InIOChannel { + if _, ok := compiledProtos[io.Serde.Protobuf.PackagePath]; !ok { + protoPkg, err := NewFile(io.Serde.Protobuf.PackagePath) + if err != nil { + return nil, err + } + compiledProtos[io.Serde.Protobuf.PackagePath] = protoPkg + } + } + } + + if includeOutIO { + for _, io := range desc.OutIOChannel { + if _, ok := compiledProtos[io.Serde.Protobuf.PackagePath]; !ok { + protoPkg, err := NewFile(io.Serde.Protobuf.PackagePath) + if err != nil { + return nil, err + } + compiledProtos[io.Serde.Protobuf.PackagePath] = protoPkg + } + } + } } - compiledProtos[io.Serde.Protobuf.AbsPackagePath] = protoPkg } - return nil + return compiledProtos, nil } // CodeletsetConfigFromFiles reads and unmarshals the given files into a slice of CodeletsetConfig -func CodeletsetConfigFromFiles(configs ...string) ([]CodeletsetConfig, map[string]*File, error) { - out := make([]CodeletsetConfig, 0, len(configs)) - compiledProtos := make(map[string]*File) +func CodeletsetConfigFromFiles(configs ...string) ([]*CodeletsetConfig, error) { + out := make([]*CodeletsetConfig, 0, len(configs)) errs := make([]error, 0, len(configs)) -configLoad: for _, c := range configs { - f, err := NewFile(c) + rawConfig, err := newCodeletsetRawConfig(c) if err != nil { - errs = append(errs, fmt.Errorf("failed to read file %s: %w", c, err)) - continue - } - var config CodeletsetConfig - if err := yaml.Unmarshal(f.Data, &config); err != nil { - errs = append(errs, fmt.Errorf("failed to unmarshal file %s: %w", c, err)) + errs = append(errs, err) continue } - for _, desc := range config.CodeletDescriptor { - for _, io := range desc.InIOChannel { - if err := io.verify(compiledProtos); err != nil { - errs = append(errs, fmt.Errorf("failed to verify in_io_channel in file %s: %w", c, err)) - continue configLoad - } - } - for _, io := range desc.OutIOChannel { - if err := io.verify(compiledProtos); err != nil { - errs = append(errs, fmt.Errorf("failed to verify out_io_channel in file %s: %w", c, err)) - continue configLoad - } - } + config, err := newCodeletSetConfig(rawConfig) + if err != nil { + errs = append(errs, fmt.Errorf("failed to unpack file %s: %w", c, err)) + continue } out = append(out, config) } - if err := errors.Join(errs...); err != nil { - return nil, nil, err - } - return out, compiledProtos, nil + return out, errors.Join(errs...) } diff --git a/pkg/common/codeletset_raw_config.go b/pkg/common/codeletset_raw_config.go new file mode 100644 index 0000000..8a13823 --- /dev/null +++ b/pkg/common/codeletset_raw_config.go @@ -0,0 +1,49 @@ +package common + +import ( + "fmt" + + "gopkg.in/yaml.v3" +) + +// ProtobufRawConfig represents the configuration for a protobuf message as defined in the yaml config +type ProtobufRawConfig struct { + MsgName string `yaml:"msg_name"` + PackagePath string `yaml:"package_path"` +} + +// SerdeRawConfig represents the configuration for serialize/deserialize as defined in the yaml config +type SerdeRawConfig struct { + Protobuf *ProtobufRawConfig `yaml:"protobuf"` +} + +// IOChannelRawConfig represents the configuration for an IO channel as defined in the yaml config +type IOChannelRawConfig struct { + Serde *SerdeRawConfig `yaml:"serde"` + StreamID string `yaml:"stream_id"` +} + +// CodeletDescriptorRawConfig represents the configuration for a codelet descriptor as defined in the yaml config +type CodeletDescriptorRawConfig struct { + InIOChannel []*IOChannelRawConfig `yaml:"in_io_channel"` + OutIOChannel []*IOChannelRawConfig `yaml:"out_io_channel"` +} + +// CodeletsetRawConfig represents the configuration for loading a decoder as defined in the yaml config +type CodeletsetRawConfig struct { + CodeletDescriptor []*CodeletDescriptorRawConfig `yaml:"codelet_descriptor"` +} + +func newCodeletsetRawConfig(filePath string) (*CodeletsetRawConfig, error) { + f, err := NewFile(filePath) + if err != nil { + return nil, fmt.Errorf("failed to read file %s: %w", filePath, err) + } + + var rawConfig CodeletsetRawConfig + if err := yaml.Unmarshal(f.Data, &rawConfig); err != nil { + return nil, fmt.Errorf("failed to unmarshal file %s: %w", filePath, err) + } + + return &rawConfig, nil +} diff --git a/pkg/schema/client.go b/pkg/schema/client.go index 2aa7c92..be87c72 100644 --- a/pkg/schema/client.go +++ b/pkg/schema/client.go @@ -11,6 +11,7 @@ import ( "fmt" "io" "net/http" + "net/url" "strings" "github.com/google/uuid" @@ -27,8 +28,13 @@ type Client struct { // NewClient creates a new Client func NewClient(ctx context.Context, logger *logrus.Logger, opts *Options) (*Client, error) { + ip := opts.ip + if len(ip) == 0 { + ip = "localhost" + } + return &Client{ - baseURL: fmt.Sprintf("%s://%s:%d", controlScheme, opts.ip, opts.port), + baseURL: fmt.Sprintf("%s://%s:%d", controlScheme, ip, opts.port), ctx: ctx, inner: &http.Client{}, logger: logger, @@ -88,7 +94,7 @@ func (c *Client) doDelete(relativePath string) error { return err } - if resp.StatusCode >= http.StatusOK && resp.StatusCode < http.StatusMultipleChoices { + if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices { err := fmt.Errorf("unexpected status code: %d", resp.StatusCode) c.logger.WithField("body", buf.String()).WithError(err).Error("unexpected status code") return err @@ -155,7 +161,7 @@ func (c *Client) Unload(streamUUIDs []uuid.UUID) error { errs := make([]error, 0, len(streamUUIDs)) for _, streamUUID := range streamUUIDs { streamIDStr := base64.StdEncoding.EncodeToString(streamUUID[:]) - if err := c.doDelete(fmt.Sprintf("/stream?stream_uuid=%s", streamIDStr)); err != nil { + if err := c.doDelete(fmt.Sprintf("/stream?stream_uuid=%s", url.PathEscape(streamIDStr))); err != nil { err = fmt.Errorf("failed to delete stream ID association %s: %w", streamUUID.String(), err) errs = append(errs, err) continue diff --git a/pkg/schema/serve.go b/pkg/schema/serve.go index 7f2a37e..4747aba 100644 --- a/pkg/schema/serve.go +++ b/pkg/schema/serve.go @@ -9,6 +9,7 @@ import ( "fmt" "io" "net/http" + "net/url" "os" "os/signal" "strings" @@ -64,24 +65,32 @@ func (s *Server) serveHTTP(ctx context.Context) error { } case http.MethodDelete: - streamUUIDStr := r.URL.Query().Get("streamUUID") - bs, err := base64.StdEncoding.DecodeString(streamUUIDStr) + streamUUIDStr := r.URL.Query().Get("stream_uuid") + unescapedStreamUUIDStr, err := url.PathUnescape(streamUUIDStr) + if err != nil { + s.logger.WithError(err).Error("failed to unescape stream_uuid") w.WriteHeader(http.StatusInternalServerError) return } - streamUUID, err := uuid.FromBytes(bs) + + bs, err := base64.StdEncoding.DecodeString(unescapedStreamUUIDStr) if err != nil { + s.logger.WithError(err).Errorf("failed to decode stream_uuid from %s", unescapedStreamUUIDStr) w.WriteHeader(http.StatusInternalServerError) return } - if err := s.DeleteStreamToSchemaAssociation(r.Context(), streamUUID); err != nil { + + streamUUID, err := uuid.FromBytes(bs) + if err != nil { + s.logger.WithError(err).Error("failed to parse stream id from bytes") w.WriteHeader(http.StatusInternalServerError) return - } else { - w.WriteHeader(http.StatusAccepted) } + s.DeleteStreamToSchemaAssociation(r.Context(), streamUUID) + w.WriteHeader(http.StatusAccepted) + default: w.WriteHeader(http.StatusMethodNotAllowed) } diff --git a/pkg/schema/server.go b/pkg/schema/server.go index f02db4e..25466d8 100644 --- a/pkg/schema/server.go +++ b/pkg/schema/server.go @@ -116,7 +116,7 @@ func (s *Server) AddStreamToSchemaAssociation(_ context.Context, req *AddSchemaA } // DeleteStreamToSchemaAssociation removes the association between a stream and a schema -func (s *Server) DeleteStreamToSchemaAssociation(_ context.Context, req uuid.UUID) error { +func (s *Server) DeleteStreamToSchemaAssociation(_ context.Context, req uuid.UUID) { l := s.logger.WithField("streamUUID", req.String()) if current, ok := s.store.streamToSchema[req]; !ok { @@ -128,6 +128,4 @@ func (s *Server) DeleteStreamToSchemaAssociation(_ context.Context, req uuid.UUI "protoPackage": current.ProtoPackage, }).Info("association removed") } - - return nil } From 7a93835bf94693afc5314a2f3c36e7295758aeb6 Mon Sep 17 00:00:00 2001 From: Connor Settle Date: Fri, 15 Nov 2024 12:29:35 +0000 Subject: [PATCH 6/6] PR comments --- README.md | 4 ++++ docs/design.md | 2 +- docs/jbpf_arch.png | Bin 30008 -> 24925 bytes 3 files changed, 5 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 7adb516..cf5327c 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,7 @@ Prerequisites: * Make * Pip * Python3 +* Protocol Buffer Compiler (protoc) The project utilizes [Nanopb](https://github.com/nanopb/nanopb) to generate C structures for given protobuf specs that use contiguous memory. It also generates serializer libraries that can be provided to jbpf, to encode output and decode input data to seamlessly integrate external data processing systems. @@ -19,6 +20,9 @@ The project utilizes [Nanopb](https://github.com/nanopb/nanopb) to generate C st # init submodules: ./init_submodules.sh +# Install nanopb pip packages: +python3 -m pip install -r 3p/nanopb/requirements.txt + # source environment variables source ./setup_jbpfp_env.sh diff --git a/docs/design.md b/docs/design.md index b938b29..3fdef67 100644 --- a/docs/design.md +++ b/docs/design.md @@ -8,7 +8,7 @@ For complete details of each subcommand, see `./jbpf_protobuf_cli {SUBCOMMAND} - ## Serde -The `serde` subcommand generates assets from protobuf specs which can integrate with `jbpf`'s [serde functionality](../jbpf/docs/serde.md). +The `serde` subcommand generates assets from protobuf specs which can integrate with `jbpf`'s [serde functionality](https://github.com/microsoft/jbpf/blob/main/docs/serde.md). Developers must write `.proto` file(s) defining the models that are to be serialized. Additionally they must provide [generator options](https://jpa.kapsi.fi/nanopb/docs/reference.html#generator-options) as defined by nanopb to ensure generated structs can be defined in C as contiguous memory structs. diff --git a/docs/jbpf_arch.png b/docs/jbpf_arch.png index 72829c3ef35cd2d5bab7db75c1f0feca496b1909..345f44ec854a720565faec70dd382f0a2cdfbbc7 100755 GIT binary patch literal 24925 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 Lf0JRk@9e(;h8W#z