SEBA-767 Directory restructuring in accordance with best practices
Change-Id: Id651366a3545ad0141a7854e99fa46867e543295
diff --git a/internal/pkg/cli/version/version.go b/internal/pkg/cli/version/version.go
new file mode 100644
index 0000000..21880bb
--- /dev/null
+++ b/internal/pkg/cli/version/version.go
@@ -0,0 +1,30 @@
+/*
+ * Portions copyright 2019-present Open Networking Foundation
+ * Original 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 version
+
+// Default build-time variable.
+// These values can (should) be overridden via ldflags when built with
+// `make`
+var (
+ Version = "unknown-version"
+ GoVersion = "unknown-goversion"
+ GitCommit = "unknown-gitcommit"
+ GitDirty = "unknown-gitdirty"
+ BuildTime = "unknown-buildtime"
+ Os = "unknown-os"
+ Arch = "unknown-arch"
+)
diff --git a/internal/pkg/commands/backup.go b/internal/pkg/commands/backup.go
new file mode 100644
index 0000000..84df5a9
--- /dev/null
+++ b/internal/pkg/commands/backup.go
@@ -0,0 +1,255 @@
+/*
+ * Portions copyright 2019-present Open Networking Foundation
+ * Original 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 (
+ "context"
+ flags "github.com/jessevdk/go-flags"
+ corderrors "github.com/opencord/cordctl/internal/pkg/error"
+ "time"
+)
+
+const (
+ DEFAULT_BACKUP_FORMAT = "table{{ .Status }}\t{{ .Checksum }}\t{{ .Chunks }}\t{{ .Bytes }}"
+)
+
+type BackupOutput struct {
+ Status string `json:"status"`
+ Checksum string `json:"checksum"`
+ Chunks int `json:"chunks"`
+ Bytes int `json:"bytes"`
+}
+
+type BackupCreate struct {
+ OutputOptions
+ ChunkSize int `short:"h" long:"chunksize" default:"65536" description:"Chunk size for streaming transfer"`
+ Args struct {
+ LocalFileName string
+ } `positional-args:"yes" required:"yes"`
+}
+
+type BackupRestore struct {
+ OutputOptions
+ ChunkSize int `short:"h" long:"chunksize" default:"65536" description:"Chunk size for streaming transfer"`
+ Args struct {
+ LocalFileName string
+ } `positional-args:"yes" required:"yes"`
+ CreateURIFunc func() (string, string) // allow override of CreateURIFunc for easy unit testing
+}
+
+type BackupOpts struct {
+ Create BackupCreate `command:"create"`
+ Restore BackupRestore `command:"restore"`
+}
+
+var backupOpts = BackupOpts{}
+
+func RegisterBackupCommands(parser *flags.Parser) {
+ parser.AddCommand("backup", "backup management commands", "Commands to create backups and restore backups", &backupOpts)
+}
+
+func (options *BackupCreate) Execute(args []string) error {
+ conn, descriptor, err := InitClient(INIT_DEFAULT)
+ if err != nil {
+ return err
+ }
+ defer conn.Close()
+
+ ctx := context.Background() // TODO: Implement a sync timeout
+
+ // We might close and reopen the connection befor we do the DownloadFile,
+ // so make sure we've downloaded the service descriptor.
+ _, err = descriptor.FindSymbol("xos.filetransfer")
+ if err != nil {
+ return err
+ }
+
+ local_name := options.Args.LocalFileName
+
+ // STEP 1: Create backup operation
+
+ backupop := make(map[string]interface{})
+ backupop["operation"] = "create"
+ err = CreateModel(conn, descriptor, "BackupOperation", backupop)
+ if err != nil {
+ return err
+ }
+ conditional_printf(!options.Quiet, "Created backup-create operation id=%d uuid=%s\n", backupop["id"], backupop["uuid"])
+ conditional_printf(!options.Quiet, "Waiting for sync ")
+
+ // STEP 2: Wait for the operation to complete
+
+ flags := GM_UNTIL_ENACTED | GM_UNTIL_FOUND | Ternary_uint32(options.Quiet, GM_QUIET, 0)
+ conn, completed_backupop, err := GetModelWithRetry(ctx, conn, descriptor, "BackupOperation", backupop["id"].(int32), flags)
+ if err != nil {
+ return err
+ }
+
+ defer conn.Close()
+
+ status := completed_backupop.GetFieldByName("status").(string)
+ conditional_printf(!options.Quiet, "\nStatus: %s\n", status)
+
+ // we've failed. leave.
+ if status != "created" {
+ return corderrors.NewInternalError("BackupOp status is %s", status)
+ }
+
+ // STEP 3: Retrieve URI
+ backupfile_id := completed_backupop.GetFieldByName("file_id").(int32)
+ if backupfile_id == 0 {
+ return corderrors.NewInternalError("BackupOp.file_id is not set")
+ }
+
+ completed_backupfile, err := GetModel(ctx, conn, descriptor, "BackupFile", backupfile_id)
+ if err != nil {
+ return err
+ }
+
+ uri := completed_backupfile.GetFieldByName("uri").(string)
+ conditional_printf(!options.Quiet, "URI %s\n", uri)
+
+ // STEP 4: Download the file
+
+ conditional_printf(!options.Quiet, "Downloading %s\n", local_name)
+
+ h, err := DownloadFile(conn, descriptor, uri, local_name)
+ if err != nil {
+ return err
+ }
+
+ // STEP 5: Verify checksum
+
+ if completed_backupfile.GetFieldByName("checksum").(string) != h.GetChecksum() {
+ return corderrors.WithStackTrace(&corderrors.ChecksumMismatchError{
+ Actual: h.GetChecksum(),
+ Expected: completed_backupfile.GetFieldByName("checksum").(string)})
+ }
+
+ // STEP 6: Show results
+
+ data := make([]BackupOutput, 1)
+ data[0].Chunks = h.chunks
+ data[0].Bytes = h.bytes
+ data[0].Status = h.status
+ data[0].Checksum = h.GetChecksum()
+
+ FormatAndGenerateOutput(&options.OutputOptions, DEFAULT_BACKUP_FORMAT, "{{.Status}}", data)
+
+ return nil
+}
+
+// Create a file:/// URI to use for storing the file in the core
+func CreateDynamicURI() (string, string) {
+ remote_name := "cordctl-restore-" + time.Now().Format("20060102T150405Z")
+ uri := "file:///var/run/xos/backup/local/" + remote_name
+ return remote_name, uri
+}
+
+func (options *BackupRestore) Execute(args []string) error {
+ conn, descriptor, err := InitClient(INIT_DEFAULT)
+ if err != nil {
+ return err
+ }
+ defer conn.Close()
+
+ ctx := context.Background() // TODO: Implement a sync timeout
+
+ local_name := options.Args.LocalFileName
+
+ var remote_name, uri string
+ if options.CreateURIFunc != nil {
+ remote_name, uri = options.CreateURIFunc()
+ } else {
+ remote_name, uri = CreateDynamicURI()
+ }
+
+ // STEP 1: Upload the file
+
+ h, upload_result, err := UploadFile(conn, descriptor, local_name, uri, options.ChunkSize)
+ if err != nil {
+ return err
+ }
+
+ upload_status := GetEnumValue(upload_result, "status")
+ if upload_status != "SUCCESS" {
+ return corderrors.NewInternalError("Upload status was %s", upload_status)
+ }
+
+ // STEP 2: Verify checksum
+
+ if upload_result.GetFieldByName("checksum").(string) != h.GetChecksum() {
+ return corderrors.WithStackTrace(&corderrors.ChecksumMismatchError{
+ Expected: h.GetChecksum(),
+ Actual: upload_result.GetFieldByName("checksum").(string)})
+ }
+
+ // STEP 2: Create a BackupFile object
+
+ backupfile := make(map[string]interface{})
+ backupfile["name"] = remote_name
+ backupfile["uri"] = uri
+ backupfile["checksum"] = h.GetChecksum()
+ err = CreateModel(conn, descriptor, "BackupFile", backupfile)
+ if err != nil {
+ return err
+ }
+ conditional_printf(!options.Quiet, "Created backup file %d\n", backupfile["id"])
+
+ // STEP 3: Create a BackupOperation object
+
+ backupop := make(map[string]interface{})
+ backupop["operation"] = "restore"
+ backupop["file_id"] = backupfile["id"]
+ err = CreateModel(conn, descriptor, "BackupOperation", backupop)
+ if err != nil {
+ return err
+ }
+ conditional_printf(!options.Quiet, "Created backup-restore operation id=%d uuid=%s\n", backupop["id"], backupop["uuid"])
+
+ conditional_printf(!options.Quiet, "Waiting for completion ")
+
+ // STEP 4: Wait for completion
+
+ flags := GM_UNTIL_ENACTED | GM_UNTIL_FOUND | GM_UNTIL_STATUS | Ternary_uint32(options.Quiet, GM_QUIET, 0)
+ queries := map[string]string{"uuid": backupop["uuid"].(string)}
+ conn, completed_backupop, err := FindModelWithRetry(ctx, conn, descriptor, "BackupOperation", queries, flags)
+ if err != nil {
+ return err
+ }
+
+ defer conn.Close()
+
+ conditional_printf(!options.Quiet, "\n")
+
+ // STEP 5: Show results
+
+ data := make([]BackupOutput, 1)
+ data[0].Checksum = upload_result.GetFieldByName("checksum").(string)
+ data[0].Chunks = int(upload_result.GetFieldByName("chunks_received").(int32))
+ data[0].Bytes = int(upload_result.GetFieldByName("bytes_received").(int32))
+
+ if completed_backupop.GetFieldByName("status") == "restored" {
+ data[0].Status = "SUCCESS"
+ } else {
+ data[0].Status = "FAILURE"
+ }
+
+ FormatAndGenerateOutput(&options.OutputOptions, DEFAULT_BACKUP_FORMAT, "{{.Status}}", data)
+
+ return nil
+}
diff --git a/internal/pkg/commands/backup_test.go b/internal/pkg/commands/backup_test.go
new file mode 100644
index 0000000..4d78a11
--- /dev/null
+++ b/internal/pkg/commands/backup_test.go
@@ -0,0 +1,90 @@
+/*
+ * Portions copyright 2019-present Open Networking Foundation
+ * Original 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 (
+ "bytes"
+ "github.com/opencord/cordctl/pkg/testutils"
+ "io/ioutil"
+ "testing"
+)
+
+func TestBackupCreate(t *testing.T) {
+ // use `python -m json.tool` to pretty-print json
+ expected := `[
+ {
+ "bytes": 6,
+ "checksum": "sha256:e9c0f8b575cbfcb42ab3b78ecc87efa3b011d9a5d10b09fa4e96f240bf6a82f5",
+ "chunks": 2,
+ "status": "SUCCESS"
+ }
+ ]`
+
+ got := new(bytes.Buffer)
+ OutputStream = got
+
+ var options BackupOpts
+ options.Create.OutputAs = "json"
+ options.Create.Args.LocalFileName = "/tmp/transfer.down"
+ options.Create.ChunkSize = 3
+ err := options.Create.Execute([]string{})
+
+ if err != nil {
+ t.Errorf("%s: Received error %v", t.Name(), err)
+ return
+ }
+
+ testutils.AssertJSONEqual(t, got.String(), expected)
+}
+
+// Mock the CreateURI function in the Restore code to use file:///tmp/transfer.up
+func CreateURI() (string, string) {
+ remote_name := "transfer.up"
+ uri := "file:///tmp/" + remote_name
+ return remote_name, uri
+}
+
+func TestBackupRestore(t *testing.T) {
+ // use `python -m json.tool` to pretty-print json
+ expected := `[
+ {
+ "bytes": 6,
+ "checksum": "sha256:e9c0f8b575cbfcb42ab3b78ecc87efa3b011d9a5d10b09fa4e96f240bf6a82f5",
+ "chunks": 2,
+ "status": "SUCCESS"
+ }
+ ]`
+
+ err := ioutil.WriteFile("/tmp/transfer.up", []byte("ABCDEF"), 0644)
+
+ got := new(bytes.Buffer)
+ OutputStream = got
+
+ var options BackupRestore
+ options.OutputAs = "json"
+ options.Args.LocalFileName = "/tmp/transfer.up"
+ options.ChunkSize = 3
+ options.CreateURIFunc = CreateURI
+ err = options.Execute([]string{})
+
+ if err != nil {
+ t.Errorf("%s: Received error %v", t.Name(), err)
+ return
+ }
+
+ testutils.AssertJSONEqual(t, got.String(), expected)
+}
diff --git a/internal/pkg/commands/command.go b/internal/pkg/commands/command.go
new file mode 100644
index 0000000..45ccd5d
--- /dev/null
+++ b/internal/pkg/commands/command.go
@@ -0,0 +1,277 @@
+/*
+ * Portions copyright 2019-present Open Networking Foundation
+ * Original 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"
+ "fmt"
+ "github.com/opencord/cordctl/pkg/format"
+ "github.com/opencord/cordctl/pkg/order"
+ "google.golang.org/grpc"
+ "gopkg.in/yaml.v2"
+ "io"
+ "io/ioutil"
+ "log"
+ "os"
+ "strings"
+ "time"
+ "net"
+)
+
+type OutputType uint8
+
+const (
+ OUTPUT_TABLE OutputType = iota
+ OUTPUT_JSON
+ OUTPUT_YAML
+
+ CORE_VERSION_CONSTRAINT = ">= 3, < 4" // Support XOS major version 3
+)
+
+// Make it easy to override output stream for testing
+var OutputStream io.Writer = os.Stdout
+
+var CharReplacer = strings.NewReplacer("\\t", "\t", "\\n", "\n")
+
+type GrpcConfigSpec struct {
+ Timeout time.Duration `yaml:"timeout"`
+}
+
+type TlsConfigSpec struct {
+ UseTls bool `yaml:"useTls"`
+ CACert string `yaml:"caCert"`
+ Cert string `yaml:"cert"`
+ Key string `yaml:"key"`
+ Verify string `yaml:"verify"`
+}
+
+type GlobalConfigSpec struct {
+ Server string `yaml:"server"`
+ Username string `yaml:"username"`
+ Password string `yaml:"password"`
+ Protoset string `yaml:"protoset"`
+ Tls TlsConfigSpec `yaml:"tls"`
+ Grpc GrpcConfigSpec
+}
+
+var GlobalConfig = GlobalConfigSpec{
+ Server: "localhost",
+ Tls: TlsConfigSpec{
+ UseTls: false,
+ },
+ Grpc: GrpcConfigSpec{
+ Timeout: time.Second * 10,
+ },
+}
+
+var GlobalOptions struct {
+ Config string `short:"c" long:"config" env:"CORDCONFIG" value-name:"FILE" default:"" description:"Location of client config file"`
+ Server string `short:"s" long:"server" default:"" value-name:"SERVER:PORT" description:"IP/Host and port of XOS"`
+ Username string `short:"u" long:"username" value-name:"USERNAME" default:"" description:"Username to authenticate with XOS"`
+ Password string `short:"p" long:"password" value-name:"PASSWORD" default:"" description:"Password to authenticate with XOS"`
+ Protoset string `long:"protoset" value-name:"FILENAME" description:"Load protobuf definitions from protoset instead of reflection api"`
+ Debug bool `short:"d" long:"debug" description:"Enable debug mode"`
+ UseTLS bool `long:"tls" description:"Use TLS"`
+ CACert string `long:"tlscacert" value-name:"CA_CERT_FILE" description:"Trust certs signed only by this CA"`
+ Cert string `long:"tlscert" value-name:"CERT_FILE" description:"Path to TLS vertificate file"`
+ Key string `long:"tlskey" value-name:"KEY_FILE" description:"Path to TLS key file"`
+ Verify bool `long:"tlsverify" description:"Use TLS and verify the remote"`
+ Yes bool `short:"y" long:"yes" description:"answer yes to any confirmation prompts"`
+}
+
+type OutputOptions struct {
+ Format string `long:"format" value-name:"FORMAT" default:"" description:"Format to use to output structured data"`
+ Quiet bool `short:"q" long:"quiet" description:"Output only the IDs of the objects"`
+ OutputAs string `short:"o" long:"outputas" default:"table" choice:"table" choice:"json" choice:"yaml" description:"Type of output to generate"`
+}
+
+type ListOutputOptions struct {
+ OutputOptions
+ OrderBy string `short:"r" long:"orderby" default:"" description:"Specify the sort order of the results"`
+}
+
+func toOutputType(in string) OutputType {
+ switch in {
+ case "table":
+ fallthrough
+ default:
+ return OUTPUT_TABLE
+ case "json":
+ return OUTPUT_JSON
+ case "yaml":
+ return OUTPUT_YAML
+ }
+}
+
+type CommandResult struct {
+ Format format.Format
+ OrderBy string
+ OutputAs OutputType
+ Data interface{}
+}
+
+type config struct {
+ Server string `yaml:"server"`
+}
+
+func ProcessGlobalOptions() {
+ if len(GlobalOptions.Config) == 0 {
+ home, err := os.UserHomeDir()
+ if err != nil {
+ log.Printf("Unable to discover the users home directory: %s\n", err)
+ }
+ GlobalOptions.Config = fmt.Sprintf("%s/.cord/config", home)
+ }
+
+ info, err := os.Stat(GlobalOptions.Config)
+ if err == nil && !info.IsDir() {
+ configFile, err := ioutil.ReadFile(GlobalOptions.Config)
+ if err != nil {
+ log.Printf("configFile.Get err #%v ", err)
+ }
+ err = yaml.Unmarshal(configFile, &GlobalConfig)
+ if err != nil {
+ log.Fatalf("Unmarshal: %v", err)
+ }
+ }
+
+ // Override from environment
+ // in particualr, for passing env vars via `go test`
+ env_server, present := os.LookupEnv("CORDCTL_SERVER")
+ if present {
+ GlobalConfig.Server = env_server
+ }
+ env_username, present := os.LookupEnv("CORDCTL_USERNAME")
+ if present {
+ GlobalConfig.Username = env_username
+ }
+ env_password, present := os.LookupEnv("CORDCTL_PASSWORD")
+ if present {
+ GlobalConfig.Password = env_password
+ }
+ env_protoset, present := os.LookupEnv("CORDCTL_PROTOSET")
+ if present {
+ GlobalConfig.Protoset = env_protoset
+ }
+
+ // Override from command line
+ if GlobalOptions.Server != "" {
+ GlobalConfig.Server = GlobalOptions.Server
+ }
+ if GlobalOptions.Username != "" {
+ GlobalConfig.Username = GlobalOptions.Username
+ }
+ if GlobalOptions.Password != "" {
+ GlobalConfig.Password = GlobalOptions.Password
+ }
+ if GlobalOptions.Protoset != "" {
+ GlobalConfig.Protoset = GlobalOptions.Protoset
+ }
+
+ // Generate error messages for required settings
+ if GlobalConfig.Server == "" {
+ log.Fatal("Server is not set. Please update config file or use the -s option")
+ }
+ if GlobalConfig.Username == "" {
+ log.Fatal("Username is not set. Please update config file or use the -u option")
+ }
+ if GlobalConfig.Password == "" {
+ log.Fatal("Password is not set. Please update config file or use the -p option")
+ }
+ //Try to resolve hostname if provided for the server
+ if host, port, err := net.SplitHostPort(GlobalConfig.Server); err == nil {
+ if addrs, err := net.LookupHost(host); err == nil {
+ GlobalConfig.Server = net.JoinHostPort(addrs[0], port)
+ }
+ }
+}
+
+func NewConnection() (*grpc.ClientConn, error) {
+ ProcessGlobalOptions()
+ return grpc.Dial(GlobalConfig.Server, grpc.WithInsecure())
+}
+
+func GenerateOutput(result *CommandResult) {
+ if result != nil && result.Data != nil {
+ data := result.Data
+ if result.OrderBy != "" {
+ s, err := order.Parse(result.OrderBy)
+ if err != nil {
+ panic(err)
+ }
+ data, err = s.Process(data)
+ if err != nil {
+ panic(err)
+ }
+ }
+ if result.OutputAs == OUTPUT_TABLE {
+ tableFormat := format.Format(result.Format)
+ tableFormat.Execute(OutputStream, true, data)
+ } else if result.OutputAs == OUTPUT_JSON {
+ asJson, err := json.Marshal(&data)
+ if err != nil {
+ panic(err)
+ }
+ fmt.Fprintf(OutputStream, "%s", asJson)
+ } else if result.OutputAs == OUTPUT_YAML {
+ asYaml, err := yaml.Marshal(&data)
+ if err != nil {
+ panic(err)
+ }
+ fmt.Fprintf(OutputStream, "%s", asYaml)
+ }
+ }
+}
+
+// Applies common output options to format and generate output
+func FormatAndGenerateOutput(options *OutputOptions, default_format string, quiet_format string, data interface{}) {
+ outputFormat := CharReplacer.Replace(options.Format)
+ if outputFormat == "" {
+ outputFormat = default_format
+ }
+ if options.Quiet {
+ outputFormat = quiet_format
+ }
+
+ result := CommandResult{
+ Format: format.Format(outputFormat),
+ OutputAs: toOutputType(options.OutputAs),
+ Data: data,
+ }
+
+ GenerateOutput(&result)
+}
+
+// Applies common output options to format and generate output
+func FormatAndGenerateListOutput(options *ListOutputOptions, default_format string, quiet_format string, data interface{}) {
+ outputFormat := CharReplacer.Replace(options.Format)
+ if outputFormat == "" {
+ outputFormat = default_format
+ }
+ if options.Quiet {
+ outputFormat = quiet_format
+ }
+
+ result := CommandResult{
+ Format: format.Format(outputFormat),
+ OutputAs: toOutputType(options.OutputAs),
+ Data: data,
+ OrderBy: options.OrderBy,
+ }
+
+ GenerateOutput(&result)
+}
diff --git a/internal/pkg/commands/common.go b/internal/pkg/commands/common.go
new file mode 100644
index 0000000..6f795e4
--- /dev/null
+++ b/internal/pkg/commands/common.go
@@ -0,0 +1,168 @@
+/*
+ * Copyright 2019-present Open Networking Foundation
+ *
+ * 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 (
+ "bufio"
+ b64 "encoding/base64"
+ "fmt"
+ "github.com/fullstorydev/grpcurl"
+ versionUtils "github.com/hashicorp/go-version"
+ "github.com/jhump/protoreflect/dynamic"
+ "github.com/jhump/protoreflect/grpcreflect"
+ corderrors "github.com/opencord/cordctl/internal/pkg/error"
+ "golang.org/x/net/context"
+ "google.golang.org/grpc"
+ reflectpb "google.golang.org/grpc/reflection/grpc_reflection_v1alpha"
+ "log"
+ "os"
+ "strings"
+)
+
+// Flags for calling the InitReflectionClient Method
+const (
+ INIT_DEFAULT = 0
+ INIT_NO_VERSION_CHECK = 1 // Do not check whether server is allowed version
+)
+
+func GenerateHeaders() []string {
+ username := GlobalConfig.Username
+ password := GlobalConfig.Password
+ sEnc := b64.StdEncoding.EncodeToString([]byte(username + ":" + password))
+ headers := []string{"authorization: basic " + sEnc}
+ return headers
+}
+
+// Perform the GetVersion API call on the core to get the version
+func GetVersion(conn *grpc.ClientConn, descriptor grpcurl.DescriptorSource) (*dynamic.Message, error) {
+ ctx, cancel := context.WithTimeout(context.Background(), GlobalConfig.Grpc.Timeout)
+ defer cancel()
+
+ headers := GenerateHeaders()
+
+ h := &RpcEventHandler{}
+ err := grpcurl.InvokeRPC(ctx, descriptor, conn, "xos.utility.GetVersion", headers, h, h.GetParams)
+ if err != nil {
+ return nil, corderrors.RpcErrorToCordError(err)
+ }
+
+ if h.Status != nil && h.Status.Err() != nil {
+ return nil, corderrors.RpcErrorToCordError(h.Status.Err())
+ }
+
+ d, err := dynamic.AsDynamicMessage(h.Response)
+
+ return d, err
+}
+
+// Initialize client connection
+// flags is a set of optional flags that may influence how the connection is setup
+// INIT_DEFAULT - default behavior (0)
+// INIT_NO_VERSION_CHECK - do not perform core version check
+
+func InitClient(flags uint32) (*grpc.ClientConn, grpcurl.DescriptorSource, error) {
+ conn, err := NewConnection()
+ if err != nil {
+ return nil, nil, err
+ }
+
+ refClient := grpcreflect.NewClient(context.Background(), reflectpb.NewServerReflectionClient(conn))
+ defer refClient.Reset()
+
+ // Intended method of use is to download the protos via reflection API. Loading the
+ // protos from a file is supported for unit testing, as the mock server does not
+ // support the reflection API.
+
+ var descriptor grpcurl.DescriptorSource
+ if GlobalConfig.Protoset != "" {
+ descriptor, err = grpcurl.DescriptorSourceFromProtoSets(GlobalConfig.Protoset)
+ if err != nil {
+ return nil, nil, err
+ }
+ } else {
+ descriptor = grpcurl.DescriptorSourceFromServer(context.Background(), refClient)
+ }
+
+ if flags&INIT_NO_VERSION_CHECK == 0 {
+ d, err := GetVersion(conn, descriptor)
+ if err != nil {
+ return nil, nil, err
+ }
+ // Note: NewVersion doesn't like the `-dev` suffix, so strip it off.
+ serverVersion, err := versionUtils.NewVersion(strings.Split(d.GetFieldByName("version").(string), "-")[0])
+ if err != nil {
+ return nil, nil, err
+ }
+
+ constraint, err := versionUtils.NewConstraint(CORE_VERSION_CONSTRAINT)
+ if err != nil {
+ return nil, nil, err
+ }
+
+ if !constraint.Check(serverVersion) {
+ return nil, nil, corderrors.WithStackTrace(&corderrors.VersionConstraintError{
+ Name: "xos-core",
+ Version: serverVersion.String(),
+ Constraint: CORE_VERSION_CONSTRAINT})
+ }
+
+ }
+
+ return conn, descriptor, nil
+}
+
+// A makeshift substitute for C's Ternary operator
+func Ternary_uint32(condition bool, value_true uint32, value_false uint32) uint32 {
+ if condition {
+ return value_true
+ } else {
+ return value_false
+ }
+}
+
+// call printf only if visible is True
+func conditional_printf(visible bool, format string, args ...interface{}) {
+ if visible {
+ fmt.Printf(format, args...)
+ }
+}
+
+// Print a confirmation prompt and get a response from the user
+func Confirmf(format string, args ...interface{}) bool {
+ if GlobalOptions.Yes {
+ return true
+ }
+
+ reader := bufio.NewReader(os.Stdin)
+
+ for {
+ msg := fmt.Sprintf(format, args...)
+ fmt.Print(msg)
+
+ response, err := reader.ReadString('\n')
+ if err != nil {
+ log.Fatal(err)
+ }
+
+ response = strings.ToLower(strings.TrimSpace(response))
+
+ if response == "y" || response == "yes" {
+ return true
+ } else if response == "n" || response == "no" {
+ return false
+ }
+ }
+}
diff --git a/internal/pkg/commands/completion.go b/internal/pkg/commands/completion.go
new file mode 100644
index 0000000..74e7cfa
--- /dev/null
+++ b/internal/pkg/commands/completion.go
@@ -0,0 +1,37 @@
+/*
+ * 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 (
+ "fmt"
+ flags "github.com/jessevdk/go-flags"
+ "github.com/opencord/cordctl/internal/pkg/completion"
+)
+
+type BashOptions struct{}
+
+type CompletionOptions struct {
+ BashOptions `command:"bash"`
+}
+
+func RegisterCompletionCommands(parent *flags.Parser) {
+ parent.AddCommand("completion", "generate shell compleition", "Commands to generate shell completion information", &CompletionOptions{})
+}
+
+func (options *BashOptions) Execute(args []string) error {
+ fmt.Fprintln(OutputStream, completion.Bash)
+ return nil
+}
diff --git a/internal/pkg/commands/completion_test.go b/internal/pkg/commands/completion_test.go
new file mode 100644
index 0000000..b7b25c8
--- /dev/null
+++ b/internal/pkg/commands/completion_test.go
@@ -0,0 +1,39 @@
+/*
+ * Portions copyright 2019-present Open Networking Foundation
+ * Original 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 (
+ "bytes"
+ "github.com/opencord/cordctl/internal/pkg/completion"
+ "github.com/opencord/cordctl/pkg/testutils"
+ "testing"
+)
+
+func TestCompletion(t *testing.T) {
+ got := new(bytes.Buffer)
+ OutputStream = got
+
+ var options CompletionOptions
+ err := options.Execute([]string{})
+
+ if err != nil {
+ t.Errorf("%s: Received error %v", t.Name(), err)
+ return
+ }
+
+ testutils.AssertStringEqual(t, got.String(), completion.Bash+"\n")
+}
diff --git a/internal/pkg/commands/config.go b/internal/pkg/commands/config.go
new file mode 100644
index 0000000..81875a5
--- /dev/null
+++ b/internal/pkg/commands/config.go
@@ -0,0 +1,60 @@
+/*
+ * Portions copyright 2019-present Open Networking Foundation
+ * Original 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 (
+ "fmt"
+ flags "github.com/jessevdk/go-flags"
+ "gopkg.in/yaml.v2"
+)
+
+const copyrightNotice = `
+# Portions copyright 2019-present Open Networking Foundation
+# Original 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.
+#
+`
+
+type ConfigOptions struct {
+}
+
+func RegisterConfigCommands(parent *flags.Parser) {
+ parent.AddCommand("config", "generate cordctl configuration", "Commands to generate cordctl configuration", &ConfigOptions{})
+}
+
+func (options *ConfigOptions) Execute(args []string) error {
+ //GlobalConfig
+ ProcessGlobalOptions()
+ b, err := yaml.Marshal(GlobalConfig)
+ if err != nil {
+ return err
+ }
+ fmt.Println(copyrightNotice)
+ fmt.Println(string(b))
+ return nil
+}
diff --git a/internal/pkg/commands/funcmap.go b/internal/pkg/commands/funcmap.go
new file mode 100644
index 0000000..7a2bdf2
--- /dev/null
+++ b/internal/pkg/commands/funcmap.go
@@ -0,0 +1,80 @@
+/*
+ * Portions copyright 2019-present Open Networking Foundation
+ * Original 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 (
+ "fmt"
+ "github.com/fullstorydev/grpcurl"
+ "github.com/jhump/protoreflect/dynamic"
+ "github.com/jhump/protoreflect/grpcreflect"
+ "golang.org/x/net/context"
+ "google.golang.org/grpc"
+ reflectpb "google.golang.org/grpc/reflection/grpc_reflection_v1alpha"
+)
+
+type MethodNotFoundError struct {
+ Name string
+}
+
+func (e *MethodNotFoundError) Error() string {
+ return fmt.Sprintf("Method '%s' not found in function map", e.Name)
+}
+
+type MethodVersionNotFoundError struct {
+ Name string
+ Version string
+}
+
+func (e *MethodVersionNotFoundError) Error() string {
+ return fmt.Sprintf("Method '%s' does not have a verison for '%s' specfied in function map", e.Name, e.Version)
+}
+
+type DescriptorNotFoundError struct {
+ Version string
+}
+
+func (e *DescriptorNotFoundError) Error() string {
+ return fmt.Sprintf("Protocol buffer descriptor for API version '%s' not found", e.Version)
+}
+
+type UnableToParseDescriptorErrror struct {
+ err error
+ Version string
+}
+
+func (e *UnableToParseDescriptorErrror) Error() string {
+ return fmt.Sprintf("Unable to parse protocal buffer descriptor for version '%s': %s", e.Version, e.err)
+}
+
+func GetReflectionMethod(conn *grpc.ClientConn, name string) (grpcurl.DescriptorSource, string, error) {
+ refClient := grpcreflect.NewClient(context.Background(), reflectpb.NewServerReflectionClient(conn))
+ defer refClient.Reset()
+
+ desc := grpcurl.DescriptorSourceFromServer(context.Background(), refClient)
+
+ return desc, name, nil
+}
+
+func GetEnumValue(msg *dynamic.Message, name string) string {
+ return msg.FindFieldDescriptorByName(name).GetEnumType().
+ FindValueByNumber(msg.GetFieldByName(name).(int32)).GetName()
+}
+
+func SetEnumValue(msg *dynamic.Message, name string, value string) {
+ eValue := msg.FindFieldDescriptorByName(name).GetEnumType().FindValueByName(value)
+ msg.SetFieldByName(name, eValue.GetNumber())
+}
diff --git a/internal/pkg/commands/handler.go b/internal/pkg/commands/handler.go
new file mode 100644
index 0000000..678883d
--- /dev/null
+++ b/internal/pkg/commands/handler.go
@@ -0,0 +1,85 @@
+/*
+ * Portions copyright 2019-present Open Networking Foundation
+ * Original 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 (
+ "github.com/golang/protobuf/proto"
+ "github.com/jhump/protoreflect/desc"
+ "github.com/jhump/protoreflect/dynamic"
+ "google.golang.org/grpc/metadata"
+ "google.golang.org/grpc/status"
+ "io"
+)
+
+type RpcEventHandler struct {
+ Response proto.Message
+ Status *status.Status
+ Data []byte
+ Fields map[string]map[string]interface{}
+}
+
+func (h *RpcEventHandler) OnResolveMethod(*desc.MethodDescriptor) {
+}
+
+func (h *RpcEventHandler) OnSendHeaders(metadata.MD) {
+}
+
+func (h *RpcEventHandler) OnReceiveHeaders(metadata.MD) {
+}
+
+func (h *RpcEventHandler) OnReceiveResponse(m proto.Message) {
+ h.Response = m
+}
+
+func (h *RpcEventHandler) OnReceiveTrailers(s *status.Status, m metadata.MD) {
+ h.Status = s
+}
+
+func (h *RpcEventHandler) GetParams(msg proto.Message) error {
+ dmsg, err := dynamic.AsDynamicMessage(msg)
+ if err != nil {
+ return err
+ }
+
+ //fmt.Printf("MessageName: %s\n", dmsg.XXX_MessageName())
+
+ if h.Fields == nil || len(h.Fields) == 0 {
+ //fmt.Println("EOF")
+ return io.EOF
+ }
+
+ fields, ok := h.Fields[dmsg.XXX_MessageName()]
+ if !ok {
+ //fmt.Println("nil")
+ return nil
+ }
+
+ for k, v := range fields {
+ // _json is a special field name that indicates we should unmarshal json data
+ if k == "_json" {
+ err = dmsg.UnmarshalMergeJSON(v.([]byte))
+ if err != nil {
+ return err
+ }
+ } else {
+ dmsg.SetFieldByName(k, v)
+ }
+ }
+ delete(h.Fields, dmsg.XXX_MessageName())
+
+ return nil
+}
diff --git a/internal/pkg/commands/models.go b/internal/pkg/commands/models.go
new file mode 100644
index 0000000..c39d1df
--- /dev/null
+++ b/internal/pkg/commands/models.go
@@ -0,0 +1,609 @@
+/*
+ * Portions copyright 2019-present Open Networking Foundation
+ * Original 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 (
+ "context"
+ "fmt"
+ "github.com/fullstorydev/grpcurl"
+ flags "github.com/jessevdk/go-flags"
+ "github.com/jhump/protoreflect/dynamic"
+ corderrors "github.com/opencord/cordctl/internal/pkg/error"
+ "google.golang.org/grpc"
+ "sort"
+ "strings"
+ "time"
+)
+
+const (
+ DEFAULT_CREATE_FORMAT = "table{{ .Id }}\t{{ .Message }}"
+ DEFAULT_DELETE_FORMAT = "table{{ .Id }}\t{{ .Message }}"
+ DEFAULT_UPDATE_FORMAT = "table{{ .Id }}\t{{ .Message }}"
+ DEFAULT_SYNC_FORMAT = "table{{ .Id }}\t{{ .Message }}"
+)
+
+type ModelNameString string
+
+type ModelList struct {
+ ListOutputOptions
+ ShowHidden bool `long:"showhidden" description:"Show hidden fields in default output"`
+ ShowFeedback bool `long:"showfeedback" description:"Show feedback fields in default output"`
+ ShowBookkeeping bool `long:"showbookkeeping" description:"Show bookkeeping fields in default output"`
+ Filter string `short:"f" long:"filter" description:"Comma-separated list of filters"`
+ State string `short:"s" long:"state" description:"Filter model state [DEFAULT | ALL | DIRTY | DELETED | DIRTYPOL | DELETEDPOL]"`
+ Args struct {
+ ModelName ModelNameString
+ } `positional-args:"yes" required:"yes"`
+}
+
+type ModelUpdate struct {
+ OutputOptions
+ Unbuffered bool `short:"u" long:"unbuffered" description:"Do not buffer console output and suppress default output processor"`
+ Filter string `short:"f" long:"filter" description:"Comma-separated list of filters"`
+ SetFields string `long:"set-field" description:"Comma-separated list of field=value to set"`
+ SetJSON string `long:"set-json" description:"JSON dictionary to use for settings fields"`
+ Sync bool `long:"sync" description:"Synchronize before returning"`
+ SyncTimeout time.Duration `long:"synctimeout" default:"600s" description:"Timeout for --sync option"`
+ Args struct {
+ ModelName ModelNameString
+ } `positional-args:"yes" required:"yes"`
+ IDArgs struct {
+ ID []int32
+ } `positional-args:"yes" required:"no"`
+}
+
+type ModelDelete struct {
+ OutputOptions
+ Unbuffered bool `short:"u" long:"unbuffered" description:"Do not buffer console output and suppress default output processor"`
+ Filter string `short:"f" long:"filter" description:"Comma-separated list of filters"`
+ All bool `short:"a" long:"all" description:"Operate on all models"`
+ Args struct {
+ ModelName ModelNameString
+ } `positional-args:"yes" required:"yes"`
+ IDArgs struct {
+ ID []int32
+ } `positional-args:"yes" required:"no"`
+}
+
+type ModelCreate struct {
+ OutputOptions
+ Unbuffered bool `short:"u" long:"unbuffered" description:"Do not buffer console output"`
+ SetFields string `long:"set-field" description:"Comma-separated list of field=value to set"`
+ SetJSON string `long:"set-json" description:"JSON dictionary to use for settings fields"`
+ Sync bool `long:"sync" description:"Synchronize before returning"`
+ SyncTimeout time.Duration `long:"synctimeout" default:"600s" description:"Timeout for --sync option"`
+ Args struct {
+ ModelName ModelNameString
+ } `positional-args:"yes" required:"yes"`
+}
+
+type ModelSync struct {
+ OutputOptions
+ Unbuffered bool `short:"u" long:"unbuffered" description:"Do not buffer console output and suppress default output processor"`
+ Filter string `short:"f" long:"filter" description:"Comma-separated list of filters"`
+ SyncTimeout time.Duration `long:"synctimeout" default:"600s" description:"Timeout for synchronization"`
+ All bool `short:"a" long:"all" description:"Operate on all models"`
+ Args struct {
+ ModelName ModelNameString
+ } `positional-args:"yes" required:"yes"`
+ IDArgs struct {
+ ID []int32
+ } `positional-args:"yes" required:"no"`
+}
+
+type ModelSetDirty struct {
+ OutputOptions
+ Unbuffered bool `short:"u" long:"unbuffered" description:"Do not buffer console output and suppress default output processor"`
+ Filter string `short:"f" long:"filter" description:"Comma-separated list of filters"`
+ All bool `short:"a" long:"all" description:"Operate on all models"`
+ Args struct {
+ ModelName ModelNameString
+ } `positional-args:"yes" required:"yes"`
+ IDArgs struct {
+ ID []int32
+ } `positional-args:"yes" required:"no"`
+}
+
+type ModelOpts struct {
+ List ModelList `command:"list"`
+ Update ModelUpdate `command:"update"`
+ Delete ModelDelete `command:"delete"`
+ Create ModelCreate `command:"create"`
+ Sync ModelSync `command:"sync"`
+ SetDirty ModelSetDirty `command:"setdirty"`
+}
+
+type ModelStatusOutputRow struct {
+ Id interface{} `json:"id"`
+ Message string `json:"message"`
+}
+
+type ModelStatusOutput struct {
+ Rows []ModelStatusOutputRow
+ Unbuffered bool
+}
+
+var modelOpts = ModelOpts{}
+
+func RegisterModelCommands(parser *flags.Parser) {
+ parser.AddCommand("model", "model commands", "Commands to query and manipulate XOS models", &modelOpts)
+}
+
+// Initialize ModelStatusOutput structure, creating a row for each model that will be output
+func InitModelStatusOutput(unbuffered bool, count int) ModelStatusOutput {
+ return ModelStatusOutput{Rows: make([]ModelStatusOutputRow, count), Unbuffered: unbuffered}
+}
+
+// Update model status output row for the model
+// If unbuffered is set then we will output directly to the console. Regardless of the unbuffered
+// setting, we always update the row, as callers may check that row for status.
+// Args:
+// output - ModelStatusOutput struct to update
+// i - index of row to update
+// id - id of model, <nil> if no model exists
+// status - status text to set if there is no error
+// errror - if non-nil, then apply error text instead of status text
+// final - true if successful status should be reported, false if successful status is yet to come
+
+func UpdateModelStatusOutput(output *ModelStatusOutput, i int, id interface{}, status string, err error, final bool) {
+ if err != nil {
+ if output.Unbuffered {
+ fmt.Printf("%v: %s\n", id, err)
+ }
+ output.Rows[i] = ModelStatusOutputRow{Id: id, Message: err.Error()}
+ } else {
+ if output.Unbuffered && final {
+ fmt.Println(id)
+ }
+ output.Rows[i] = ModelStatusOutputRow{Id: id, Message: status}
+ }
+}
+
+// Convert a user-supplied state filter argument to the appropriate enum name
+func GetFilterKind(kindArg string) (string, error) {
+ kindMap := map[string]string{
+ "default": FILTER_DEFAULT,
+ "all": FILTER_ALL,
+ "dirty": FILTER_DIRTY,
+ "deleted": FILTER_DELETED,
+ "dirtypol": FILTER_DIRTYPOL,
+ "deletedpo": FILTER_DELETEDPOL,
+ }
+
+ // If no arg then use default
+ if kindArg == "" {
+ return kindMap["default"], nil
+ }
+
+ val, ok := kindMap[strings.ToLower(kindArg)]
+ if !ok {
+ return "", corderrors.WithStackTrace(&corderrors.UnknownModelStateError{Name: kindArg})
+ }
+
+ return val, nil
+}
+
+// Common processing for commands that take a modelname and a list of ids or a filter
+func GetIDList(conn *grpc.ClientConn, descriptor grpcurl.DescriptorSource, modelName string, ids []int32, filter string, all bool) ([]int32, error) {
+ err := CheckModelName(descriptor, modelName)
+ if err != nil {
+ return nil, err
+ }
+
+ // we require exactly one of ID, --filter, or --all
+ exclusiveCount := 0
+ if len(ids) > 0 {
+ exclusiveCount++
+ }
+ if filter != "" {
+ exclusiveCount++
+ }
+ if all {
+ exclusiveCount++
+ }
+
+ if (exclusiveCount == 0) || (exclusiveCount > 1) {
+ return nil, corderrors.WithStackTrace(&corderrors.FilterRequiredError{})
+ }
+
+ queries, err := CommaSeparatedQueryToMap(filter, true)
+ if err != nil {
+ return nil, err
+ }
+
+ if len(ids) > 0 {
+ // do nothing
+ } else {
+ models, err := ListOrFilterModels(context.Background(), conn, descriptor, modelName, FILTER_DEFAULT, queries)
+ if err != nil {
+ return nil, err
+ }
+ ids = make([]int32, len(models))
+ for i, model := range models {
+ ids[i] = model.GetFieldByName("id").(int32)
+ }
+ if len(ids) == 0 {
+ return nil, corderrors.WithStackTrace(&corderrors.NoMatchError{})
+ } else if len(ids) > 1 {
+ if !Confirmf("Filter matches %d objects. Continue [y/n] ? ", len(models)) {
+ return nil, corderrors.WithStackTrace(&corderrors.AbortedError{})
+ }
+ }
+ }
+
+ return ids, nil
+}
+
+func (options *ModelList) Execute(args []string) error {
+ conn, descriptor, err := InitClient(INIT_DEFAULT)
+ if err != nil {
+ return err
+ }
+
+ defer conn.Close()
+
+ err = CheckModelName(descriptor, string(options.Args.ModelName))
+ if err != nil {
+ return err
+ }
+
+ filterKind, err := GetFilterKind(options.State)
+ if err != nil {
+ return err
+ }
+
+ queries, err := CommaSeparatedQueryToMap(options.Filter, true)
+ if err != nil {
+ return err
+ }
+
+ models, err := ListOrFilterModels(context.Background(), conn, descriptor, string(options.Args.ModelName), filterKind, queries)
+ if err != nil {
+ return err
+ }
+
+ var field_names []string
+ data := make([]map[string]interface{}, len(models))
+ for i, val := range models {
+ data[i] = make(map[string]interface{})
+ for _, field_desc := range val.GetKnownFields() {
+ field_name := field_desc.GetName()
+
+ isGuiHidden := strings.Contains(field_desc.GetFieldOptions().String(), "1005:1")
+ isFeedback := strings.Contains(field_desc.GetFieldOptions().String(), "1006:1")
+ isBookkeeping := strings.Contains(field_desc.GetFieldOptions().String(), "1007:1")
+
+ if isGuiHidden && (!options.ShowHidden) {
+ continue
+ }
+
+ if isFeedback && (!options.ShowFeedback) {
+ continue
+ }
+
+ if isBookkeeping && (!options.ShowBookkeeping) {
+ continue
+ }
+
+ if field_desc.IsRepeated() {
+ continue
+ }
+
+ data[i][field_name] = val.GetFieldByName(field_name)
+
+ // Every row has the same set of known field names, so it suffices to use the names
+ // from the first row.
+ if i == 0 {
+ field_names = append(field_names, field_name)
+ }
+ }
+ }
+
+ // Sort field names, making sure "id" appears first
+ sort.SliceStable(field_names, func(i, j int) bool {
+ if field_names[i] == "id" {
+ return true
+ } else if field_names[j] == "id" {
+ return false
+ } else {
+ return (field_names[i] < field_names[j])
+ }
+ })
+
+ var default_format strings.Builder
+ default_format.WriteString("table")
+ for i, field_name := range field_names {
+ if i == 0 {
+ fmt.Fprintf(&default_format, "{{ .%s }}", field_name)
+ } else {
+ fmt.Fprintf(&default_format, "\t{{ .%s }}", field_name)
+ }
+ }
+
+ FormatAndGenerateListOutput(&options.ListOutputOptions, default_format.String(), "{{.id}}", data)
+
+ return nil
+}
+
+func (options *ModelUpdate) Execute(args []string) error {
+ conn, descriptor, err := InitClient(INIT_DEFAULT)
+ if err != nil {
+ return err
+ }
+
+ defer conn.Close()
+
+ err = CheckModelName(descriptor, string(options.Args.ModelName))
+ if err != nil {
+ return err
+ }
+
+ if (len(options.IDArgs.ID) == 0 && len(options.Filter) == 0) ||
+ (len(options.IDArgs.ID) != 0 && len(options.Filter) != 0) {
+ return corderrors.WithStackTrace(&corderrors.FilterRequiredError{})
+ }
+
+ queries, err := CommaSeparatedQueryToMap(options.Filter, true)
+ if err != nil {
+ return err
+ }
+
+ updates, err := CommaSeparatedQueryToMap(options.SetFields, true)
+ if err != nil {
+ return err
+ }
+
+ modelName := string(options.Args.ModelName)
+
+ var models []*dynamic.Message
+
+ if len(options.IDArgs.ID) > 0 {
+ models = make([]*dynamic.Message, len(options.IDArgs.ID))
+ for i, id := range options.IDArgs.ID {
+ models[i], err = GetModel(context.Background(), conn, descriptor, modelName, id)
+ if err != nil {
+ return err
+ }
+ }
+ } else {
+ models, err = ListOrFilterModels(context.Background(), conn, descriptor, modelName, FILTER_DEFAULT, queries)
+ if err != nil {
+ return err
+ }
+ }
+
+ if len(models) == 0 {
+ return corderrors.WithStackTrace(&corderrors.NoMatchError{})
+ } else if len(models) > 1 {
+ if !Confirmf("Filter matches %d objects. Continue [y/n] ? ", len(models)) {
+ return corderrors.WithStackTrace(&corderrors.AbortedError{})
+ }
+ }
+
+ fields := make(map[string]interface{})
+
+ if len(options.SetJSON) > 0 {
+ fields["_json"] = []byte(options.SetJSON)
+ }
+
+ for fieldName, value := range updates {
+ value = value[1:]
+ proto_value, err := TypeConvert(descriptor, modelName, fieldName, value)
+ if err != nil {
+ return err
+ }
+ fields[fieldName] = proto_value
+ }
+
+ modelStatusOutput := InitModelStatusOutput(options.Unbuffered, len(models))
+ for i, model := range models {
+ id := model.GetFieldByName("id").(int32)
+ fields["id"] = id
+ err := UpdateModel(conn, descriptor, modelName, fields)
+
+ UpdateModelStatusOutput(&modelStatusOutput, i, id, "Updated", err, !options.Sync)
+ }
+
+ if options.Sync {
+ ctx, cancel := context.WithTimeout(context.Background(), options.SyncTimeout)
+ defer cancel()
+ for i, model := range models {
+ id := model.GetFieldByName("id").(int32)
+ if modelStatusOutput.Rows[i].Message == "Updated" {
+ conditional_printf(!options.Quiet, "Wait for sync: %d ", id)
+ conn, _, err = GetModelWithRetry(ctx, conn, descriptor, modelName, id, GM_UNTIL_ENACTED|Ternary_uint32(options.Quiet, GM_QUIET, 0))
+ conditional_printf(!options.Quiet, "\n")
+ UpdateModelStatusOutput(&modelStatusOutput, i, id, "Enacted", err, true)
+ }
+ }
+ }
+
+ if !options.Unbuffered {
+ FormatAndGenerateOutput(&options.OutputOptions, DEFAULT_UPDATE_FORMAT, DEFAULT_UPDATE_FORMAT, modelStatusOutput.Rows)
+ }
+
+ return nil
+}
+
+func (options *ModelDelete) Execute(args []string) error {
+ conn, descriptor, err := InitClient(INIT_DEFAULT)
+ if err != nil {
+ return err
+ }
+
+ defer conn.Close()
+
+ modelName := string(options.Args.ModelName)
+ ids, err := GetIDList(conn, descriptor, modelName, options.IDArgs.ID, options.Filter, options.All)
+ if err != nil {
+ return err
+ }
+
+ modelStatusOutput := InitModelStatusOutput(options.Unbuffered, len(ids))
+ for i, id := range ids {
+ err = DeleteModel(conn, descriptor, modelName, id)
+ UpdateModelStatusOutput(&modelStatusOutput, i, id, "Deleted", err, true)
+ }
+
+ if !options.Unbuffered {
+ FormatAndGenerateOutput(&options.OutputOptions, DEFAULT_DELETE_FORMAT, DEFAULT_DELETE_FORMAT, modelStatusOutput.Rows)
+ }
+
+ return nil
+}
+
+func (options *ModelCreate) Execute(args []string) error {
+ conn, descriptor, err := InitClient(INIT_DEFAULT)
+ if err != nil {
+ return err
+ }
+
+ defer conn.Close()
+
+ err = CheckModelName(descriptor, string(options.Args.ModelName))
+ if err != nil {
+ return err
+ }
+
+ updates, err := CommaSeparatedQueryToMap(options.SetFields, true)
+ if err != nil {
+ return err
+ }
+
+ modelName := string(options.Args.ModelName)
+
+ fields := make(map[string]interface{})
+
+ if len(options.SetJSON) > 0 {
+ fields["_json"] = []byte(options.SetJSON)
+ }
+
+ for fieldName, value := range updates {
+ value = value[1:]
+ proto_value, err := TypeConvert(descriptor, modelName, fieldName, value)
+ if err != nil {
+ return err
+ }
+ fields[fieldName] = proto_value
+ }
+
+ modelStatusOutput := InitModelStatusOutput(options.Unbuffered, 1)
+
+ err = CreateModel(conn, descriptor, modelName, fields)
+ UpdateModelStatusOutput(&modelStatusOutput, 0, fields["id"], "Created", err, !options.Sync)
+
+ if options.Sync {
+ ctx, cancel := context.WithTimeout(context.Background(), options.SyncTimeout)
+ defer cancel()
+ if modelStatusOutput.Rows[0].Message == "Created" {
+ id := fields["id"].(int32)
+ conditional_printf(!options.Quiet, "Wait for sync: %d ", id)
+ conn, _, err = GetModelWithRetry(ctx, conn, descriptor, modelName, id, GM_UNTIL_ENACTED|Ternary_uint32(options.Quiet, GM_QUIET, 0))
+ conditional_printf(!options.Quiet, "\n")
+ UpdateModelStatusOutput(&modelStatusOutput, 0, id, "Enacted", err, true)
+ }
+ }
+
+ if !options.Unbuffered {
+ FormatAndGenerateOutput(&options.OutputOptions, DEFAULT_CREATE_FORMAT, DEFAULT_CREATE_FORMAT, modelStatusOutput.Rows)
+ }
+
+ return nil
+}
+
+func (options *ModelSync) Execute(args []string) error {
+ conn, descriptor, err := InitClient(INIT_DEFAULT)
+ if err != nil {
+ return err
+ }
+
+ defer conn.Close()
+
+ modelName := string(options.Args.ModelName)
+ ids, err := GetIDList(conn, descriptor, modelName, options.IDArgs.ID, options.Filter, options.All)
+ if err != nil {
+ return err
+ }
+
+ ctx, cancel := context.WithTimeout(context.Background(), options.SyncTimeout)
+ defer cancel()
+
+ modelStatusOutput := InitModelStatusOutput(options.Unbuffered, len(ids))
+ for i, id := range ids {
+ conditional_printf(!options.Quiet, "Wait for sync: %d ", id)
+ conn, _, err = GetModelWithRetry(ctx, conn, descriptor, modelName, id, GM_UNTIL_ENACTED|Ternary_uint32(options.Quiet, GM_QUIET, 0))
+ conditional_printf(!options.Quiet, "\n")
+ UpdateModelStatusOutput(&modelStatusOutput, i, id, "Enacted", err, true)
+ }
+
+ if !options.Unbuffered {
+ FormatAndGenerateOutput(&options.OutputOptions, DEFAULT_SYNC_FORMAT, DEFAULT_SYNC_FORMAT, modelStatusOutput.Rows)
+ }
+
+ return nil
+}
+
+func (options *ModelSetDirty) Execute(args []string) error {
+ conn, descriptor, err := InitClient(INIT_DEFAULT)
+ if err != nil {
+ return err
+ }
+
+ defer conn.Close()
+
+ modelName := string(options.Args.ModelName)
+ ids, err := GetIDList(conn, descriptor, modelName, options.IDArgs.ID, options.Filter, options.All)
+ if err != nil {
+ return err
+ }
+
+ modelStatusOutput := InitModelStatusOutput(options.Unbuffered, len(ids))
+ for i, id := range ids {
+ updateMap := map[string]interface{}{"id": id}
+ err := UpdateModel(conn, descriptor, modelName, updateMap)
+ UpdateModelStatusOutput(&modelStatusOutput, i, id, "Dirtied", err, true)
+ }
+
+ if !options.Unbuffered {
+ FormatAndGenerateOutput(&options.OutputOptions, DEFAULT_SYNC_FORMAT, DEFAULT_SYNC_FORMAT, modelStatusOutput.Rows)
+ }
+
+ return nil
+}
+
+func (modelName *ModelNameString) Complete(match string) []flags.Completion {
+ conn, descriptor, err := InitClient(INIT_DEFAULT)
+ if err != nil {
+ return nil
+ }
+
+ defer conn.Close()
+
+ models, err := GetModelNames(descriptor)
+ if err != nil {
+ return nil
+ }
+
+ list := make([]flags.Completion, 0)
+ for k := range models {
+ if strings.HasPrefix(k, match) {
+ list = append(list, flags.Completion{Item: k})
+ }
+ }
+
+ return list
+}
diff --git a/internal/pkg/commands/models_test.go b/internal/pkg/commands/models_test.go
new file mode 100644
index 0000000..40e25ea
--- /dev/null
+++ b/internal/pkg/commands/models_test.go
@@ -0,0 +1,446 @@
+/*
+ * Portions copyright 2019-present Open Networking Foundation
+ * Original copyright 2019-present Ciena Corporation
+ *
+ * Licensed under the Apache License, Version 2.0 (the"github.com/stretchr/testify/assert" "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 (
+ "bytes"
+ corderrors "github.com/opencord/cordctl/internal/pkg/error"
+ "github.com/opencord/cordctl/pkg/testutils"
+ "github.com/stretchr/testify/assert"
+ "testing"
+ "time"
+)
+
+func TestModelList(t *testing.T) {
+ // use `python -m json.tool` to pretty-print json
+ expected := `[
+ {
+ "controller_kind": "",
+ "controller_replica_count": 0,
+ "creator_id": 0,
+ "default_flavor_id": 0,
+ "default_image_id": 0,
+ "default_isolation": "",
+ "default_node_id": 0,
+ "description": "",
+ "enabled": false,
+ "exposed_ports": "",
+ "id": 1,
+ "max_instances": 0,
+ "mount_data_sets": "",
+ "name": "mockslice1",
+ "network": "",
+ "principal_id": 0,
+ "service_id": 0,
+ "site_id": 1,
+ "trust_domain_id": 0
+ },
+ {
+ "controller_kind": "",
+ "controller_replica_count": 0,
+ "creator_id": 0,
+ "default_flavor_id": 0,
+ "default_image_id": 0,
+ "default_isolation": "",
+ "default_node_id": 0,
+ "description": "",
+ "enabled": false,
+ "exposed_ports": "",
+ "id": 2,
+ "max_instances": 0,
+ "mount_data_sets": "",
+ "name": "mockslice2",
+ "network": "",
+ "principal_id": 0,
+ "service_id": 0,
+ "site_id": 1,
+ "trust_domain_id": 0
+ }
+ ]`
+
+ got := new(bytes.Buffer)
+ OutputStream = got
+
+ var options ModelOpts
+ options.List.Args.ModelName = "Slice"
+ options.List.OutputAs = "json"
+ err := options.List.Execute([]string{})
+
+ if err != nil {
+ t.Errorf("%s: Received error %v", t.Name(), err)
+ return
+ }
+
+ testutils.AssertJSONEqual(t, got.String(), expected)
+}
+
+func TestModelListFilterID(t *testing.T) {
+ // use `python -m json.tool` to pretty-print json
+ expected := `[
+ {
+ "controller_kind": "",
+ "controller_replica_count": 0,
+ "creator_id": 0,
+ "default_flavor_id": 0,
+ "default_image_id": 0,
+ "default_isolation": "",
+ "default_node_id": 0,
+ "description": "",
+ "enabled": false,
+ "exposed_ports": "",
+ "id": 1,
+ "max_instances": 0,
+ "mount_data_sets": "",
+ "name": "mockslice1",
+ "network": "",
+ "principal_id": 0,
+ "service_id": 0,
+ "site_id": 1,
+ "trust_domain_id": 0
+ }
+ ]`
+
+ got := new(bytes.Buffer)
+ OutputStream = got
+
+ var options ModelOpts
+ options.List.Args.ModelName = "Slice"
+ options.List.OutputAs = "json"
+ options.List.Filter = "id=1"
+ err := options.List.Execute([]string{})
+
+ if err != nil {
+ t.Errorf("%s: Received error %v", t.Name(), err)
+ return
+ }
+
+ testutils.AssertJSONEqual(t, got.String(), expected)
+}
+
+func TestModelListFilterName(t *testing.T) {
+ // use `python -m json.tool` to pretty-print json
+ expected := `[
+ {
+ "controller_kind": "",
+ "controller_replica_count": 0,
+ "creator_id": 0,
+ "default_flavor_id": 0,
+ "default_image_id": 0,
+ "default_isolation": "",
+ "default_node_id": 0,
+ "description": "",
+ "enabled": false,
+ "exposed_ports": "",
+ "id": 2,
+ "max_instances": 0,
+ "mount_data_sets": "",
+ "name": "mockslice2",
+ "network": "",
+ "principal_id": 0,
+ "service_id": 0,
+ "site_id": 1,
+ "trust_domain_id": 0
+ }
+ ]`
+
+ got := new(bytes.Buffer)
+ OutputStream = got
+
+ var options ModelOpts
+ options.List.Args.ModelName = "Slice"
+ options.List.OutputAs = "json"
+ options.List.Filter = "name=mockslice2"
+ err := options.List.Execute([]string{})
+
+ if err != nil {
+ t.Errorf("%s: Received error %v", t.Name(), err)
+ return
+ }
+
+ testutils.AssertJSONEqual(t, got.String(), expected)
+}
+
+func TestModelListDirty(t *testing.T) {
+ // use `python -m json.tool` to pretty-print json
+ expected := `[
+ {
+ "controller_kind": "",
+ "controller_replica_count": 0,
+ "creator_id": 0,
+ "default_flavor_id": 0,
+ "default_image_id": 0,
+ "default_isolation": "",
+ "default_node_id": 0,
+ "description": "",
+ "enabled": false,
+ "exposed_ports": "",
+ "id": 2,
+ "max_instances": 0,
+ "mount_data_sets": "",
+ "name": "mockslice2",
+ "network": "",
+ "principal_id": 0,
+ "service_id": 0,
+ "site_id": 1,
+ "trust_domain_id": 0
+ }
+ ]`
+
+ got := new(bytes.Buffer)
+ OutputStream = got
+
+ var options ModelOpts
+ options.List.Args.ModelName = "Slice"
+ options.List.OutputAs = "json"
+ options.List.State = "dirty"
+ err := options.List.Execute([]string{})
+
+ if err != nil {
+ t.Errorf("%s: Received error %v", t.Name(), err)
+ return
+ }
+
+ testutils.AssertJSONEqual(t, got.String(), expected)
+}
+
+func TestModelUpdate(t *testing.T) {
+ expected := `[{"id":1, "message":"Updated"}]`
+
+ got := new(bytes.Buffer)
+ OutputStream = got
+
+ var options ModelOpts
+ options.Update.Args.ModelName = "Slice"
+ options.Update.OutputAs = "json"
+ options.Update.IDArgs.ID = []int32{1}
+ options.Update.SetFields = "name=mockslice1_newname"
+ err := options.Update.Execute([]string{})
+
+ if err != nil {
+ t.Errorf("%s: Received error %v", t.Name(), err)
+ return
+ }
+
+ testutils.AssertJSONEqual(t, got.String(), expected)
+}
+
+func TestModelUpdateUsingFilter(t *testing.T) {
+ expected := `[{"id":1, "message":"Updated"}]`
+
+ got := new(bytes.Buffer)
+ OutputStream = got
+
+ var options ModelOpts
+ options.Update.Args.ModelName = "Slice"
+ options.Update.OutputAs = "json"
+ options.Update.Filter = "id=1"
+ options.Update.SetFields = "name=mockslice1_newname"
+ err := options.Update.Execute([]string{})
+
+ if err != nil {
+ t.Errorf("%s: Received error %v", t.Name(), err)
+ return
+ }
+
+ testutils.AssertJSONEqual(t, got.String(), expected)
+}
+
+func TestModelUpdateNoExist(t *testing.T) {
+ got := new(bytes.Buffer)
+ OutputStream = got
+
+ var options ModelOpts
+ options.Update.Args.ModelName = "Slice"
+ options.Update.OutputAs = "json"
+ options.Update.IDArgs.ID = []int32{77}
+ options.Update.SetFields = "name=mockslice1_newname"
+ err := options.Update.Execute([]string{})
+
+ _, matched := err.(*corderrors.ModelNotFoundError)
+ assert.True(t, matched)
+}
+
+func TestModelUpdateUsingFilterNoExist(t *testing.T) {
+ got := new(bytes.Buffer)
+ OutputStream = got
+
+ var options ModelOpts
+ options.Update.Args.ModelName = "Slice"
+ options.Update.OutputAs = "json"
+ options.Update.Filter = "id=77"
+ options.Update.SetFields = "name=mockslice1_newname"
+ err := options.Update.Execute([]string{})
+
+ _, matched := err.(*corderrors.NoMatchError)
+ assert.True(t, matched)
+}
+
+func TestModelCreate(t *testing.T) {
+ expected := `[{"id":3, "message":"Created"}]`
+
+ got := new(bytes.Buffer)
+ OutputStream = got
+
+ var options ModelOpts
+ options.Create.Args.ModelName = "Slice"
+ options.Create.OutputAs = "json"
+ options.Create.SetFields = "name=mockslice3,site_id=1"
+ err := options.Create.Execute([]string{})
+
+ if err != nil {
+ t.Errorf("%s: Received error %v", t.Name(), err)
+ return
+ }
+
+ testutils.AssertJSONEqual(t, got.String(), expected)
+}
+
+func TestModelDelete(t *testing.T) {
+ expected := `[{"id":1, "message":"Deleted"}]`
+
+ got := new(bytes.Buffer)
+ OutputStream = got
+
+ var options ModelOpts
+ options.Delete.Args.ModelName = "Slice"
+ options.Delete.OutputAs = "json"
+ options.Delete.IDArgs.ID = []int32{1}
+ err := options.Delete.Execute([]string{})
+
+ if err != nil {
+ t.Errorf("%s: Received error %v", t.Name(), err)
+ return
+ }
+
+ testutils.AssertJSONEqual(t, got.String(), expected)
+}
+
+func TestModelDeleteUsingFilter(t *testing.T) {
+ expected := `[{"id":1, "message":"Deleted"}]`
+
+ got := new(bytes.Buffer)
+ OutputStream = got
+
+ var options ModelOpts
+ options.Delete.Args.ModelName = "Slice"
+ options.Delete.OutputAs = "json"
+ options.Delete.Filter = "id=1"
+ err := options.Delete.Execute([]string{})
+
+ if err != nil {
+ t.Errorf("%s: Received error %v", t.Name(), err)
+ return
+ }
+
+ testutils.AssertJSONEqual(t, got.String(), expected)
+}
+
+func TestModelDeleteNoExist(t *testing.T) {
+ expected := `[{"id":77, "message":"Not Found [on model Slice <id=77>]"}]`
+
+ got := new(bytes.Buffer)
+ OutputStream = got
+
+ var options ModelOpts
+ options.Delete.Args.ModelName = "Slice"
+ options.Delete.OutputAs = "json"
+ options.Delete.IDArgs.ID = []int32{77}
+ err := options.Delete.Execute([]string{})
+
+ if err != nil {
+ t.Errorf("%s: Received error %v", t.Name(), err)
+ return
+ }
+
+ testutils.AssertJSONEqual(t, got.String(), expected)
+}
+
+func TestModelDeleteFilterNoExist(t *testing.T) {
+ got := new(bytes.Buffer)
+ OutputStream = got
+
+ var options ModelOpts
+ options.Delete.Args.ModelName = "Slice"
+ options.Delete.OutputAs = "json"
+ options.Delete.Filter = "id=77"
+ err := options.Delete.Execute([]string{})
+
+ _, matched := err.(*corderrors.NoMatchError)
+ assert.True(t, matched)
+}
+
+func TestModelSync(t *testing.T) {
+ expected := `[{"id":1, "message":"Enacted"}]`
+
+ got := new(bytes.Buffer)
+ OutputStream = got
+
+ var options ModelOpts
+ options.Sync.Args.ModelName = "Slice"
+ options.Sync.OutputAs = "json"
+ options.Sync.IDArgs.ID = []int32{1}
+ options.Sync.SyncTimeout = 5 * time.Second
+ err := options.Sync.Execute([]string{})
+
+ if err != nil {
+ t.Errorf("%s: Received error %v", t.Name(), err)
+ return
+ }
+
+ testutils.AssertJSONEqual(t, got.String(), expected)
+}
+
+func TestModelSyncTimeout(t *testing.T) {
+ expected := `[{"id":2, "message":"context deadline exceeded"}]`
+
+ got := new(bytes.Buffer)
+ OutputStream = got
+
+ var options ModelOpts
+ options.Sync.Args.ModelName = "Slice"
+ options.Sync.OutputAs = "json"
+ options.Sync.IDArgs.ID = []int32{2}
+ options.Sync.SyncTimeout = 5 * time.Second
+ err := options.Sync.Execute([]string{})
+
+ if err != nil {
+ t.Errorf("%s: Received error %v", t.Name(), err)
+ return
+ }
+
+ testutils.AssertJSONEqual(t, got.String(), expected)
+}
+
+func TestModelSetDirty(t *testing.T) {
+ expected := `[{"id":1, "message":"Dirtied"}]`
+
+ got := new(bytes.Buffer)
+ OutputStream = got
+
+ var options ModelOpts
+ options.SetDirty.Args.ModelName = "Slice"
+ options.SetDirty.OutputAs = "json"
+ options.SetDirty.IDArgs.ID = []int32{1}
+ err := options.SetDirty.Execute([]string{})
+
+ if err != nil {
+ t.Errorf("%s: Received error %v", t.Name(), err)
+ return
+ }
+
+ testutils.AssertJSONEqual(t, got.String(), expected)
+}
diff --git a/internal/pkg/commands/modeltypes.go b/internal/pkg/commands/modeltypes.go
new file mode 100644
index 0000000..e24a57a
--- /dev/null
+++ b/internal/pkg/commands/modeltypes.go
@@ -0,0 +1,69 @@
+/*
+ * Portions copyright 2019-present Open Networking Foundation
+ * Original 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 (
+ flags "github.com/jessevdk/go-flags"
+ "sort"
+)
+
+const (
+ DEFAULT_MODELTYPE_LIST_FORMAT = "{{ . }}"
+)
+
+type ModelTypeList struct {
+ OutputOptions
+}
+
+type ModelTypeOpts struct {
+ List ModelTypeList `command:"list"`
+}
+
+var modelTypeOpts = ModelTypeOpts{}
+
+func RegisterModelTypeCommands(parser *flags.Parser) {
+ parser.AddCommand("modeltype", "model type commands", "Commands to query the types of models", &modelTypeOpts)
+}
+
+func (options *ModelTypeList) Execute(args []string) error {
+ conn, descriptor, err := InitClient(INIT_DEFAULT)
+ if err != nil {
+ return err
+ }
+
+ defer conn.Close()
+
+ models, err := GetModelNames(descriptor)
+ if err != nil {
+ return err
+ }
+
+ model_names := []string{}
+ for k := range models {
+ model_names = append(model_names, k)
+ }
+
+ sort.Strings(model_names)
+
+ FormatAndGenerateOutput(
+ &options.OutputOptions,
+ DEFAULT_MODELTYPE_LIST_FORMAT,
+ DEFAULT_MODELTYPE_LIST_FORMAT,
+ model_names)
+
+ return nil
+}
diff --git a/internal/pkg/commands/modeltypes_test.go b/internal/pkg/commands/modeltypes_test.go
new file mode 100644
index 0000000..e18c2db
--- /dev/null
+++ b/internal/pkg/commands/modeltypes_test.go
@@ -0,0 +1,111 @@
+/*
+ * Portions copyright 2019-present Open Networking Foundation
+ * Original 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 (
+ "bytes"
+ "github.com/opencord/cordctl/pkg/testutils"
+ "testing"
+)
+
+func TestModelTypeList(t *testing.T) {
+ // use `python -m json.tool` to pretty-print json
+ expected := `ANIPort
+AddressPool
+AttWorkflowDriverService
+AttWorkflowDriverServiceInstance
+AttWorkflowDriverWhiteListEntry
+BNGPortMapping
+BackupFile
+BackupOperation
+BandwidthProfile
+ComputeServiceInstance
+FabricCrossconnectService
+FabricCrossconnectServiceInstance
+FabricIpAddress
+FabricService
+Flavor
+Image
+InterfaceType
+KubernetesConfigMap
+KubernetesConfigVolumeMount
+KubernetesData
+KubernetesResourceInstance
+KubernetesSecret
+KubernetesSecretVolumeMount
+KubernetesService
+KubernetesServiceInstance
+NNIPort
+Network
+NetworkParameter
+NetworkParameterType
+NetworkSlice
+NetworkTemplate
+Node
+NodeLabel
+NodeToSwitchPort
+OLTDevice
+ONOSApp
+ONOSService
+ONUDevice
+PONPort
+Port
+PortBase
+PortInterface
+Principal
+Privilege
+RCORDIpAddress
+RCORDService
+RCORDSubscriber
+Role
+Service
+ServiceAttribute
+ServiceDependency
+ServiceGraphConstraint
+ServiceInstance
+ServiceInstanceAttribute
+ServiceInstanceLink
+ServiceInterface
+ServicePort
+Site
+Slice
+Switch
+SwitchPort
+Tag
+TechnologyProfile
+TrustDomain
+UNIPort
+User
+VOLTService
+VOLTServiceInstance
+XOSCore
+XOSGuiExtension
+`
+
+ got := new(bytes.Buffer)
+ OutputStream = got
+
+ var options ModelTypeOpts
+ err := options.List.Execute([]string{})
+
+ if err != nil {
+ t.Errorf("%s: Received error %v", t.Name(), err)
+ return
+ }
+
+ testutils.AssertStringEqual(t, got.String(), expected)
+}
diff --git a/internal/pkg/commands/orm.go b/internal/pkg/commands/orm.go
new file mode 100644
index 0000000..0ac846a
--- /dev/null
+++ b/internal/pkg/commands/orm.go
@@ -0,0 +1,658 @@
+/*
+ * Portions copyright 2019-present Open Networking Foundation
+ * Original 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 (
+ "context"
+ "fmt"
+ "github.com/fullstorydev/grpcurl"
+ "github.com/golang/protobuf/proto"
+ "github.com/golang/protobuf/protoc-gen-go/descriptor"
+ "github.com/jhump/protoreflect/desc"
+ "github.com/jhump/protoreflect/dynamic"
+ corderrors "github.com/opencord/cordctl/internal/pkg/error"
+ "google.golang.org/grpc"
+ "io"
+ "strconv"
+ "strings"
+ "time"
+)
+
+// Flags for calling the *WithRetry methods
+const (
+ GM_QUIET = 1
+ GM_UNTIL_FOUND = 2
+ GM_UNTIL_ENACTED = 4
+ GM_UNTIL_STATUS = 8
+)
+
+// Valid choices for FilterModels `Kind` argument
+const (
+ FILTER_DEFAULT = "DEFAULT"
+ FILTER_ALL = "ALL"
+ FILTER_DIRTY = "SYNCHRONIZER_DIRTY_OBJECTS"
+ FILTER_DELETED = "SYNCHRONIZER_DELETED_OBJECTS"
+ FILTER_DIRTYPOL = "SYNCHRONIZER_DIRTY_POLICIES"
+ FILTER_DELETEDPOL = "SYNCHRONIZER_DELETED_POLICIES"
+)
+
+type QueryEventHandler struct {
+ RpcEventHandler
+ Elements map[string]string
+ Model *desc.MessageDescriptor
+ Kind string
+ EOF bool
+}
+
+// Separate the operator from the query value.
+// For example,
+// "==foo" --> "EQUAL", "foo"
+func DecodeOperator(query string) (string, string, bool, error) {
+ if strings.HasPrefix(query, "!=") {
+ return strings.TrimSpace(query[2:]), "EQUAL", true, nil
+ } else if strings.HasPrefix(query, "==") {
+ return "", "", false, corderrors.NewInvalidInputError("Operator == is now allowed. Suggest using = instead.")
+ } else if strings.HasPrefix(query, "=") {
+ return strings.TrimSpace(query[1:]), "EQUAL", false, nil
+ } else if strings.HasPrefix(query, ">=") {
+ return strings.TrimSpace(query[2:]), "GREATER_THAN_OR_EQUAL", false, nil
+ } else if strings.HasPrefix(query, ">") {
+ return strings.TrimSpace(query[1:]), "GREATER_THAN", false, nil
+ } else if strings.HasPrefix(query, "<=") {
+ return strings.TrimSpace(query[2:]), "LESS_THAN_OR_EQUAL", false, nil
+ } else if strings.HasPrefix(query, "<") {
+ return strings.TrimSpace(query[1:]), "LESS_THAN", false, nil
+ } else {
+ return strings.TrimSpace(query), "EQUAL", false, nil
+ }
+}
+
+// Generate the parameters for Query messages.
+func (h *QueryEventHandler) GetParams(msg proto.Message) error {
+ dmsg, err := dynamic.AsDynamicMessage(msg)
+ if err != nil {
+ return err
+ }
+
+ //fmt.Printf("MessageName: %s\n", dmsg.XXX_MessageName())
+
+ if h.EOF {
+ return io.EOF
+ }
+
+ // Get the MessageType for the `elements` field
+ md := dmsg.GetMessageDescriptor()
+ elements_fld := md.FindFieldByName("elements")
+ elements_mt := elements_fld.GetMessageType()
+
+ for field_name, element := range h.Elements {
+ value, operator, invert, err := DecodeOperator(element)
+ if err != nil {
+ return err
+ }
+
+ nm := dynamic.NewMessage(elements_mt)
+
+ field_descriptor := h.Model.FindFieldByName(field_name)
+ if field_descriptor == nil {
+ return corderrors.WithStackTrace(&corderrors.FieldDoesNotExistError{ModelName: h.Model.GetName(), FieldName: field_name})
+ }
+
+ field_type := field_descriptor.GetType()
+ switch field_type {
+ case descriptor.FieldDescriptorProto_TYPE_INT32:
+ var i int64
+ i, err = strconv.ParseInt(value, 10, 32)
+ nm.SetFieldByName("iValue", int32(i))
+ case descriptor.FieldDescriptorProto_TYPE_UINT32:
+ var i int64
+ i, err = strconv.ParseInt(value, 10, 32)
+ nm.SetFieldByName("iValue", uint32(i))
+ case descriptor.FieldDescriptorProto_TYPE_FLOAT:
+ err = corderrors.NewInvalidInputError("Floating point filters are unsupported")
+ case descriptor.FieldDescriptorProto_TYPE_DOUBLE:
+ err = corderrors.NewInvalidInputError("Floating point filters are unsupported")
+ default:
+ nm.SetFieldByName("sValue", value)
+ err = nil
+ }
+
+ if err != nil {
+ return err
+ }
+
+ nm.SetFieldByName("name", field_name)
+ nm.SetFieldByName("invert", invert)
+ SetEnumValue(nm, "operator", operator)
+ dmsg.AddRepeatedFieldByName("elements", nm)
+ }
+
+ SetEnumValue(dmsg, "kind", h.Kind)
+
+ h.EOF = true
+
+ return nil
+}
+
+// Take a string list of queries and turns it into a map of queries
+func QueryStringsToMap(query_args []string, allow_inequality bool) (map[string]string, error) {
+ queries := make(map[string]string)
+ for _, query_str := range query_args {
+ query_str := strings.TrimSpace(query_str)
+ operator_pos := -1
+ for i, ch := range query_str {
+ if allow_inequality {
+ if (ch == '!') || (ch == '=') || (ch == '>') || (ch == '<') {
+ operator_pos = i
+ break
+ }
+ } else {
+ if ch == '=' {
+ operator_pos = i
+ break
+ }
+ }
+ }
+ if operator_pos == -1 {
+ return nil, corderrors.WithStackTrace(&corderrors.IllegalQueryError{Query: query_str})
+ }
+ queries[strings.TrimSpace(query_str[:operator_pos])] = query_str[operator_pos:]
+ }
+ return queries, nil
+}
+
+// Take a string of comma-separated queries and turn it into a map of queries
+func CommaSeparatedQueryToMap(query_str string, allow_inequality bool) (map[string]string, error) {
+ if query_str == "" {
+ return nil, nil
+ }
+
+ query_strings := strings.Split(query_str, ",")
+ return QueryStringsToMap(query_strings, allow_inequality)
+}
+
+// Convert a string into the appropriate gRPC type for a given field
+func TypeConvert(source grpcurl.DescriptorSource, modelName string, field_name string, v string) (interface{}, error) {
+ model_descriptor, err := source.FindSymbol("xos." + modelName)
+ if err != nil {
+ return nil, err
+ }
+ model_md, ok := model_descriptor.(*desc.MessageDescriptor)
+ if !ok {
+ return nil, corderrors.WithStackTrace(&corderrors.TypeConversionError{Source: modelName, Destination: "messageDescriptor"})
+ }
+ field_descriptor := model_md.FindFieldByName(field_name)
+ if field_descriptor == nil {
+ return nil, corderrors.WithStackTrace(&corderrors.FieldDoesNotExistError{ModelName: modelName, FieldName: field_name})
+ }
+ field_type := field_descriptor.GetType()
+
+ var result interface{}
+
+ switch field_type {
+ case descriptor.FieldDescriptorProto_TYPE_INT32:
+ var i int64
+ i, err = strconv.ParseInt(v, 10, 32)
+ result = int32(i)
+ case descriptor.FieldDescriptorProto_TYPE_UINT32:
+ var i int64
+ i, err = strconv.ParseInt(v, 10, 32)
+ result = uint32(i)
+ case descriptor.FieldDescriptorProto_TYPE_FLOAT:
+ var f float64
+ f, err = strconv.ParseFloat(v, 32)
+ result = float32(f)
+ case descriptor.FieldDescriptorProto_TYPE_DOUBLE:
+ var f float64
+ f, err = strconv.ParseFloat(v, 64)
+ result = f
+ default:
+ result = v
+ err = nil
+ }
+
+ return result, err
+}
+
+// Return a list of all available model names
+func GetModelNames(source grpcurl.DescriptorSource) (map[string]bool, error) {
+ models := make(map[string]bool)
+ methods, err := grpcurl.ListMethods(source, "xos.xos")
+
+ if err != nil {
+ return nil, err
+ }
+
+ for _, method := range methods {
+ if strings.HasPrefix(method, "xos.xos.Get") {
+ models[method[11:]] = true
+ }
+ }
+
+ return models, nil
+}
+
+// Check to see if a model name is valid
+func CheckModelName(source grpcurl.DescriptorSource, name string) error {
+ models, err := GetModelNames(source)
+ if err != nil {
+ return err
+ }
+ _, present := models[name]
+ if !present {
+ return corderrors.WithStackTrace(&corderrors.UnknownModelTypeError{Name: name})
+ }
+ return nil
+}
+
+// Create a model in XOS given a map of fields
+func CreateModel(conn *grpc.ClientConn, descriptor grpcurl.DescriptorSource, modelName string, fields map[string]interface{}) error {
+ ctx, cancel := context.WithTimeout(context.Background(), GlobalConfig.Grpc.Timeout)
+ defer cancel()
+
+ headers := GenerateHeaders()
+
+ h := &RpcEventHandler{
+ Fields: map[string]map[string]interface{}{"xos." + modelName: fields},
+ }
+ err := grpcurl.InvokeRPC(ctx, descriptor, conn, "xos.xos.Create"+modelName, headers, h, h.GetParams)
+ if err != nil {
+ return corderrors.RpcErrorWithModelNameToCordError(err, modelName)
+ } else if h.Status != nil && h.Status.Err() != nil {
+ return corderrors.RpcErrorWithModelNameToCordError(h.Status.Err(), modelName)
+ }
+
+ resp, err := dynamic.AsDynamicMessage(h.Response)
+ if err != nil {
+ return err
+ }
+
+ fields["id"] = resp.GetFieldByName("id").(int32)
+
+ if resp.HasFieldName("uuid") {
+ fields["uuid"] = resp.GetFieldByName("uuid").(string)
+ }
+
+ return nil
+}
+
+// Update a model in XOS given a map of fields
+func UpdateModel(conn *grpc.ClientConn, descriptor grpcurl.DescriptorSource, modelName string, fields map[string]interface{}) error {
+ ctx, cancel := context.WithTimeout(context.Background(), GlobalConfig.Grpc.Timeout)
+ defer cancel()
+
+ headers := GenerateHeaders()
+
+ h := &RpcEventHandler{
+ Fields: map[string]map[string]interface{}{"xos." + modelName: fields},
+ }
+ err := grpcurl.InvokeRPC(ctx, descriptor, conn, "xos.xos.Update"+modelName, headers, h, h.GetParams)
+ if err != nil {
+ return corderrors.RpcErrorWithModelNameToCordError(err, modelName)
+ } else if h.Status != nil && h.Status.Err() != nil {
+ return corderrors.RpcErrorWithModelNameToCordError(h.Status.Err(), modelName)
+ }
+
+ resp, err := dynamic.AsDynamicMessage(h.Response)
+ if err != nil {
+ return err
+ }
+
+ // TODO: Do we need to do anything with the response?
+ _ = resp
+
+ return nil
+}
+
+// Get a model from XOS given its ID
+func GetModel(ctx context.Context, conn *grpc.ClientConn, descriptor grpcurl.DescriptorSource, modelName string, id int32) (*dynamic.Message, error) {
+ ctx, cancel := context.WithTimeout(ctx, GlobalConfig.Grpc.Timeout)
+ defer cancel()
+
+ headers := GenerateHeaders()
+
+ h := &RpcEventHandler{
+ Fields: map[string]map[string]interface{}{"xos.ID": map[string]interface{}{"id": id}},
+ }
+ err := grpcurl.InvokeRPC(ctx, descriptor, conn, "xos.xos.Get"+modelName, headers, h, h.GetParams)
+ if err != nil {
+ return nil, corderrors.RpcErrorWithIdToCordError(err, modelName, id)
+ }
+
+ if h.Status != nil && h.Status.Err() != nil {
+ return nil, corderrors.RpcErrorWithIdToCordError(h.Status.Err(), modelName, id) //h.Status.Err()
+ }
+
+ d, err := dynamic.AsDynamicMessage(h.Response)
+ if err != nil {
+ return nil, err
+ }
+
+ return d, nil
+}
+
+// Get a model, but retry under a variety of circumstances
+func GetModelWithRetry(ctx context.Context, conn *grpc.ClientConn, descriptor grpcurl.DescriptorSource, modelName string, id int32, flags uint32) (*grpc.ClientConn, *dynamic.Message, error) {
+ quiet := (flags & GM_QUIET) != 0
+ until_found := (flags & GM_UNTIL_FOUND) != 0
+ until_enacted := (flags & GM_UNTIL_ENACTED) != 0
+ until_status := (flags & GM_UNTIL_STATUS) != 0
+
+ for {
+ var err error
+
+ if conn == nil {
+ conn, err = NewConnection()
+ if err != nil {
+ return nil, nil, err
+ }
+ }
+
+ model, err := GetModel(ctx, conn, descriptor, modelName, id)
+ if err != nil {
+ if strings.Contains(err.Error(), "rpc error: code = Unavailable") ||
+ strings.Contains(err.Error(), "rpc error: code = Internal desc = stream terminated by RST_STREAM") {
+ if !quiet {
+ fmt.Print(".")
+ }
+ select {
+ case <-time.After(100 * time.Millisecond):
+ case <-ctx.Done():
+ return nil, nil, ctx.Err()
+ }
+ conn.Close()
+ conn = nil
+ continue
+ }
+
+ _, is_not_found_error := err.(*corderrors.ModelNotFoundError)
+ if until_found && is_not_found_error {
+ if !quiet {
+ fmt.Print("x")
+ }
+ select {
+ case <-time.After(100 * time.Millisecond):
+ case <-ctx.Done():
+ return nil, nil, ctx.Err()
+ }
+ continue
+ }
+ return nil, nil, err
+ }
+
+ if until_enacted && !IsEnacted(model) {
+ if !quiet {
+ fmt.Print("o")
+ }
+ select {
+ case <-time.After(100 * time.Millisecond):
+ case <-ctx.Done():
+ return nil, nil, ctx.Err()
+ }
+ continue
+ }
+
+ if until_status && model.GetFieldByName("status") == nil {
+ if !quiet {
+ fmt.Print("O")
+ }
+ select {
+ case <-time.After(100 * time.Millisecond):
+ case <-ctx.Done():
+ return nil, nil, ctx.Err()
+ }
+ continue
+ }
+
+ return conn, model, nil
+ }
+}
+
+func ItemsToDynamicMessageList(items interface{}) []*dynamic.Message {
+ result := make([]*dynamic.Message, len(items.([]interface{})))
+ for i, item := range items.([]interface{}) {
+ result[i] = item.(*dynamic.Message)
+ }
+ return result
+}
+
+// List all objects of a given model
+func ListModels(ctx context.Context, conn *grpc.ClientConn, descriptor grpcurl.DescriptorSource, modelName string) ([]*dynamic.Message, error) {
+ ctx, cancel := context.WithTimeout(ctx, GlobalConfig.Grpc.Timeout)
+ defer cancel()
+
+ headers := GenerateHeaders()
+
+ h := &RpcEventHandler{}
+ err := grpcurl.InvokeRPC(ctx, descriptor, conn, "xos.xos.List"+modelName, headers, h, h.GetParams)
+ if err != nil {
+ return nil, corderrors.RpcErrorWithModelNameToCordError(err, modelName)
+ }
+
+ if h.Status != nil && h.Status.Err() != nil {
+ return nil, corderrors.RpcErrorWithModelNameToCordError(h.Status.Err(), modelName)
+ }
+
+ d, err := dynamic.AsDynamicMessage(h.Response)
+ if err != nil {
+ return nil, err
+ }
+
+ items, err := d.TryGetFieldByName("items")
+ if err != nil {
+ return nil, err
+ }
+
+ return ItemsToDynamicMessageList(items), nil
+}
+
+// Filter models based on field values
+// queries is a map of <field_name> to <operator><query>
+// For example,
+// map[string]string{"name": "==mysite"}
+func FilterModels(ctx context.Context, conn *grpc.ClientConn, descriptor grpcurl.DescriptorSource, modelName string, kind string, queries map[string]string) ([]*dynamic.Message, error) {
+ ctx, cancel := context.WithTimeout(ctx, GlobalConfig.Grpc.Timeout)
+ defer cancel()
+
+ headers := GenerateHeaders()
+
+ model_descriptor, err := descriptor.FindSymbol("xos." + modelName)
+ if err != nil {
+ return nil, err
+ }
+ model_md, ok := model_descriptor.(*desc.MessageDescriptor)
+ if !ok {
+ return nil, corderrors.WithStackTrace(&corderrors.TypeConversionError{Source: modelName, Destination: "messageDescriptor"})
+ }
+
+ h := &QueryEventHandler{
+ RpcEventHandler: RpcEventHandler{
+ Fields: map[string]map[string]interface{}{"xos.Query": map[string]interface{}{"kind": 0}},
+ },
+ Elements: queries,
+ Model: model_md,
+ Kind: kind,
+ }
+ err = grpcurl.InvokeRPC(ctx, descriptor, conn, "xos.xos.Filter"+modelName, headers, h, h.GetParams)
+ if err != nil {
+ return nil, corderrors.RpcErrorWithQueriesToCordError(err, modelName, queries)
+ }
+
+ if h.Status != nil && h.Status.Err() != nil {
+ return nil, corderrors.RpcErrorWithQueriesToCordError(h.Status.Err(), modelName, queries)
+ }
+
+ d, err := dynamic.AsDynamicMessage(h.Response)
+ if err != nil {
+ return nil, err
+ }
+
+ items, err := d.TryGetFieldByName("items")
+ if err != nil {
+ return nil, err
+ }
+
+ return ItemsToDynamicMessageList(items), nil
+}
+
+// Call ListModels or FilterModels as appropriate
+func ListOrFilterModels(ctx context.Context, conn *grpc.ClientConn, descriptor grpcurl.DescriptorSource, modelName string, kind string, queries map[string]string) ([]*dynamic.Message, error) {
+ if (len(queries) == 0) && (kind == FILTER_DEFAULT) {
+ return ListModels(ctx, conn, descriptor, modelName)
+ } else {
+ return FilterModels(ctx, conn, descriptor, modelName, kind, queries)
+ }
+}
+
+// Get a model from XOS given a fieldName/fieldValue
+func FindModel(ctx context.Context, conn *grpc.ClientConn, descriptor grpcurl.DescriptorSource, modelName string, queries map[string]string) (*dynamic.Message, error) {
+ models, err := FilterModels(ctx, conn, descriptor, modelName, FILTER_DEFAULT, queries)
+ if err != nil {
+ return nil, err
+ }
+
+ if len(models) == 0 {
+ cordError := &corderrors.ModelNotFoundError{}
+ cordError.Obj = corderrors.ObjectReference{ModelName: modelName, Queries: queries}
+ return nil, corderrors.WithStackTrace(cordError)
+ }
+
+ return models[0], nil
+}
+
+// Find a model, but retry under a variety of circumstances
+func FindModelWithRetry(ctx context.Context, conn *grpc.ClientConn, descriptor grpcurl.DescriptorSource, modelName string, queries map[string]string, flags uint32) (*grpc.ClientConn, *dynamic.Message, error) {
+ quiet := (flags & GM_QUIET) != 0
+ until_found := (flags & GM_UNTIL_FOUND) != 0
+ until_enacted := (flags & GM_UNTIL_ENACTED) != 0
+ until_status := (flags & GM_UNTIL_STATUS) != 0
+
+ for {
+ var err error
+
+ if conn == nil {
+ conn, err = NewConnection()
+ if err != nil {
+ return nil, nil, err
+ }
+ }
+
+ model, err := FindModel(ctx, conn, descriptor, modelName, queries)
+ if err != nil {
+ if strings.Contains(err.Error(), "rpc error: code = Unavailable") ||
+ strings.Contains(err.Error(), "rpc error: code = Internal desc = stream terminated by RST_STREAM") {
+ if !quiet {
+ fmt.Print(".")
+ }
+ select {
+ case <-time.After(100 * time.Millisecond):
+ case <-ctx.Done():
+ return nil, nil, ctx.Err()
+ }
+ conn.Close()
+ conn = nil
+ continue
+ }
+
+ _, is_not_found_error := err.(*corderrors.ModelNotFoundError)
+ if until_found && is_not_found_error {
+ if !quiet {
+ fmt.Print("x")
+ }
+ select {
+ case <-time.After(100 * time.Millisecond):
+ case <-ctx.Done():
+ return nil, nil, ctx.Err()
+ }
+ continue
+ }
+ return nil, nil, err
+ }
+
+ if until_enacted && !IsEnacted(model) {
+ if !quiet {
+ fmt.Print("o")
+ }
+ select {
+ case <-time.After(100 * time.Millisecond):
+ case <-ctx.Done():
+ return nil, nil, ctx.Err()
+ }
+ continue
+ }
+
+ if until_status && model.GetFieldByName("status") == nil {
+ if !quiet {
+ fmt.Print("O")
+ }
+ select {
+ case <-time.After(100 * time.Millisecond):
+ case <-ctx.Done():
+ return nil, nil, ctx.Err()
+ }
+ continue
+ }
+
+ return conn, model, nil
+ }
+}
+
+// Get a model from XOS given its ID
+func DeleteModel(conn *grpc.ClientConn, descriptor grpcurl.DescriptorSource, modelName string, id int32) error {
+ ctx, cancel := context.WithTimeout(context.Background(), GlobalConfig.Grpc.Timeout)
+ defer cancel()
+
+ headers := GenerateHeaders()
+
+ h := &RpcEventHandler{
+ Fields: map[string]map[string]interface{}{"xos.ID": map[string]interface{}{"id": id}},
+ }
+ err := grpcurl.InvokeRPC(ctx, descriptor, conn, "xos.xos.Delete"+modelName, headers, h, h.GetParams)
+ if err != nil {
+ return corderrors.RpcErrorWithIdToCordError(err, modelName, id)
+ }
+
+ if h.Status != nil && h.Status.Err() != nil {
+ return corderrors.RpcErrorWithIdToCordError(h.Status.Err(), modelName, id)
+ }
+
+ _, err = dynamic.AsDynamicMessage(h.Response)
+ if err != nil {
+ return err
+ }
+
+ return nil
+}
+
+// Takes a *dynamic.Message and turns it into a map of fields to interfaces
+// TODO: Might be more useful to convert the values to strings and ints
+func MessageToMap(d *dynamic.Message) map[string]interface{} {
+ fields := make(map[string]interface{})
+ for _, field_desc := range d.GetKnownFields() {
+ field_name := field_desc.GetName()
+ fields[field_name] = d.GetFieldByName(field_name)
+ }
+ return fields
+}
+
+// Returns True if a message has been enacted
+func IsEnacted(d *dynamic.Message) bool {
+ enacted := d.GetFieldByName("enacted").(float64)
+ updated := d.GetFieldByName("updated").(float64)
+
+ return (enacted >= updated)
+}
diff --git a/internal/pkg/commands/orm_test.go b/internal/pkg/commands/orm_test.go
new file mode 100644
index 0000000..2d8db62
--- /dev/null
+++ b/internal/pkg/commands/orm_test.go
@@ -0,0 +1,243 @@
+/*
+ * Portions copyright 2019-present Open Networking Foundation
+ * Original 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 (
+ "context"
+ corderrors "github.com/opencord/cordctl/internal/pkg/error"
+ "github.com/stretchr/testify/assert"
+ "testing"
+)
+
+func TestDecodeOperator(t *testing.T) {
+ value, operator, invert, err := DecodeOperator("=something")
+ assert.Equal(t, value, "something")
+ assert.Equal(t, operator, "EQUAL")
+ assert.Equal(t, invert, false)
+ assert.Equal(t, err, nil)
+
+ value, operator, invert, err = DecodeOperator("!=something")
+ assert.Equal(t, value, "something")
+ assert.Equal(t, operator, "EQUAL")
+ assert.Equal(t, invert, true)
+ assert.Equal(t, err, nil)
+
+ value, operator, invert, err = DecodeOperator(">3")
+ assert.Equal(t, value, "3")
+ assert.Equal(t, operator, "GREATER_THAN")
+ assert.Equal(t, invert, false)
+ assert.Equal(t, err, nil)
+
+ value, operator, invert, err = DecodeOperator(">=3")
+ assert.Equal(t, value, "3")
+ assert.Equal(t, operator, "GREATER_THAN_OR_EQUAL")
+ assert.Equal(t, invert, false)
+ assert.Equal(t, err, nil)
+
+ value, operator, invert, err = DecodeOperator("<3")
+ assert.Equal(t, value, "3")
+ assert.Equal(t, operator, "LESS_THAN")
+ assert.Equal(t, invert, false)
+ assert.Equal(t, err, nil)
+
+ value, operator, invert, err = DecodeOperator("<=3")
+ assert.Equal(t, value, "3")
+ assert.Equal(t, operator, "LESS_THAN_OR_EQUAL")
+ assert.Equal(t, invert, false)
+ assert.Equal(t, err, nil)
+}
+
+func TestCommaSeparatedQueryToMap(t *testing.T) {
+ m, err := CommaSeparatedQueryToMap("foo=7,bar!=stuff, x = 5, y= 27", true)
+ assert.Equal(t, err, nil)
+ assert.Equal(t, m["foo"], "=7")
+ assert.Equal(t, m["bar"], "!=stuff")
+ assert.Equal(t, m["x"], "= 5")
+ assert.Equal(t, m["y"], "= 27")
+}
+
+func TestCommaSeparatedQueryToMapIllegal(t *testing.T) {
+ // Query string missing operator
+ _, err := CommaSeparatedQueryToMap("foo", true)
+
+ _, matched := err.(*corderrors.IllegalQueryError)
+ assert.True(t, matched)
+
+ // Query string is contains an empty element
+ _, err = CommaSeparatedQueryToMap(",foo=bar", true)
+
+ _, matched = err.(*corderrors.IllegalQueryError)
+ assert.True(t, matched)
+}
+
+func TestCommaSeparatedQueryToMapEmpty(t *testing.T) {
+ // Query string missing operator
+ m, err := CommaSeparatedQueryToMap("", true)
+
+ assert.Equal(t, err, nil)
+ assert.Equal(t, len(m), 0)
+}
+
+func TestTypeConvert(t *testing.T) {
+ conn, descriptor, err := InitClient(INIT_DEFAULT)
+ assert.Equal(t, err, nil)
+ defer conn.Close()
+
+ v, err := TypeConvert(descriptor, "Site", "id", "7")
+ assert.Equal(t, err, nil)
+ assert.Equal(t, v, int32(7))
+
+ v, err = TypeConvert(descriptor, "Site", "name", "foo")
+ assert.Equal(t, err, nil)
+ assert.Equal(t, v, "foo")
+
+ v, err = TypeConvert(descriptor, "Site", "enacted", "123.4")
+ assert.Equal(t, err, nil)
+ assert.Equal(t, v, 123.4)
+}
+
+func TestCheckModelName(t *testing.T) {
+ conn, descriptor, err := InitClient(INIT_DEFAULT)
+ assert.Equal(t, err, nil)
+ defer conn.Close()
+
+ err = CheckModelName(descriptor, "Slice")
+ assert.Equal(t, err, nil)
+
+ err = CheckModelName(descriptor, "DoesNotExist")
+ _, matched := err.(*corderrors.UnknownModelTypeError)
+ assert.True(t, matched)
+}
+
+func TestCreateModel(t *testing.T) {
+ conn, descriptor, err := InitClient(INIT_DEFAULT)
+ assert.Equal(t, err, nil)
+ defer conn.Close()
+
+ m := make(map[string]interface{})
+ m["name"] = "mockslice3"
+ m["site_id"] = int32(1)
+
+ err = CreateModel(conn, descriptor, "Slice", m)
+ assert.Equal(t, err, nil)
+
+ assert.Equal(t, m["id"], int32(3))
+}
+
+func TestUpdateModel(t *testing.T) {
+ conn, descriptor, err := InitClient(INIT_DEFAULT)
+ assert.Equal(t, err, nil)
+ defer conn.Close()
+
+ m := make(map[string]interface{})
+ m["id"] = int32(1)
+ m["name"] = "mockslice1_newname"
+
+ err = UpdateModel(conn, descriptor, "Slice", m)
+ assert.Equal(t, err, nil)
+}
+
+func TestGetModel(t *testing.T) {
+ conn, descriptor, err := InitClient(INIT_DEFAULT)
+ assert.Equal(t, err, nil)
+ defer conn.Close()
+
+ m, err := GetModel(context.Background(), conn, descriptor, "Slice", int32(1))
+ assert.Equal(t, err, nil)
+
+ assert.Equal(t, m.GetFieldByName("id").(int32), int32(1))
+ assert.Equal(t, m.GetFieldByName("name").(string), "mockslice1")
+}
+
+func TestGetModelNoExist(t *testing.T) {
+ conn, descriptor, err := InitClient(INIT_DEFAULT)
+ assert.Equal(t, err, nil)
+ defer conn.Close()
+
+ _, err = GetModel(context.Background(), conn, descriptor, "Slice", int32(77))
+ assert.NotEqual(t, err, nil)
+
+ _, matched := err.(*corderrors.ModelNotFoundError)
+ assert.True(t, matched)
+}
+
+func TestListModels(t *testing.T) {
+ conn, descriptor, err := InitClient(INIT_DEFAULT)
+ assert.Equal(t, err, nil)
+ defer conn.Close()
+
+ m, err := ListModels(context.Background(), conn, descriptor, "Slice")
+ assert.Equal(t, err, nil)
+
+ assert.Equal(t, len(m), 2)
+ assert.Equal(t, m[0].GetFieldByName("id").(int32), int32(1))
+ assert.Equal(t, m[0].GetFieldByName("name").(string), "mockslice1")
+ assert.Equal(t, m[1].GetFieldByName("id").(int32), int32(2))
+ assert.Equal(t, m[1].GetFieldByName("name").(string), "mockslice2")
+}
+
+func TestFilterModels(t *testing.T) {
+ conn, descriptor, err := InitClient(INIT_DEFAULT)
+ assert.Equal(t, err, nil)
+ defer conn.Close()
+
+ qm := map[string]string{"id": "=1"}
+
+ m, err := FilterModels(context.Background(), conn, descriptor, "Slice", FILTER_DEFAULT, qm)
+ assert.Equal(t, err, nil)
+
+ assert.Equal(t, len(m), 1)
+ assert.Equal(t, m[0].GetFieldByName("id").(int32), int32(1))
+ assert.Equal(t, m[0].GetFieldByName("name").(string), "mockslice1")
+}
+
+func TestFindModel(t *testing.T) {
+ conn, descriptor, err := InitClient(INIT_DEFAULT)
+ assert.Equal(t, err, nil)
+ defer conn.Close()
+
+ qm := map[string]string{"id": "=1"}
+
+ m, err := FindModel(context.Background(), conn, descriptor, "Slice", qm)
+ assert.Equal(t, err, nil)
+
+ assert.Equal(t, m.GetFieldByName("id").(int32), int32(1))
+ assert.Equal(t, m.GetFieldByName("name").(string), "mockslice1")
+}
+
+func TestFindModelNoExist(t *testing.T) {
+ conn, descriptor, err := InitClient(INIT_DEFAULT)
+ assert.Equal(t, err, nil)
+ defer conn.Close()
+
+ qm := map[string]string{"id": "=77"}
+
+ _, err = FindModel(context.Background(), conn, descriptor, "Slice", qm)
+ assert.NotEqual(t, err, nil)
+
+ _, matched := err.(*corderrors.ModelNotFoundError)
+ assert.True(t, matched)
+}
+
+func TestDeleteModel(t *testing.T) {
+ conn, descriptor, err := InitClient(INIT_DEFAULT)
+ assert.Equal(t, err, nil)
+ defer conn.Close()
+
+ err = DeleteModel(conn, descriptor, "Slice", int32(1))
+ assert.Equal(t, err, nil)
+}
diff --git a/internal/pkg/commands/services.go b/internal/pkg/commands/services.go
new file mode 100644
index 0000000..d199ee9
--- /dev/null
+++ b/internal/pkg/commands/services.go
@@ -0,0 +1,95 @@
+/*
+ * Portions copyright 2019-present Open Networking Foundation
+ * Original 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 (
+ "context"
+ "github.com/fullstorydev/grpcurl"
+ flags "github.com/jessevdk/go-flags"
+ "github.com/jhump/protoreflect/dynamic"
+ corderrors "github.com/opencord/cordctl/internal/pkg/error"
+)
+
+const (
+ DEFAULT_SERVICE_FORMAT = "table{{ .Name }}\t{{.Version}}\t{{.State}}"
+)
+
+type ServiceList struct {
+ ListOutputOptions
+}
+
+type ServiceListOutput struct {
+ Name string `json:"name"`
+ Version string `json:"version"`
+ State string `json:"state"`
+}
+
+type ServiceOpts struct {
+ List ServiceList `command:"list"`
+}
+
+var serviceOpts = ServiceOpts{}
+
+func RegisterServiceCommands(parser *flags.Parser) {
+ parser.AddCommand("service", "service commands", "Commands to query and manipulate dynamically loaded XOS Services", &serviceOpts)
+}
+
+func (options *ServiceList) Execute(args []string) error {
+ conn, descriptor, err := InitClient(INIT_DEFAULT)
+ if err != nil {
+ return err
+ }
+ defer conn.Close()
+
+ ctx, cancel := context.WithTimeout(context.Background(), GlobalConfig.Grpc.Timeout)
+ defer cancel()
+
+ headers := GenerateHeaders()
+
+ h := &RpcEventHandler{}
+ err = grpcurl.InvokeRPC(ctx, descriptor, conn, "xos.dynamicload.GetLoadStatus", headers, h, h.GetParams)
+ if err != nil {
+ return corderrors.RpcErrorToCordError(err)
+ }
+
+ if h.Status != nil && h.Status.Err() != nil {
+ return corderrors.RpcErrorToCordError(h.Status.Err())
+ }
+
+ d, err := dynamic.AsDynamicMessage(h.Response)
+ if err != nil {
+ return err
+ }
+
+ items, err := d.TryGetFieldByName("services")
+ if err != nil {
+ return err
+ }
+
+ data := make([]ServiceListOutput, len(items.([]interface{})))
+
+ for i, item := range items.([]interface{}) {
+ val := item.(*dynamic.Message)
+ data[i].Name = val.GetFieldByName("name").(string)
+ data[i].Version = val.GetFieldByName("version").(string)
+ data[i].State = val.GetFieldByName("state").(string)
+ }
+
+ FormatAndGenerateListOutput(&options.ListOutputOptions, DEFAULT_SERVICE_FORMAT, "{{.Name}}", data)
+
+ return nil
+}
diff --git a/internal/pkg/commands/services_test.go b/internal/pkg/commands/services_test.go
new file mode 100644
index 0000000..721b6dc
--- /dev/null
+++ b/internal/pkg/commands/services_test.go
@@ -0,0 +1,101 @@
+/*
+ * Portions copyright 2019-present Open Networking Foundation
+ * Original 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 (
+ "bytes"
+ "github.com/opencord/cordctl/pkg/testutils"
+ "testing"
+)
+
+func TestServicesList(t *testing.T) {
+ // use `python -m json.tool` to pretty-print json
+ expected := `[
+ {
+ "name": "onos",
+ "state": "present",
+ "version": "2.1.1-dev"
+ },
+ {
+ "name": "kubernetes",
+ "state": "present",
+ "version": "1.2.1"
+ }
+ ]`
+
+ got := new(bytes.Buffer)
+ OutputStream = got
+
+ var options ServiceOpts
+ options.List.OutputAs = "json"
+ err := options.List.Execute([]string{})
+
+ if err != nil {
+ t.Errorf("%s: Received error %v", t.Name(), err)
+ return
+ }
+
+ testutils.AssertJSONEqual(t, got.String(), expected)
+}
+
+func TestServicesListTable(t *testing.T) {
+ // We'll use the ServicesList command to be our end-to-end test for
+ // table formatted commands, as the output is relatively simple.
+ expected := `NAME VERSION STATE
+onos 2.1.1-dev present
+kubernetes 1.2.1 present
+`
+
+ got := new(bytes.Buffer)
+ OutputStream = got
+
+ var options ServiceOpts
+ err := options.List.Execute([]string{})
+
+ if err != nil {
+ t.Errorf("%s: Received error %v", t.Name(), err)
+ return
+ }
+
+ testutils.AssertStringEqual(t, got.String(), expected)
+}
+
+func TestServicesListYaml(t *testing.T) {
+ // We'll use the ServicesList command to be our end-to-end test for
+ // yaml formatted commands, as the output is relatively simple.
+ expected := `- name: onos
+ version: 2.1.1-dev
+ state: present
+- name: kubernetes
+ version: 1.2.1
+ state: present
+`
+
+ got := new(bytes.Buffer)
+ OutputStream = got
+
+ var options ServiceOpts
+ options.List.OutputAs = "yaml"
+ err := options.List.Execute([]string{})
+
+ if err != nil {
+ t.Errorf("%s: Received error %v", t.Name(), err)
+ return
+ }
+
+ testutils.AssertStringEqual(t, got.String(), expected)
+}
diff --git a/internal/pkg/commands/setup_test.go b/internal/pkg/commands/setup_test.go
new file mode 100644
index 0000000..3b1dc7b
--- /dev/null
+++ b/internal/pkg/commands/setup_test.go
@@ -0,0 +1,35 @@
+/*
+ * Portions copyright 2019-present Open Networking Foundation
+ * Original 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 (
+ "fmt"
+ "github.com/opencord/cordctl/pkg/testutils"
+ "os"
+ "testing"
+)
+
+// This TestMain is global to all tests in the `commands` package
+
+func TestMain(m *testing.M) {
+ err := testutils.StartMockServer("data.json")
+ if err != nil {
+ fmt.Printf("Error when initializing mock server %v", err)
+ os.Exit(-1)
+ }
+ os.Exit(m.Run())
+}
diff --git a/internal/pkg/commands/status.go b/internal/pkg/commands/status.go
new file mode 100644
index 0000000..802720a
--- /dev/null
+++ b/internal/pkg/commands/status.go
@@ -0,0 +1,91 @@
+/*
+ * Portions copyright 2019-present Open Networking Foundation
+ * Original 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 (
+ "context"
+ "github.com/fullstorydev/grpcurl"
+ flags "github.com/jessevdk/go-flags"
+ "github.com/jhump/protoreflect/dynamic"
+ corderrors "github.com/opencord/cordctl/internal/pkg/error"
+ "strings"
+)
+
+const StatusListFormat = "table{{ .Component }}\t{{ .Name }}\t{{ .Version }}\t{{ .Connection }}\t{{ .Status }}"
+
+type StatusListOpts struct {
+ ListOutputOptions
+ Filter string `short:"f" long:"filter" description:"Comma-separated list of filters"`
+}
+
+type StatusOpts struct {
+ List StatusListOpts `command:"list"`
+}
+
+var statusOpts = StatusOpts{}
+
+func RegisterStatusCommands(parser *flags.Parser) {
+ parser.AddCommand("status", "status commands", "Commands to query status of various subsystems", &statusOpts)
+}
+
+func (options *StatusListOpts) Execute(args []string) error {
+ conn, descriptor, err := InitClient(INIT_DEFAULT)
+ if err != nil {
+ return err
+ }
+ defer conn.Close()
+
+ ctx, cancel := context.WithTimeout(context.Background(), GlobalConfig.Grpc.Timeout)
+ defer cancel()
+
+ headers := GenerateHeaders()
+
+ var components []map[string]string
+
+ // TODO(smbaker): Consider using David's client-side filtering can be used so we get filtering
+ // by other fields (status, etc) for free.
+
+ if options.Filter == "" || strings.Contains(strings.ToLower(options.Filter), "database") {
+ h := &RpcEventHandler{}
+ err = grpcurl.InvokeRPC(ctx, descriptor, conn, "xos.utility.GetDatabaseInfo", headers, h, h.GetParams)
+ if err != nil {
+ return corderrors.RpcErrorToCordError(err)
+ }
+
+ if h.Status != nil && h.Status.Err() != nil {
+ return corderrors.RpcErrorToCordError(h.Status.Err())
+ }
+
+ d, err := dynamic.AsDynamicMessage(h.Response)
+ if err != nil {
+ return err
+ }
+
+ db_map := make(map[string]string)
+ db_map["Component"] = "Database"
+ db_map["Name"] = d.GetFieldByName("name").(string)
+ db_map["Version"] = d.GetFieldByName("version").(string)
+ db_map["Connection"] = d.GetFieldByName("connection").(string)
+ db_map["Status"] = GetEnumValue(d, "status")
+
+ components = append(components, db_map)
+ }
+
+ FormatAndGenerateListOutput(&options.ListOutputOptions, StatusListFormat, StatusListFormat, components)
+
+ return nil
+}
diff --git a/internal/pkg/commands/status_test.go b/internal/pkg/commands/status_test.go
new file mode 100644
index 0000000..a7662fb
--- /dev/null
+++ b/internal/pkg/commands/status_test.go
@@ -0,0 +1,50 @@
+/*
+ * Portions copyright 2019-present Open Networking Foundation
+ * Original 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 (
+ "bytes"
+ "github.com/opencord/cordctl/pkg/testutils"
+ "testing"
+)
+
+func TestStatusList(t *testing.T) {
+ // use `python -m json.tool` to pretty-print json
+ expected := `[
+ {
+ "Component": "Database",
+ "Connection": "xos-db:5432",
+ "Name": "xos",
+ "Status": "OPERATIONAL",
+ "Version": "10.3"
+ }
+ ]`
+
+ got := new(bytes.Buffer)
+ OutputStream = got
+
+ var options StatusOpts
+ options.List.OutputAs = "json"
+ err := options.List.Execute([]string{})
+
+ if err != nil {
+ t.Errorf("%s: Received error %v", t.Name(), err)
+ return
+ }
+
+ testutils.AssertJSONEqual(t, got.String(), expected)
+}
diff --git a/internal/pkg/commands/transfer.go b/internal/pkg/commands/transfer.go
new file mode 100644
index 0000000..c8fa89d
--- /dev/null
+++ b/internal/pkg/commands/transfer.go
@@ -0,0 +1,143 @@
+/*
+ * Portions copyright 2019-present Open Networking Foundation
+ * Original 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 (
+ flags "github.com/jessevdk/go-flags"
+ corderrors "github.com/opencord/cordctl/internal/pkg/error"
+ "strings"
+)
+
+const (
+ DEFAULT_TRANSFER_FORMAT = "table{{ .Status }}\t{{ .Checksum }}\t{{ .Chunks }}\t{{ .Bytes }}"
+)
+
+type TransferOutput struct {
+ Status string `json:"status"`
+ Checksum string `json:"checksum"`
+ Chunks int `json:"chunks"`
+ Bytes int `json:"bytes"`
+}
+
+type TransferUpload struct {
+ OutputOptions
+ ChunkSize int `short:"h" long:"chunksize" default:"65536" description:"Host and port"`
+ Args struct {
+ LocalFileName string
+ URI string
+ } `positional-args:"yes" required:"yes"`
+}
+
+type TransferDownload struct {
+ OutputOptions
+ Args struct {
+ URI string
+ LocalFileName string
+ } `positional-args:"yes" required:"yes"`
+}
+
+type TransferOpts struct {
+ Upload TransferUpload `command:"upload"`
+ Download TransferDownload `command:"download"`
+}
+
+var transferOpts = TransferOpts{}
+
+func RegisterTransferCommands(parser *flags.Parser) {
+ parser.AddCommand("transfer", "file transfer commands", "Commands to transfer files to and from XOS", &transferOpts)
+}
+
+/* Command processors */
+
+func (options *TransferUpload) Execute(args []string) error {
+
+ conn, descriptor, err := InitClient(INIT_DEFAULT)
+ if err != nil {
+ return err
+ }
+ defer conn.Close()
+
+ local_name := options.Args.LocalFileName
+ uri := options.Args.URI
+
+ if IsFileUri(local_name) {
+ return corderrors.NewInvalidInputError("local_name argument should not be a uri")
+ }
+
+ if !IsFileUri(uri) {
+ return corderrors.NewInvalidInputError("uri argument should be a file:// uri")
+ }
+
+ h, upload_result, err := UploadFile(conn, descriptor, local_name, uri, options.ChunkSize)
+ if err != nil {
+ return err
+ }
+
+ if upload_result.GetFieldByName("checksum").(string) != h.GetChecksum() {
+ return corderrors.WithStackTrace(&corderrors.ChecksumMismatchError{
+ Expected: h.GetChecksum(),
+ Actual: upload_result.GetFieldByName("checksum").(string)})
+ }
+
+ data := make([]TransferOutput, 1)
+ data[0].Checksum = upload_result.GetFieldByName("checksum").(string)
+ data[0].Chunks = int(upload_result.GetFieldByName("chunks_received").(int32))
+ data[0].Bytes = int(upload_result.GetFieldByName("bytes_received").(int32))
+ data[0].Status = GetEnumValue(upload_result, "status")
+
+ FormatAndGenerateOutput(&options.OutputOptions, DEFAULT_TRANSFER_FORMAT, "{{.Status}}", data)
+
+ return nil
+}
+
+func IsFileUri(s string) bool {
+ return strings.HasPrefix(s, "file://")
+}
+
+func (options *TransferDownload) Execute(args []string) error {
+ conn, descriptor, err := InitClient(INIT_DEFAULT)
+ if err != nil {
+ return err
+ }
+ defer conn.Close()
+
+ local_name := options.Args.LocalFileName
+ uri := options.Args.URI
+
+ if IsFileUri(local_name) {
+ return corderrors.NewInvalidInputError("local_name argument should not be a uri")
+ }
+
+ if !IsFileUri(uri) {
+ return corderrors.NewInvalidInputError("uri argument should be a file:// uri")
+ }
+
+ h, err := DownloadFile(conn, descriptor, uri, local_name)
+ if err != nil {
+ return err
+ }
+
+ data := make([]TransferOutput, 1)
+ data[0].Chunks = h.chunks
+ data[0].Bytes = h.bytes
+ data[0].Status = h.status
+ data[0].Checksum = h.GetChecksum()
+
+ FormatAndGenerateOutput(&options.OutputOptions, DEFAULT_TRANSFER_FORMAT, "{{.Status}}", data)
+
+ return nil
+}
diff --git a/internal/pkg/commands/transfer_handler.go b/internal/pkg/commands/transfer_handler.go
new file mode 100644
index 0000000..cc18f1b
--- /dev/null
+++ b/internal/pkg/commands/transfer_handler.go
@@ -0,0 +1,167 @@
+/*
+ * Portions copyright 2019-present Open Networking Foundation
+ * Original 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 (
+ "context"
+ "crypto/sha256"
+ "fmt"
+ "github.com/fullstorydev/grpcurl"
+ "github.com/golang/protobuf/proto"
+ "github.com/jhump/protoreflect/dynamic"
+ "google.golang.org/grpc"
+ "hash"
+ "io"
+ "os"
+)
+
+/* Handlers for streaming upload and download */
+
+type DownloadHandler struct {
+ RpcEventHandler
+ f *os.File
+ chunks int
+ bytes int
+ status string
+ hash hash.Hash
+}
+
+type UploadHandler struct {
+ RpcEventHandler
+ chunksize int
+ f *os.File
+ uri string
+ hash hash.Hash
+}
+
+func (h *DownloadHandler) OnReceiveResponse(m proto.Message) {
+ d, err := dynamic.AsDynamicMessage(m)
+ if err != nil {
+ h.status = "ERROR"
+ // TODO(smbaker): How to raise an exception?
+ return
+ }
+ chunk := d.GetFieldByName("chunk").(string)
+ io.WriteString(h.hash, chunk)
+ h.f.Write([]byte(chunk))
+ h.chunks += 1
+ h.bytes += len(chunk)
+}
+
+func (h *DownloadHandler) GetChecksum() string {
+ return fmt.Sprintf("sha256:%x", h.hash.Sum(nil))
+}
+
+func (h *UploadHandler) GetParams(msg proto.Message) error {
+ dmsg, err := dynamic.AsDynamicMessage(msg)
+ if err != nil {
+ return err
+ }
+
+ //fmt.Printf("streamer, MessageName: %s\n", dmsg.XXX_MessageName())
+
+ block := make([]byte, h.chunksize)
+ bytes_read, err := h.f.Read(block)
+
+ if err == io.EOF {
+ h.f.Close()
+ //fmt.Print("EOF\n")
+ return err
+ }
+
+ if err != nil {
+ //fmt.Print("ERROR!\n")
+ return err
+ }
+
+ chunk := string(block[:bytes_read])
+ io.WriteString(h.hash, chunk)
+
+ dmsg.TrySetFieldByName("uri", h.uri)
+ dmsg.TrySetFieldByName("chunk", chunk)
+
+ return nil
+}
+
+func (h *UploadHandler) GetChecksum() string {
+ return fmt.Sprintf("sha256:%x", h.hash.Sum(nil))
+}
+
+func UploadFile(conn *grpc.ClientConn, descriptor grpcurl.DescriptorSource, local_name string, uri string, chunkSize int) (*UploadHandler, *dynamic.Message, error) {
+ ctx, cancel := context.WithTimeout(context.Background(), GlobalConfig.Grpc.Timeout)
+ defer cancel()
+
+ headers := GenerateHeaders()
+
+ f, err := os.Open(local_name)
+ if err != nil {
+ return nil, nil, err
+ }
+
+ h := &UploadHandler{uri: uri,
+ f: f,
+ chunksize: chunkSize,
+ hash: sha256.New()}
+
+ err = grpcurl.InvokeRPC(ctx, descriptor, conn, "xos.filetransfer/Upload", headers, h, h.GetParams)
+ if err != nil {
+ return nil, nil, err
+ }
+ if h.Status.Err() != nil {
+ return nil, nil, h.Status.Err()
+ }
+ d, err := dynamic.AsDynamicMessage(h.Response)
+ if err != nil {
+ return nil, nil, err
+ }
+
+ return h, d, err
+}
+
+func DownloadFile(conn *grpc.ClientConn, descriptor grpcurl.DescriptorSource, uri string, local_name string) (*DownloadHandler, error) {
+ ctx, cancel := context.WithTimeout(context.Background(), GlobalConfig.Grpc.Timeout)
+ defer cancel()
+
+ headers := GenerateHeaders()
+
+ f, err := os.Create(local_name)
+ if err != nil {
+ return nil, err
+ }
+
+ dm := make(map[string]interface{})
+ dm["uri"] = uri
+
+ h := &DownloadHandler{
+ RpcEventHandler: RpcEventHandler{
+ Fields: map[string]map[string]interface{}{"xos.FileRequest": dm},
+ },
+ f: f,
+ hash: sha256.New(),
+ status: "SUCCESS"}
+
+ err = grpcurl.InvokeRPC(ctx, descriptor, conn, "xos.filetransfer/Download", headers, h, h.GetParams)
+ if err != nil {
+ return nil, err
+ }
+
+ if h.Status.Err() != nil {
+ return nil, h.Status.Err()
+ }
+
+ return h, err
+}
diff --git a/internal/pkg/commands/transfer_test.go b/internal/pkg/commands/transfer_test.go
new file mode 100644
index 0000000..b6a4dd2
--- /dev/null
+++ b/internal/pkg/commands/transfer_test.go
@@ -0,0 +1,83 @@
+/*
+ * Portions copyright 2019-present Open Networking Foundation
+ * Original 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 (
+ "bytes"
+ "github.com/opencord/cordctl/pkg/testutils"
+ "io/ioutil"
+ "testing"
+)
+
+func TestDownload(t *testing.T) {
+ // use `python -m json.tool` to pretty-print json
+ expected := `[
+ {
+ "bytes": 6,
+ "checksum": "sha256:e9c0f8b575cbfcb42ab3b78ecc87efa3b011d9a5d10b09fa4e96f240bf6a82f5",
+ "chunks": 2,
+ "status": "SUCCESS"
+ }
+ ]`
+
+ got := new(bytes.Buffer)
+ OutputStream = got
+
+ var options TransferOpts
+ options.Download.OutputAs = "json"
+ options.Download.Args.LocalFileName = "/tmp/transfer.down"
+ options.Download.Args.URI = "file:///tmp/transfer.down"
+ err := options.Download.Execute([]string{})
+
+ if err != nil {
+ t.Errorf("%s: Received error %v", t.Name(), err)
+ return
+ }
+
+ testutils.AssertJSONEqual(t, got.String(), expected)
+}
+
+func TestUpload(t *testing.T) {
+ // use `python -m json.tool` to pretty-print json
+ expected := `[
+ {
+ "bytes": 6,
+ "checksum": "sha256:e9c0f8b575cbfcb42ab3b78ecc87efa3b011d9a5d10b09fa4e96f240bf6a82f5",
+ "chunks": 2,
+ "status": "SUCCESS"
+ }
+ ]`
+
+ err := ioutil.WriteFile("/tmp/transfer.up", []byte("ABCDEF"), 0644)
+
+ got := new(bytes.Buffer)
+ OutputStream = got
+
+ var options TransferOpts
+ options.Upload.OutputAs = "json"
+ options.Upload.Args.LocalFileName = "/tmp/transfer.up"
+ options.Upload.Args.URI = "file:///tmp/transfer.up"
+ options.Upload.ChunkSize = 3
+ err = options.Upload.Execute([]string{})
+
+ if err != nil {
+ t.Errorf("%s: Received error %v", t.Name(), err)
+ return
+ }
+
+ testutils.AssertJSONEqual(t, got.String(), expected)
+}
diff --git a/internal/pkg/commands/version.go b/internal/pkg/commands/version.go
new file mode 100644
index 0000000..c207d55
--- /dev/null
+++ b/internal/pkg/commands/version.go
@@ -0,0 +1,143 @@
+/*
+ * Portions copyright 2019-present Open Networking Foundation
+ * Original 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 (
+ flags "github.com/jessevdk/go-flags"
+ "github.com/opencord/cordctl/internal/pkg/cli/version"
+ "github.com/opencord/cordctl/pkg/format"
+)
+
+type VersionDetails struct {
+ Version string `json:"version"`
+ GoVersion string `json:"goversion"`
+ GitCommit string `json:"gitcommit"`
+ GitDirty string `json:"gitdirty"`
+ BuildTime string `json:"buildtime"`
+ Os string `json:"os"`
+ Arch string `json:"arch"`
+}
+
+type CoreVersionDetails struct {
+ Version string `json:"version"`
+ PythonVersion string `json:"goversion"`
+ GitCommit string `json:"gitcommit"`
+ BuildTime string `json:"buildtime"`
+ Os string `json:"os"`
+ Arch string `json:"arch"`
+ DjangoVersion string `json:"djangoversion"`
+}
+
+type VersionOutput struct {
+ Client VersionDetails `json:"client"`
+ Server CoreVersionDetails `json:"server"`
+}
+
+type VersionOpts struct {
+ OutputAs string `short:"o" long:"outputas" default:"table" choice:"table" choice:"json" choice:"yaml" description:"Type of output to generate"`
+ ClientOnly bool `short:"c" long:"client-only" description:"Print only client version"`
+}
+
+var versionOpts = VersionOpts{}
+
+var versionInfo = VersionOutput{
+ Client: VersionDetails{
+ Version: version.Version,
+ GoVersion: version.GoVersion,
+ GitCommit: version.GitCommit,
+ GitDirty: version.GitDirty,
+ Os: version.Os,
+ Arch: version.Arch,
+ BuildTime: version.BuildTime,
+ },
+ Server: CoreVersionDetails{
+ Version: "unknown",
+ PythonVersion: "unknown",
+ GitCommit: "unknown",
+ Os: "unknown",
+ Arch: "unknown",
+ BuildTime: "unknown",
+ DjangoVersion: "unknown",
+ },
+}
+
+func RegisterVersionCommands(parent *flags.Parser) {
+ parent.AddCommand("version", "display version", "Display client version", &versionOpts)
+}
+
+const ClientFormat = `Client:
+ Version {{.Client.Version}}
+ Go version: {{.Client.GoVersion}}
+ Git commit: {{.Client.GitCommit}}
+ Git dirty: {{.Client.GitDirty}}
+ Built: {{.Client.BuildTime}}
+ OS/Arch: {{.Client.Os}}/{{.Client.Arch}}
+`
+const ServerFormat = `
+Server:
+ Version {{.Server.Version}}
+ Python version: {{.Server.PythonVersion}}
+ Django version: {{.Server.DjangoVersion}}
+ Git commit: {{.Server.GitCommit}}
+ Built: {{.Server.BuildTime}}
+ OS/Arch: {{.Server.Os}}/{{.Server.Arch}}
+`
+
+const DefaultFormat = ClientFormat + ServerFormat
+
+func (options *VersionOpts) Execute(args []string) error {
+ if !options.ClientOnly {
+ conn, descriptor, err := InitClient(INIT_NO_VERSION_CHECK)
+ if err != nil {
+ return err
+ }
+ defer conn.Close()
+
+ d, err := GetVersion(conn, descriptor)
+ if err != nil {
+ return err
+ }
+
+ versionInfo.Server.Version = d.GetFieldByName("version").(string)
+ versionInfo.Server.PythonVersion = d.GetFieldByName("pythonVersion").(string)
+ versionInfo.Server.GitCommit = d.GetFieldByName("gitCommit").(string)
+ versionInfo.Server.BuildTime = d.GetFieldByName("buildTime").(string)
+ versionInfo.Server.Os = d.GetFieldByName("os").(string)
+ versionInfo.Server.Arch = d.GetFieldByName("arch").(string)
+
+ // djangoVersion was added to GetVersion() in xos-core 3.3.1-dev
+ djangoVersion, err := d.TryGetFieldByName("djangoVersion")
+ if err == nil {
+ versionInfo.Server.DjangoVersion = djangoVersion.(string)
+ }
+ }
+
+ result := CommandResult{
+ // Format: format.Format(DefaultFormat),
+ OutputAs: toOutputType(options.OutputAs),
+ Data: versionInfo,
+ }
+
+ if options.ClientOnly {
+ result.Format = format.Format(ClientFormat)
+ } else {
+ result.Format = format.Format(DefaultFormat)
+ }
+
+ GenerateOutput(&result)
+ return nil
+}
diff --git a/internal/pkg/commands/version_test.go b/internal/pkg/commands/version_test.go
new file mode 100644
index 0000000..e37b6d1
--- /dev/null
+++ b/internal/pkg/commands/version_test.go
@@ -0,0 +1,89 @@
+/*
+ * Portions copyright 2019-present Open Networking Foundation
+ * Original 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 (
+ "bytes"
+ "testing"
+)
+
+func TestVersionClientOnly(t *testing.T) {
+ expected := "" +
+ "Client:\n" +
+ " Version unknown-version\n" +
+ " Go version: unknown-goversion\n" +
+ " Git commit: unknown-gitcommit\n" +
+ " Git dirty: unknown-gitdirty\n" +
+ " Built: unknown-buildtime\n" +
+ " OS/Arch: unknown-os/unknown-arch\n" +
+ "\n"
+
+ got := new(bytes.Buffer)
+ OutputStream = got
+
+ var options VersionOpts
+ options.ClientOnly = true
+ err := options.Execute([]string{})
+
+ if err != nil {
+ t.Errorf("%s: Received error %v", t.Name(), err)
+ return
+ }
+
+ if got.String() != expected {
+ t.Logf("RECEIVED:\n%s\n", got.String())
+ t.Logf("EXPECTED:\n%s\n", expected)
+ t.Errorf("%s: expected and received did not match", t.Name())
+ }
+}
+
+func TestVersionClientAndServer(t *testing.T) {
+ expected := "" +
+ "Client:\n" +
+ " Version unknown-version\n" +
+ " Go version: unknown-goversion\n" +
+ " Git commit: unknown-gitcommit\n" +
+ " Git dirty: unknown-gitdirty\n" +
+ " Built: unknown-buildtime\n" +
+ " OS/Arch: unknown-os/unknown-arch\n" +
+ "\n" +
+ "Server:\n" +
+ " Version 3.2.6\n" +
+ " Python version: 2.7.16 (default, May 6 2019, 19:35:26)\n" +
+ " Django version: unknown\n" +
+ " Git commit: b0df1bf6ed1698285eda6a6725c5da0c80aa4aee\n" +
+ " Built: 2019-05-20T17:04:14Z\n" +
+ " OS/Arch: linux/x86_64\n" +
+ "\n"
+
+ got := new(bytes.Buffer)
+ OutputStream = got
+
+ var options VersionOpts
+ err := options.Execute([]string{})
+
+ if err != nil {
+ t.Errorf("%s: Received error %v", t.Name(), err)
+ return
+ }
+
+ if got.String() != expected {
+ t.Logf("RECEIVED:\n%s\n", got.String())
+ t.Logf("EXPECTED:\n%s\n", expected)
+ t.Errorf("%s: expected and received did not match", t.Name())
+ }
+}
diff --git a/internal/pkg/completion/bash.go b/internal/pkg/completion/bash.go
new file mode 100644
index 0000000..4feb2a2
--- /dev/null
+++ b/internal/pkg/completion/bash.go
@@ -0,0 +1,45 @@
+/*
+ * 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 completion
+
+const Bash = `
+# Portions copyright 2019-present Open Networking Foundation
+# Original 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.
+#
+_cordctl() {
+ # All arguments except the first one
+ args=("${COMP_WORDS[@]:1:$COMP_CWORD}")
+ # Only split on newlines
+ local IFS=$'\n'
+ # Call completion (note that the first element of COMP_WORDS is
+ # the executable itself)
+ COMPREPLY=($(GO_FLAGS_COMPLETION=1 ${COMP_WORDS[0]} "${args[@]}"))
+ return 0
+}
+complete -F _cordctl cordctl
+`
diff --git a/internal/pkg/error/error.go b/internal/pkg/error/error.go
new file mode 100644
index 0000000..e35a14a
--- /dev/null
+++ b/internal/pkg/error/error.go
@@ -0,0 +1,435 @@
+/*
+ * Portions copyright 2019-present Open Networking Foundation
+ * Original 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 error
+
+/* Cordctl error classes
+
+ The basic idea is to throw specific error classes, so it's easier to test for them rather than doing string
+ comparisons or other ad hoc mechanisms for determining the type of error. This decouples the human
+ readable text of an error from programmatic testing of error type.
+
+ We differentiate between errors that we want to generate brief output, such as for example a
+ user mistyping a model name, versus errors that we want to generate additional context. This prevents
+ overwhelming a user with voluminous output for a simple mistake. A command-line option may be provided
+ to force full error output should it be desired.
+
+ Additionally, an added benefit is ease of maintenance and localisation, by locating all error text
+ in one place.
+
+ To return an error, for example:
+
+ return WithStackTrace(&ChecksumMismatchError{Actual: "123", Expected: "456"})
+
+ To check to see if a specific error was returned, either of the following are acceptable:
+
+ _, ok := err.(*ChecksumMismatchError)
+ ...
+
+ switch err.(type) {
+ case *ChecksumMismatchError:
+ ...
+*/
+
+import (
+ "bytes"
+ "fmt"
+ go_errors "github.com/go-errors/errors"
+ "google.golang.org/grpc/status"
+ "runtime"
+ "strings"
+)
+
+const (
+ MaxStackDepth = 50
+)
+
+/* CordCtlError is the interface for errors created by cordctl.
+ * ShouldDumpStack()
+ * Returns false for well-understood problems such as invalid user input where a brief error message is sufficient
+ * Returns true for poorly-understood / unexpected problems where a full dump context may be useful
+ * Stack()
+ * Returns a string containing the stack trace where the error occurred
+ * Only useful if WithStackTrace() was called on the error
+ */
+
+type CordCtlError interface {
+ error
+ ShouldDumpStack() bool
+ Stack() string
+ AddStackTrace(skip int)
+}
+
+/* ObjectReference contains information about the object that the error applies to.
+ This may be empty (ModelName="") or it may contain a ModelName together with
+ option Id or Queries.
+*/
+
+type ObjectReference struct {
+ ModelName string
+ Id int32
+ Queries map[string]string
+}
+
+// Returns true if the reference is populated
+func (f *ObjectReference) IsValid() bool {
+ return (f.ModelName != "")
+}
+
+func (f *ObjectReference) String() string {
+ if !f.IsValid() {
+ // The reference is empty
+ return ""
+ }
+
+ if f.Queries != nil {
+ kv := make([]string, 0, len(f.Queries))
+ for k, v := range f.Queries {
+ kv = append(kv, fmt.Sprintf("%s%s", k, v))
+ }
+ return fmt.Sprintf("%s <%v>", f.ModelName, strings.Join(kv, ", "))
+ }
+
+ if f.Id > 0 {
+ return fmt.Sprintf("%s <id=%d>", f.ModelName, f.Id)
+ }
+
+ return fmt.Sprintf("%s", f.ModelName)
+}
+
+// Returns " on model ModelName [id]" if the reference is populated, or "" otherwise.
+func (f *ObjectReference) Clause() string {
+ if !f.IsValid() {
+ // The reference is empty
+ return ""
+ }
+
+ return fmt.Sprintf(" [on model %s]", f.String())
+}
+
+/* BaseError
+ *
+ * Supports attaching stack traces to errors
+ * Borrowed the technique from github.com/go-errors. Decided against using go-errors directly since it requires
+ * wrapping our error classes. Instead, incorporated the stack trace directly into our error class.
+ *
+ * Also supports encapsulating error messages, so that a CordError can encapsulate the error message from a
+ * function that was called.
+ */
+
+type BaseError struct {
+ Obj ObjectReference
+ Encapsulated error // in case this error encapsulates an error from a lower level
+ stack []uintptr // for stack trace
+ frames []go_errors.StackFrame // for stack trace
+}
+
+func (f *BaseError) AddStackTrace(skip int) {
+ stack := make([]uintptr, MaxStackDepth)
+ length := runtime.Callers(2+skip, stack[:])
+ f.stack = stack[:length]
+}
+
+func (f *BaseError) Stack() string {
+ buf := bytes.Buffer{}
+
+ for _, frame := range f.StackFrames() {
+ buf.WriteString(frame.String())
+ }
+
+ return string(buf.Bytes())
+}
+
+func (f *BaseError) StackFrames() []go_errors.StackFrame {
+ if f.frames == nil {
+ f.frames = make([]go_errors.StackFrame, len(f.stack))
+
+ for i, pc := range f.stack {
+ f.frames[i] = go_errors.NewStackFrame(pc)
+ }
+ }
+
+ return f.frames
+}
+
+// ***************************************************************************
+// UserError is composed into Errors that are due to user input
+
+type UserError struct {
+ BaseError
+}
+
+func (f UserError) ShouldDumpStack() bool {
+ return false
+}
+
+// **************************************************************************
+// TransferError is composed into Errors that are due to failures in transfers
+
+type TransferError struct {
+ BaseError
+}
+
+func (f TransferError) ShouldDumpStack() bool {
+ return false
+}
+
+// ***************************************************************************
+// UnexpectedError is things that we don't expect to happen. They should
+// generate maximum error context, to provide useful information for developer
+// diagnosis.
+
+type UnexpectedError struct {
+ BaseError
+}
+
+func (f UnexpectedError) ShouldDumpStack() bool {
+ return true
+}
+
+// ***************************************************************************
+// Specific error classes follow
+
+// Checksum mismatch when downloading or uploading a file
+type ChecksumMismatchError struct {
+ TransferError
+ Name string // (optional) Name of file
+ Expected string
+ Actual string
+}
+
+func (f ChecksumMismatchError) Error() string {
+ if f.Name != "" {
+ return fmt.Sprintf("%s: checksum mismatch (actual=%s, expected=%s)", f.Name, f.Expected, f.Actual)
+ } else {
+ return fmt.Sprintf("checksum mismatch (actual=%s, expected=%s)", f.Expected, f.Actual)
+ }
+}
+
+// User specified a model type that is not valid
+type UnknownModelTypeError struct {
+ UserError
+ Name string // Name of model
+}
+
+func (f UnknownModelTypeError) Error() string {
+ return fmt.Sprintf("Model %s does not exist. Use `cordctl modeltype list` to get a list of available models", f.Name)
+}
+
+// User specified a model state that is not valid
+type UnknownModelStateError struct {
+ UserError
+ Name string // Name of state
+}
+
+func (f UnknownModelStateError) Error() string {
+ return fmt.Sprintf("Model state %s does not exist", f.Name)
+}
+
+// Command requires a filter be specified
+type FilterRequiredError struct {
+ UserError
+}
+
+func (f FilterRequiredError) Error() string {
+ return "Filter required. Use either an ID, --filter, or --all to specify which models to operate on"
+}
+
+// Command was aborted by the user
+type AbortedError struct {
+ UserError
+}
+
+func (f AbortedError) Error() string {
+ return "Aborted"
+}
+
+// Command was aborted by the user
+type NoMatchError struct {
+ UserError
+}
+
+func (f NoMatchError) Error() string {
+ return "No Match"
+}
+
+// User specified a field name that is not valid
+type FieldDoesNotExistError struct {
+ UserError
+ ModelName string
+ FieldName string
+}
+
+func (f FieldDoesNotExistError) Error() string {
+ return fmt.Sprintf("Model %s does not have field %s", f.ModelName, f.FieldName)
+}
+
+// User specified a query string that is not properly formatted
+type IllegalQueryError struct {
+ UserError
+ Query string
+}
+
+func (f IllegalQueryError) Error() string {
+ return fmt.Sprintf("Illegal query string %s", f.Query)
+}
+
+// We failed to type convert something that we thought should have converted
+type TypeConversionError struct {
+ UnexpectedError
+ Source string
+ Destination string
+}
+
+func (f TypeConversionError) Error() string {
+ return fmt.Sprintf("Failed to type convert from %s to %s", f.Source, f.Destination)
+}
+
+// Version did not match a constraint
+type VersionConstraintError struct {
+ UserError
+ Name string
+ Version string
+ Constraint string
+}
+
+func (f VersionConstraintError) Error() string {
+ return fmt.Sprintf("%s version %s did not match constraint '%s'", f.Name, f.Version, f.Constraint)
+}
+
+// A model was not found
+type ModelNotFoundError struct {
+ UserError
+}
+
+func (f ModelNotFoundError) Error() string {
+ return fmt.Sprintf("Not Found%s", f.Obj.Clause())
+}
+
+// Permission Denied
+type PermissionDeniedError struct {
+ UserError
+}
+
+func (f PermissionDeniedError) Error() string {
+ return fmt.Sprintf("Permission Denied%s. Please verify username and password are correct", f.Obj.Clause())
+}
+
+// InvalidInputError is a catch-all for user mistakes that aren't covered elsewhere
+type InvalidInputError struct {
+ UserError
+ Message string
+}
+
+func (f InvalidInputError) Error() string {
+ return fmt.Sprintf("%s", f.Message)
+}
+
+func NewInvalidInputError(format string, params ...interface{}) *InvalidInputError {
+ msg := fmt.Sprintf(format, params...)
+ err := &InvalidInputError{Message: msg}
+ err.AddStackTrace(2)
+ return err
+}
+
+// InternalError is a catch-all for errors that don't fit somewhere else
+type InternalError struct {
+ UnexpectedError
+ Message string
+}
+
+func (f InternalError) Error() string {
+ return fmt.Sprintf("Internal Error%s: %s", f.Obj.Clause(), f.Message)
+}
+
+func NewInternalError(format string, params ...interface{}) *InternalError {
+ msg := fmt.Sprintf(format, params...)
+ err := &InternalError{Message: msg}
+ err.AddStackTrace(2)
+ return err
+}
+
+// ***************************************************************************
+// Global exported function declarations
+
+// Attach a stack trace to an error. The error passed in must be a pointer to an error structure for the
+// CordCtlError interface to match.
+func WithStackTrace(err CordCtlError) error {
+ err.AddStackTrace(2)
+ return err
+}
+
+/* RpcErrorWithObjToCordError
+ *
+ * Convert an RPC error into a Cord Error. The ObjectReference allows methods to attach
+ * object-related information to the error, and this varies by method. For example the Delete()
+ * method comes with an ModelName and an Id. The List() method has only a ModelName.
+ *
+ * Stubs (RpcErrorWithModelNameToCordError) are provided below to make common usage more convenient.
+ */
+
+func RpcErrorWithObjToCordError(err error, obj ObjectReference) error {
+ if err == nil {
+ return err
+ }
+
+ st, ok := status.FromError(err)
+ if ok {
+ switch st.Code().String() {
+ case "PermissionDenied":
+ cordErr := &PermissionDeniedError{}
+ cordErr.Obj = obj
+ cordErr.Encapsulated = err
+ cordErr.AddStackTrace(2)
+ return cordErr
+ case "NotFound":
+ cordErr := &ModelNotFoundError{}
+ cordErr.Obj = obj
+ cordErr.Encapsulated = err
+ cordErr.AddStackTrace(2)
+ return cordErr
+ case "Unknown":
+ msg := st.Message()
+ if strings.HasPrefix(msg, "Exception calling application: ") {
+ msg = msg[31:]
+ }
+ cordErr := &InternalError{Message: msg}
+ cordErr.Obj = obj
+ cordErr.Encapsulated = err
+ cordErr.AddStackTrace(2)
+ return cordErr
+ }
+ }
+
+ return err
+}
+
+func RpcErrorToCordError(err error) error {
+ return RpcErrorWithObjToCordError(err, ObjectReference{})
+}
+
+func RpcErrorWithModelNameToCordError(err error, modelName string) error {
+ return RpcErrorWithObjToCordError(err, ObjectReference{ModelName: modelName})
+}
+
+func RpcErrorWithIdToCordError(err error, modelName string, id int32) error {
+ return RpcErrorWithObjToCordError(err, ObjectReference{ModelName: modelName, Id: id})
+}
+
+func RpcErrorWithQueriesToCordError(err error, modelName string, queries map[string]string) error {
+ return RpcErrorWithObjToCordError(err, ObjectReference{ModelName: modelName, Queries: queries})
+}
diff --git a/internal/pkg/error/error_test.go b/internal/pkg/error/error_test.go
new file mode 100644
index 0000000..3030e0d
--- /dev/null
+++ b/internal/pkg/error/error_test.go
@@ -0,0 +1,149 @@
+/*
+ * Portions copyright 2019-present Open Networking Foundation
+ * Original 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 error
+
+import (
+ "fmt"
+ "github.com/stretchr/testify/assert"
+ "google.golang.org/grpc/codes"
+ "google.golang.org/grpc/status"
+ "testing"
+)
+
+func TestGenericError(t *testing.T) {
+ var err error
+
+ err = fmt.Errorf("Some error")
+
+ // Type conversion from `error` to ChecksumMismatchError should fail
+ _, ok := err.(ChecksumMismatchError)
+ assert.False(t, ok)
+
+ // Type conversion from `error` to CordCtlError should fail
+ _, ok = err.(CordCtlError)
+ assert.False(t, ok)
+}
+
+func TestChecksumMismatchError(t *testing.T) {
+ var err error
+
+ err = WithStackTrace(&ChecksumMismatchError{Actual: "123", Expected: "456"})
+
+ // Check that the Error() function returns the right text
+ assert.Equal(t, err.Error(), "checksum mismatch (actual=456, expected=123)")
+
+ // Type conversion from `error` to ChecksumMismatchError should succeed
+ _, ok := err.(*ChecksumMismatchError)
+ assert.True(t, ok)
+
+ // Type switch is another way of doing the same
+ switch err.(type) {
+ case *ChecksumMismatchError:
+ // do nothing
+ case CordCtlError:
+ assert.Fail(t, "Should have used the ChecksumMismatchError case instead")
+ default:
+ assert.Fail(t, "Wrong part of switch statement was called")
+ }
+
+ // Type conversion from `error` to CordCtlError should succeed
+ cce, ok := err.(CordCtlError)
+ assert.True(t, ok)
+
+ // ShouldDumpStack() returned from a ChecksumMismatchError should be false
+ assert.False(t, cce.ShouldDumpStack())
+}
+
+func TestUnknownModelTypeError(t *testing.T) {
+ var err error
+
+ err = WithStackTrace(&UnknownModelTypeError{Name: "foo"})
+
+ // Check that the Error() function returns the right text
+ assert.Equal(t, err.Error(), "Model foo does not exist. Use `cordctl modeltype list` to get a list of available models")
+}
+
+func TestRpcErrorToCordError(t *testing.T) {
+ // InternalError
+ err := status.Error(codes.Unknown, "A fake Unknown error")
+
+ cordErr := RpcErrorToCordError(err)
+
+ _, ok := cordErr.(*InternalError)
+ assert.True(t, ok)
+ assert.Equal(t, cordErr.Error(), "Internal Error: A fake Unknown error")
+
+ // NotFound
+ err = status.Error(codes.NotFound, "A fake not found error")
+
+ cordErr = RpcErrorToCordError(err)
+
+ _, ok = cordErr.(*ModelNotFoundError)
+ assert.True(t, ok)
+ assert.Equal(t, cordErr.Error(), "Not Found")
+
+ // PermissionDeniedError
+ err = status.Error(codes.PermissionDenied, "A fake Permission error")
+
+ cordErr = RpcErrorToCordError(err)
+
+ _, ok = cordErr.(*PermissionDeniedError)
+ assert.True(t, ok)
+ assert.Equal(t, cordErr.Error(), "Permission Denied. Please verify username and password are correct")
+}
+
+func TestRpcErrorWithModelNameToCordError(t *testing.T) {
+ // InternalError
+ err := status.Error(codes.Unknown, "A fake Unknown error")
+
+ cordErr := RpcErrorWithModelNameToCordError(err, "Foo")
+
+ _, ok := cordErr.(*InternalError)
+ assert.True(t, ok)
+ assert.Equal(t, cordErr.Error(), "Internal Error [on model Foo]: A fake Unknown error")
+}
+
+func TestRpcErrorWithIdToCordError(t *testing.T) {
+ // InternalError
+ err := status.Error(codes.Unknown, "A fake Unknown error")
+
+ cordErr := RpcErrorWithIdToCordError(err, "Foo", 7)
+
+ _, ok := cordErr.(*InternalError)
+ assert.True(t, ok)
+ assert.Equal(t, cordErr.Error(), "Internal Error [on model Foo <id=7>]: A fake Unknown error")
+}
+
+func TestRpcErrorWithQueriesToCordError(t *testing.T) {
+ // InternalError
+ err := status.Error(codes.Unknown, "A fake Unknown error")
+
+ cordErr := RpcErrorWithQueriesToCordError(err, "Foo", map[string]string{"id": "=3"})
+
+ _, ok := cordErr.(*InternalError)
+ assert.True(t, ok)
+ assert.Equal(t, cordErr.Error(), "Internal Error [on model Foo <id=3>]: A fake Unknown error")
+}
+
+func TestStackTrace(t *testing.T) {
+ var err error
+
+ err = WithStackTrace(&UnknownModelTypeError{Name: "foo"})
+
+ // goexit occurs near the end of the stack trace
+ assert.Contains(t, err.(CordCtlError).Stack(), "goexit")
+}