diff --git a/WORKSPACE b/WORKSPACE index 8691e41..f61cde9 100644 --- a/WORKSPACE +++ b/WORKSPACE @@ -146,6 +146,34 @@ gazelle_dependencies() # # Downloads are not cached in version control mode. +go_repository( + name = "com_github_urfave_cli_v2", + importpath = "github.com/urfave/cli/v2", + sum = "h1:VAzn5oq403l5pHjc4OhD54+XGO9cdKVL/7lDjF+iKUs=", + version = "v2.25.7", +) + +go_repository( + name = "com_github_xrash_smetrics", + importpath = "github.com/xrash/smetrics", + sum = "h1:bAn7/zixMGCfxrRTfdpNzjtPYqr8smhKouy9mxVdGPU=", + version = "v0.0.0-20201216005158-039620a65673", +) + +go_repository( + name = "com_github_cpuguy83_go_md2man_v2", + importpath = "github.com/cpuguy83/go-md2man/v2", + sum = "h1:p1EgwI/C7NhT0JmVkwCD2ZBK8j4aeHQX2pMHHBfMQ6w=", + version = "v2.0.2", +) + +go_repository( + name = "com_github_russross_blackfriday_v2", + importpath = "github.com/russross/blackfriday/v2", + sum = "h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=", + version = "v2.1.0", +) + go_repository( name = "com_github_cenkalti_backoff_v4", importpath = "github.com/cenkalti/backoff/v4", @@ -526,4 +554,4 @@ rules_proto_grpc_java_repos() rules_proto_grpc_python_repos() -py_repositories() \ No newline at end of file +py_repositories() diff --git a/api/nbi/v1alpha/nbi.proto b/api/nbi/v1alpha/nbi.proto index 2598609..21317e0 100644 --- a/api/nbi/v1alpha/nbi.proto +++ b/api/nbi/v1alpha/nbi.proto @@ -224,6 +224,16 @@ message ListEntitiesOverTimeRequest { // If set, only return entities with an ID in the list. repeated string ids = 5; + + // If set, only return entities that changed between the interval's start + // and end times. End may be before start to diff backwards in time. + // + // If multiple versions of an entity exist during the interval, the version + // that existed at the end time is returned (latest commit timestamp that's + // before or equal to the end time). If the entity doesn't exist or doesn't + // match the filter at the end time, but did exist or match at the start + // time, an entity with no value is returned. + optional bool diff = 6; } message ListEntitiesOverTimeResponse { diff --git a/tools/nbictl/BUILD b/tools/nbictl/BUILD index 91192ec..11689af 100644 --- a/tools/nbictl/BUILD +++ b/tools/nbictl/BUILD @@ -23,19 +23,26 @@ go_library( "connection.go", "generate_rsa_key.go", "nbictl.go", - "set_context.go", + "set_config.go", ], importpath = "aalyria.com/spacetime/github/tools/nbictl", visibility = ["//visibility:public"], deps = [ + "//api/common:common_go_proto", "//api/nbi/v1alpha:nbi_go_grpc", + "//api/nbi/v1alpha/resources:nbi_resources_go_grpc", "//cdpi_agent/internal/auth", "//tools/nbictl/proto:nbictl_go_proto", "@com_github_jonboulle_clockwork//:clockwork", + "@com_github_urfave_cli_v2//:cli", + "@go_googleapis//google/type:interval_go_proto", "@org_golang_google_grpc//:go_default_library", "@org_golang_google_grpc//credentials", "@org_golang_google_grpc//credentials/insecure", "@org_golang_google_protobuf//encoding/prototext", + "@org_golang_google_protobuf//proto", + "@org_golang_google_protobuf//types/known/durationpb", + "@org_golang_google_protobuf//types/known/timestamppb", ], ) @@ -45,7 +52,7 @@ go_test( "connection_test.go", "fake_nbi_server_test.go", "generate_rsa_key_test.go", - "set_context_test.go", + "set_config_test.go", ], embed = [":nbictl"], deps = [ diff --git a/tools/nbictl/README.md b/tools/nbictl/README.md index b9ba658..5b50215 100644 --- a/tools/nbictl/README.md +++ b/tools/nbictl/README.md @@ -1,96 +1,115 @@ -# nbictl -- NBI command-line tool + -`nbctl` allows you to interact with the Spacetime NBI APIs from the command-line. +# NAME -## Usage: +nbictl - Interact with the Spacetime NBI service from the command line. -```sh -$ nbictl [--operation-specific-flags] -``` - -### Configuration actions +# SYNOPSIS -#### generate-keys ``` -Usage of nbictl generate-key: - -country string - country of certificate - -dir string - directory where you want your RSA keys to be stored. Default: ~/.nbictl/ - -location string - location of certificate - -org string - [REQUIRED] organization of certificate - -state string - state of certificate +nbictl [--context=value] [--help] [-h] [COMMAND OPTIONS] [ARGUMENTS...] ``` -> **Note** -> After creating the Private-Public keypair, you will need to request API access by -> sharing the `.crt` file (a self-signed x509 certificate containing the public key) with -> Aalyria to receive the `USER_ID` and a `KEY_ID` needed to complete the `nbictl` configuration. +# GLOBAL OPTIONS -> **Warning** -> Only share the public certificate (`.crt`) with Aalyria or third-parties. -> The private key (`.key`) must be protected and should never be sent by email -> or communicated to others in any. +**--context**="": Context (configuration profile) to reference for connection settings. -#### set-context +**--help, -h**: show help -You can create multiple contexts by specifying a name of context using `--context` flag. -If context name is not specified, the context will have name `DEFAULT`. +# COMMANDS -``` -Usage of nbictl set-context: - -context string - context of NBI API environment (default "DEFAULT") - -key_id string - key id associated with the provate key provided by Aalyria - -priv_key string - path to your private key for authentication to NBI API - -transport_security string - transport security to use when connecting to NBI. Values: insecure, system_cert_pool - -url string - url of NBI endpoint - -user_id string - user id address associated with the private key provided by Aalyria -``` +## create -### NBI actions +Create one or more entities described in textproto files. + +**--files, -f**="": [REQUIRED] Glob of textproto files that represent one or more Entity messages. + +## update + +Updates one or more entities described in textproto files. + +**--files, -f**="": [REQUIRED] Glob of textproto files that represent one or more Entity messages. + +## list + +Lists all entities of a given type. + +**--type, -t**="": [REQUIRED] Type of entities to query. Allowed values: [ANTENNA_PATTERN, BAND_PROFILE, CDPI_STREAM_INFO, COMPUTED_MOTION, DEVICES_IN_REGION, DRAIN_PROVISION, INTENT, INTERFACE_LINK_REPORT, INTERFERENCE_CONSTRAINT, MOTION_DEFINITION, NETWORK_NODE, NETWORK_STATS_REPORT, PLATFORM_DEFINITION, PROPAGATION_WEATHER, SERVICE_REQUEST, STATION_SET, SURFACE_REGION, TRANSCEIVER_LINK_REPORT] + +## delete + +Deletes the entity with the given type and ID. + +**--id**="": [REQUIRED] ID of entity to delete. + +**--timestamp, --commit_time**="": [REQUIRED] Commit timestamp of entity to delete. (default: 0) + +**--type, -t**="": [REQUIRED] Type of entity to delete. Allowed values: [ANTENNA_PATTERN, BAND_PROFILE, CDPI_STREAM_INFO, COMPUTED_MOTION, DEVICES_IN_REGION, DRAIN_PROVISION, INTENT, INTERFACE_LINK_REPORT, INTERFERENCE_CONSTRAINT, MOTION_DEFINITION, NETWORK_NODE, NETWORK_STATS_REPORT, PLATFORM_DEFINITION, PROPAGATION_WEATHER, SERVICE_REQUEST, STATION_SET, SURFACE_REGION, TRANSCEIVER_LINK_REPORT] + +## get-link-budget + +Gets link budget details + +**--analysis_end_timestamp**="": An RFC3339 formatted timestamp for the end of the interval to evaluate the signal propagation. If unset, the signal propagation is evaluated at the instant of the `analysis_start_timestamp.` + +**--analysis_start_timestamp**="": An RFC3339 formatted timestamp for the beginning of the interval to evaluate the signal propagation. Defaults to the current local timestamp. + +**--band_profile_id**="": The Entity ID of the BandProfile used for this link. + +**--explain_inaccessibility**: If true, the server will spend additional computational time determining the specific set of access constraints that were not satisfied and including these reasons in the response. + +**--input_file**="": A path to a textproto file containing a SignalPropagationRequest message. If set, it will be used as the request to the SignalPropagation service. If unset, the request will be built from the other flags. + +**--output_file**="": Path to a textproto file to write the response. If unset, defaults to stdout. (default: /dev/stdout) + +**--reference_data_timestamp**="": An RFC3339 formatted timestamp for the instant at which to reference the versions of the platforms. Defaults to `analysis_start_timestamp`. (default: analysis_start_timestamp) + +**--spatial_propagation_step_size**="": The analysis step size for spatial propagation metrics. (default: 1m) + +**--step_size**="": The analysis step size and the temporal resolution of the response. (default: 1m) + +**--target_platform_id**="": The Entity ID of the PlatformDefinition that represents the target. Leave unset if the antenna is fixed or non-steerable, in which case coverage calculations will be returned. + +**--target_transceiver_model_id**="": The ID of the transceiver model on the target.Leave unset if the antenna is fixed or non-steerable, in which case coverage calculations will be returned. + +**--tx_platform_id**="": The Entity ID of the PlatformDefinition that represents the transmitter. + +**--tx_transceiver_model_id**="": The ID of the transceiver model on the transmitter. + +## generate-keys + +Generate RSA keys to use for authentication with the Spacetime APIs. + +>After creating the Private-Public keypair, you will need to request API access by sharing the `.crt` file (a self-signed x509 certificate containing the public key) with Aalyria to receive the `USER_ID` and a `KEY_ID` needed to complete the nbictl configuration. Only share the public certificate (`.crt`) with Aalyria or third-parties. The private key (`.key`) must be protected and should never be sent by email or communicated to others. + +**--country**="": Country of certificate. + +**--dir, --directory**="": Directory to store the generated RSA keys in. (default: ~/.config/nbictl/keys) + +**--location**="": Location of certificate. + +**--org, --organization**="": [REQUIRED] Organization of certificate. + +**--state**="": State of certificate. + +## set-config + +Sets or updates a configuration profile that contains NBI connection settings. You can create multiple configs by specifying the name of the configuration using the `--context` flag (defaults to "DEFAULT"). + +**--key_id**="": Key ID associated with the private key provided by Aalyria. + +**--priv_key**="": Path to the private key to use for authentication. + +**--transport_security**="": Transport security to use when connecting to the NBI service. Allowed values: [insecure, system_cert_pool] + +**--url**="": URL of the NBI endpoint. + +**--user_id**="": User ID associated with the private key provided by Aalyria. + +## help, h + +Shows a list of commands or help for one command -#### create -``` -Usage of nbictl create: - -context string - name of context you want to use - -files path - [REQUIRED] a path to the textproto file containing information of the entity you want to create -``` -#### update -``` -Usage of nbictl update: - -context string - name of context you want to use - -files path - [REQUIRED] a path to the textproto file containing information of the entity you want to update -``` -#### delete -``` -Usage of nbictl delete: - -commit_time int - [REQUIRED] commit timestamp of the entity you want to delete (default -1) - -context string - name of context you want to use - -id string - [REQUIRED] the id of the entity you want to delete - -type string - [REQUIRED] type of entities you want to delete. list of possible types: [STATION_SET NETWORK_NODE CDPI_STREAM_INFO DRAIN_PROVISION INTENT INTERFACE_LINK_REPORT MOTION_DEFINITION NETWORK_STATS_REPORT BAND_PROFILE COMPUTED_MOTION TRANSCEIVER_LINK_REPORT ANTENNA_PATTERN PLATFORM_DEFINITION INTERFERENCE_CONSTRAINT PROPAGATION_WEATHER SERVICE_REQUEST DEVICES_IN_REGION SURFACE_REGION] -``` -#### list -``` -Usage of nbictl list: - -context string - name of context you want to use - -type string - [REQUIRED] type of entities you want to query. list of possible types: [DEVICES_IN_REGION NETWORK_STATS_REPORT PLATFORM_DEFINITION PROPAGATION_WEATHER SERVICE_REQUEST COMPUTED_MOTION ANTENNA_PATTERN BAND_PROFILE INTENT NETWORK_NODE TRANSCEIVER_LINK_REPORT CDPI_STREAM_INFO STATION_SET SURFACE_REGION DRAIN_PROVISION INTERFACE_LINK_REPORT INTERFERENCE_CONSTRAINT MOTION_DEFINITION] -``` diff --git a/tools/nbictl/cmd/nbictl/nbictl.go b/tools/nbictl/cmd/nbictl/nbictl.go index bf9cdb3..2c0eaf0 100644 --- a/tools/nbictl/cmd/nbictl/nbictl.go +++ b/tools/nbictl/cmd/nbictl/nbictl.go @@ -15,55 +15,15 @@ package main import ( - "context" - "errors" - "flag" "fmt" - "log" + "os" - nbictl "aalyria.com/spacetime/github/tools/nbictl" + "aalyria.com/spacetime/github/tools/nbictl" ) -var subCmds = map[string]func(context.Context, []string) error{ - "list": nbictl.List, - "create": nbictl.Create, - "update": nbictl.Update, - "delete": nbictl.Delete, - "generate-keys": nbictl.GenerateKeys, - "set-context": nbictl.SetContext, -} - -const ( - linkToAuthGuide = "https://docs.spacetime.aalyria.com/authentication" - clientName = "nbictl" -) - -func getSubcommandNames() []string { - var cmdList []string - for cmd := range subCmds { - cmdList = append(cmdList, cmd) - } - return cmdList -} - -func run() error { - args := flag.Args() - if flag.NArg() == 0 { - return errors.New("Please specify a subcommand") - } - cmd, args := args[0], args[1:] - ctx := context.Background() - - cmdToPerform, ok := subCmds[cmd] - if !ok { - return fmt.Errorf("invalid command: %s. must be one of %v", cmd, getSubcommandNames()) - } - return cmdToPerform(ctx, args) -} - func main() { - flag.Parse() - if err := run(); err != nil { - log.Fatalf("fatal error: %v", err) + if err := nbictl.App().Run(os.Args); err != nil { + fmt.Fprintf(os.Stderr, "fatal error: %v\n", err) + os.Exit(1) } } diff --git a/tools/nbictl/connection.go b/tools/nbictl/connection.go index 096eac0..e8ee0b8 100644 --- a/tools/nbictl/connection.go +++ b/tools/nbictl/connection.go @@ -18,18 +18,19 @@ import ( "bytes" "context" "crypto/x509" + "errors" "fmt" "os" "aalyria.com/spacetime/cdpi_agent/internal/auth" - pb "aalyria.com/spacetime/github/tools/nbictl/resource" + "aalyria.com/spacetime/github/tools/nbictl/nbictlpb" "github.com/jonboulle/clockwork" "google.golang.org/grpc" "google.golang.org/grpc/credentials" "google.golang.org/grpc/credentials/insecure" ) -func OpenConnection(ctx context.Context, setting *pb.Context) (*grpc.ClientConn, error) { +func OpenConnection(ctx context.Context, setting *nbictlpb.Config) (*grpc.ClientConn, error) { dialOpts, err := getDialOpts(ctx, setting) if err != nil { return nil, fmt.Errorf("unable to construct dial options: %w", err) @@ -42,16 +43,16 @@ func OpenConnection(ctx context.Context, setting *pb.Context) (*grpc.ClientConn, return conn, nil } -func getDialOpts(ctx context.Context, setting *pb.Context) ([]grpc.DialOption, error) { +func getDialOpts(ctx context.Context, setting *nbictlpb.Config) ([]grpc.DialOption, error) { dialOpts := []grpc.DialOption{ grpc.WithDefaultCallOptions(grpc.MaxCallRecvMsgSize(1024 * 1024 * 256)), } switch t := setting.GetTransportSecurity().GetType().(type) { - case *pb.Context_TransportSecurity_Insecure: + case *nbictlpb.Config_TransportSecurity_Insecure: dialOpts = append(dialOpts, grpc.WithTransportCredentials(insecure.NewCredentials())) - case *pb.Context_TransportSecurity_ServerCertificate_: + case *nbictlpb.Config_TransportSecurity_ServerCertificate_: clientTLSFromFile, err := credentials.NewClientTLSFromFile(t.ServerCertificate.GetCertFilePath(), "") if err != nil { return nil, fmt.Errorf("creating TLS credentials from certificate file: %w", err) @@ -59,7 +60,7 @@ func getDialOpts(ctx context.Context, setting *pb.Context) ([]grpc.DialOption, e dialOpts = append(dialOpts, grpc.WithTransportCredentials(clientTLSFromFile)) // SystemCertPoll is the default option in case transport_security is not set (nil). - case nil, *pb.Context_TransportSecurity_SystemCertPool: + case nil, *nbictlpb.Config_TransportSecurity_SystemCertPool: cp, err := x509.SystemCertPool() if err != nil { return nil, fmt.Errorf("reading system tls cert pool: %w", err) @@ -71,7 +72,11 @@ func getDialOpts(ctx context.Context, setting *pb.Context) ([]grpc.DialOption, e } // Unless transport-security is set to Insecure, add Spacetime PerRPCCredentials. - if _, insecure := setting.GetTransportSecurity().GetType().(*pb.Context_TransportSecurity_Insecure); !insecure { + if _, insecure := setting.GetTransportSecurity().GetType().(*nbictlpb.Config_TransportSecurity_Insecure); !insecure { + if setting.GetPrivKey() == "" { + return nil, errors.New("no private key set for chosen context") + } + pkeyBytes, err := os.ReadFile(setting.GetPrivKey()) if err != nil { return nil, fmt.Errorf("unable to read the file: %w", err) diff --git a/tools/nbictl/connection_test.go b/tools/nbictl/connection_test.go index 021931c..129c265 100644 --- a/tools/nbictl/connection_test.go +++ b/tools/nbictl/connection_test.go @@ -28,7 +28,7 @@ import ( "time" nbi "aalyria.com/spacetime/api/nbi/v1alpha" - pb "aalyria.com/spacetime/github/tools/nbictl/resource" + "aalyria.com/spacetime/github/tools/nbictl/nbictlpb" "github.com/bazelbuild/rules_go/go/tools/bazel" "golang.org/x/sync/errgroup" ) @@ -106,13 +106,13 @@ func TestOpenConnection_insecure(t *testing.T) { checkErr(t, err) // Invoke OpenConnection - nbiContext := &pb.Context{ + nbiConf := &nbictlpb.Config{ Url: lis.Addr().String(), - TransportSecurity: &pb.Context_TransportSecurity{ - Type: &pb.Context_TransportSecurity_Insecure{}, + TransportSecurity: &nbictlpb.Config_TransportSecurity{ + Type: &nbictlpb.Config_TransportSecurity_Insecure{}, }, } - conn, err := OpenConnection(ctx, nbiContext) + conn, err := OpenConnection(ctx, nbiConf) checkErr(t, err) defer conn.Close() @@ -144,8 +144,8 @@ func TestOpenConnection_serverCertificate(t *testing.T) { checkErr(t, err) serverCertPath := filepath.Join(nbictlConfig, "localhost.crt") checkErr(t, os.WriteFile(serverCertPath, LocalhostCert, 0o644)) - userRsaKeyPath, err := GenerateRSAKeys(nbictlConfig, "", "user.organization", "", "") - checkErr(t, err) + + userKeys := generateKeysForTesting(t, "--org", "user.organization") // Start fake NBI server WITH TLS and a timeout for the test ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) @@ -166,22 +166,22 @@ func TestOpenConnection_serverCertificate(t *testing.T) { defer ts.Close() // Invoke OpenConnection - nbiContext := &pb.Context{ + nbiConf := &nbictlpb.Config{ Url: lis.Addr().String(), - PrivKey: userRsaKeyPath.PrivateKeyPath, + PrivKey: userKeys.key, Name: "test", KeyId: "1", Email: "some@example.com", OidcUrl: "http://" + ts.Listener.Addr().String() + "/", - TransportSecurity: &pb.Context_TransportSecurity{ - Type: &pb.Context_TransportSecurity_ServerCertificate_{ - ServerCertificate: &pb.Context_TransportSecurity_ServerCertificate{ + TransportSecurity: &nbictlpb.Config_TransportSecurity{ + Type: &nbictlpb.Config_TransportSecurity_ServerCertificate_{ + ServerCertificate: &nbictlpb.Config_TransportSecurity_ServerCertificate{ CertFilePath: serverCertPath, }, }, }, } - conn, err := OpenConnection(ctx, nbiContext) + conn, err := OpenConnection(ctx, nbiConf) checkErr(t, err) defer conn.Close() diff --git a/tools/nbictl/generate_rsa_key.go b/tools/nbictl/generate_rsa_key.go index 4e72833..7f99f96 100644 --- a/tools/nbictl/generate_rsa_key.go +++ b/tools/nbictl/generate_rsa_key.go @@ -15,7 +15,6 @@ package nbictl import ( - "context" "crypto/rand" "crypto/rsa" "crypto/sha1" @@ -25,13 +24,14 @@ import ( "encoding/hex" "encoding/pem" "errors" - "flag" "fmt" "math" "math/big" "os" "path/filepath" "time" + + "github.com/urfave/cli/v2" ) const ( @@ -49,26 +49,17 @@ type RSAKeyPath struct { CertificatePath string } -func GenerateKeys(ctx context.Context, args []string) error { - generateKey := flag.NewFlagSet(clientName+" generate-key", flag.ExitOnError) - directory := generateKey.String("dir", "", "`directory` where you want your RSA keys to be stored. Default: ~/.nbictl/") - country := generateKey.String("country", "", "country of certificate") - org := generateKey.String("org", "", "[REQUIRED] organization of certificate") - state := generateKey.String("state", "", "state of certificate") - location := generateKey.String("location", "", "location of certificate") - - generateKey.Parse(args) - if _, err := GenerateRSAKeys(*directory, *country, *org, *state, *location); err != nil { - return fmt.Errorf("unable to generate RSA keys: %w", err) - } - return nil -} +func GenerateKeys(appCtx *cli.Context) error { + directory := appCtx.String("dir") + country := appCtx.String("country") + org := appCtx.String("org") + state := appCtx.String("state") + location := appCtx.String("location") -func GenerateRSAKeys(rsaKeyDir, country, org, state, location string) (RSAKeyPath, error) { certIssuer := pkix.Name{} if org == "" { - return RSAKeyPath{}, errors.New("missing required key --org: organization for the certification must be provided") + return errors.New("missing required key --org: organization for the certification must be provided") } else { certIssuer.Organization = []string{org} } @@ -83,37 +74,37 @@ func GenerateRSAKeys(rsaKeyDir, country, org, state, location string) (RSAKeyPat certIssuer.Locality = []string{location} } - generatedKeysDir := rsaKeyDir + generatedKeysDir := directory if generatedKeysDir == "" { configDir, err := os.UserConfigDir() if err != nil { - return RSAKeyPath{}, err + return err } - generatedKeysDir = filepath.Join(configDir, clientName, generatedKeysDirDefault) + generatedKeysDir = filepath.Join(configDir, appCtx.App.Name, generatedKeysDirDefault) } if err := os.MkdirAll(generatedKeysDir, generatedKeysDirPerm); err != nil { - return RSAKeyPath{}, err + return err } dirInfo, err := os.Stat(generatedKeysDir) if err != nil { - return RSAKeyPath{}, fmt.Errorf("unable to get directory info: %w", err) + return fmt.Errorf("unable to get directory info: %w", err) } if dirPerm := dirInfo.Mode().Perm(); dirPerm != generatedKeysDirPerm { - return RSAKeyPath{}, fmt.Errorf("directory does not have an appropriate permission: must have %v but have %v", generatedKeysDirPerm, dirPerm) + return fmt.Errorf("directory does not have an appropriate permission: must have %v but have %v", generatedKeysDirPerm, dirPerm) } now := time.Now() certSerialNumber, err := rand.Int(rand.Reader, big.NewInt(math.MaxInt64)) if err != nil { - return RSAKeyPath{}, err + return err } privateKey, err := rsa.GenerateKey(rand.Reader, rsaKeysBitSize) if err != nil { - return RSAKeyPath{}, fmt.Errorf("unable to generate private key: %w", err) + return fmt.Errorf("unable to generate private key: %w", err) } publicKey := privateKey.PublicKey @@ -136,7 +127,7 @@ func GenerateRSAKeys(rsaKeyDir, country, org, state, location string) (RSAKeyPat cert, err := x509.CreateCertificate(rand.Reader, certTemplate, certTemplate, &publicKey, privateKey) if err != nil { - return RSAKeyPath{}, fmt.Errorf("unable to create certificate: %w", err) + return fmt.Errorf("unable to create certificate: %w", err) } pemPrivateBlock := &pem.Block{ @@ -158,25 +149,25 @@ func GenerateRSAKeys(rsaKeyDir, country, org, state, location string) (RSAKeyPat privFile, err := os.OpenFile(rsaKeyPaths.PrivateKeyPath, os.O_CREATE|os.O_RDWR|os.O_EXCL, privateKeysFilePerm) if err != nil { - return RSAKeyPath{}, fmt.Errorf("unable to create file: %w", err) + return fmt.Errorf("unable to create file: %w", err) } defer privFile.Close() pubFile, err := os.OpenFile(rsaKeyPaths.CertificatePath, os.O_CREATE|os.O_RDWR|os.O_EXCL, pubCertFilePerm) if err != nil { - return RSAKeyPath{}, fmt.Errorf("unable to create file: %w", err) + return fmt.Errorf("unable to create file: %w", err) } defer pubFile.Close() if err = pem.Encode(privFile, pemPrivateBlock); err != nil { - return RSAKeyPath{}, fmt.Errorf("unable to encode private key: %w", err) + return fmt.Errorf("unable to encode private key: %w", err) } if err := pem.Encode(pubFile, pemCertBlock); err != nil { - return RSAKeyPath{}, fmt.Errorf("unable to encode certificate: %w", err) + return fmt.Errorf("unable to encode certificate: %w", err) } fmt.Printf("private key is stored under: %s\n", rsaKeyPaths.PrivateKeyPath) fmt.Printf("certificate is stored under: %s\n", rsaKeyPaths.CertificatePath) - return rsaKeyPaths, nil + return nil } diff --git a/tools/nbictl/generate_rsa_key_test.go b/tools/nbictl/generate_rsa_key_test.go index 8a8d69e..3d45296 100644 --- a/tools/nbictl/generate_rsa_key_test.go +++ b/tools/nbictl/generate_rsa_key_test.go @@ -20,6 +20,7 @@ import ( "encoding/pem" "os" "os/exec" + "path/filepath" "testing" "github.com/bazelbuild/rules_go/go/tools/bazel" @@ -32,29 +33,57 @@ const ( exampleCertLocation = "example.location" ) -func TestGenerateKey_ValiddateWithOpenSSL(t *testing.T) { - t.Parallel() - if _, err := exec.LookPath("openssl"); err != nil { - t.Skipf("unable to find openssl path: %v", err) - } +type testKeyPath struct{ key, cert string } - nbictlConfig, err := bazel.NewTmpDir("nbictl") +func generateKeysForTesting(t *testing.T, args ...string) testKeyPath { + tmpDir, err := bazel.NewTmpDir("nbictl") if err != nil { t.Fatal(err) } + t.Cleanup(func() { + if err := os.RemoveAll(tmpDir); err != nil { + t.Logf("failed to remove tmp dir %s: %v", tmpDir, err) + } + }) - rsaKeyPath, err := GenerateRSAKeys(nbictlConfig, "", exampleCertOrganization, "", "") - if err != nil { + if err := App().Run(append([]string{"nbictl", "generate-keys", "--dir", tmpDir}, args...)); err != nil { t.Fatalf("unable to generate RSA keys: %v", err) } - privCmd := exec.Command("openssl", "rsa", "-noout", "-modulus", "-in", rsaKeyPath.PrivateKeyPath) - certCmd := exec.Command("openssl", "x509", "-noout", "-modulus", "-in", rsaKeyPath.CertificatePath) + + privKeyPaths, err := filepath.Glob(filepath.Join(tmpDir, "*.key")) + if err != nil { + t.Fatal(err) + } else if len(privKeyPaths) != 1 { + t.Fatalf("expected to generate 1 private key, got %v", privKeyPaths) + } + certPaths, err := filepath.Glob(filepath.Join(tmpDir, "*.crt")) + if err != nil { + t.Fatal(err) + } else if len(certPaths) != 1 { + t.Fatalf("expected to generate 1 cert, got %v", certPaths) + } + + return testKeyPath{ + key: privKeyPaths[0], + cert: certPaths[0], + } +} + +func TestGenerateKey_ValidateWithOpenSSL(t *testing.T) { + t.Parallel() + + if _, err := exec.LookPath("openssl"); err != nil { + t.Skipf("unable to find path to openssl binary: %v", err) + } + + keys := generateKeysForTesting(t, "--org", exampleCertOrganization) + privCmd := exec.Command("openssl", "rsa", "-noout", "-modulus", "-in", keys.key) + certCmd := exec.Command("openssl", "x509", "-noout", "-modulus", "-in", keys.cert) privOutput, err := privCmd.Output() if err != nil { t.Fatalf("unable to run the openssl command for private key: %v", err) } - pubOutput, err := certCmd.Output() if err != nil { t.Fatalf("unable to run the openssl command for public key: %v", err) @@ -67,22 +96,13 @@ func TestGenerateKey_ValiddateWithOpenSSL(t *testing.T) { func TestGenerateKey_ValidateWithGoLib(t *testing.T) { t.Parallel() - nbictlConfig, err := bazel.NewTmpDir("nbictl") - if err != nil { - t.Fatal(err) - } - - rsaKeyPath, err := GenerateRSAKeys(nbictlConfig, "", exampleCertOrganization, "", "") - if err != nil { - t.Fatalf("unable to generate RSA keys: %v", err) - } - rawPrivateKey, err := os.ReadFile(rsaKeyPath.PrivateKeyPath) + keys := generateKeysForTesting(t, "--org", exampleCertOrganization) + rawPrivateKey, err := os.ReadFile(keys.key) if err != nil { t.Fatalf("failed to read file containing private key: %v", err) } - - rawCert, err := os.ReadFile(rsaKeyPath.CertificatePath) + rawCert, err := os.ReadFile(keys.cert) if err != nil { t.Fatalf("failed to read file containing certificate: %v", err) } @@ -101,17 +121,14 @@ func TestGenerateKey_ValidateWithGoLib(t *testing.T) { func TestGenerateKey_ValidateSubjectAndIssuer(t *testing.T) { t.Parallel() - nbictlConfig, err := bazel.NewTmpDir("nbictl") - if err != nil { - t.Fatal(err) - } - rsaKeyPath, err := GenerateRSAKeys(nbictlConfig, exampleCertCountry, exampleCertOrganization, exampleCertState, exampleCertLocation) - if err != nil { - t.Fatalf("unable to generate RSA keys: %v", err) - } + keys := generateKeysForTesting(t, + "--org", exampleCertOrganization, + "--country", exampleCertCountry, + "--state", exampleCertState, + "--location", exampleCertLocation) - rawCert, err := os.ReadFile(rsaKeyPath.CertificatePath) + rawCert, err := os.ReadFile(keys.cert) if err != nil { t.Fatalf("failed to read file containing certificate: %v", err) } @@ -148,48 +165,36 @@ func TestGenerateKey_ValidateSubjectAndIssuer(t *testing.T) { func TestGenerateKey_FilePermission(t *testing.T) { t.Parallel() - nbictlConfig, err := bazel.NewTmpDir("nbictl") - if err != nil { - t.Fatal(err) - } - rsaKeyPath, err := GenerateRSAKeys(nbictlConfig, "", exampleCertOrganization, "", "") - if err != nil { - t.Fatalf("unable to generate RSA keys: %v", err) - } + keys := generateKeysForTesting(t, "--org", exampleCertOrganization) - privKeyInfo, err := os.Stat(rsaKeyPath.PrivateKeyPath) + privKeyInfo, err := os.Stat(keys.key) if err != nil { t.Fatalf("unable to get file info: %v", err) } - - privFilePerm := privKeyInfo.Mode().Perm() - if privFilePerm != os.FileMode(privateKeysFilePerm) { + if privFilePerm := privKeyInfo.Mode().Perm(); privFilePerm != os.FileMode(privateKeysFilePerm) { t.Errorf("file must have permission %d, but has %s", privateKeysFilePerm, privFilePerm.String()) } - pubCertKeyInfo, err := os.Stat(rsaKeyPath.CertificatePath) + pubCertKeyInfo, err := os.Stat(keys.cert) if err != nil { t.Errorf("unable to get file info: %v", err) } - - pubCertPerm := pubCertKeyInfo.Mode().Perm() - if pubCertPerm != os.FileMode(pubCertFilePerm) { - t.Fatalf("file must have permission %d, but has %s", pubCertFilePerm, pubCertPerm.String()) + if pubCertPerm := pubCertKeyInfo.Mode().Perm(); pubCertPerm != os.FileMode(pubCertFilePerm) { + t.Errorf("file must have permission %d, but has %s", pubCertFilePerm, pubCertPerm.String()) } } func TestGenerateKey_DirPermision(t *testing.T) { t.Parallel() - nbictlConfig, err := bazel.NewTmpDir("test_nbictl") + tmpDir, err := bazel.NewTmpDir("test_nbictl") if err != nil { t.Fatal(err) } - if err := os.Chmod(nbictlConfig, 0755); err != nil { + if err := os.Chmod(tmpDir, 0755); err != nil { t.Fatal(err) } - - if _, err := GenerateRSAKeys(nbictlConfig, "", exampleCertOrganization, "", ""); err == nil { - t.Fatalf("unable to detect wrong directory permission: %v", err) + if err := App().Run([]string{"nbictl", "generate-keys", "--dir", tmpDir, "--org", exampleCertOrganization}); err == nil { + t.Fatal("unable to detect wrong directory permission (expected non-nil error, got nil)") } } diff --git a/tools/nbictl/nbictl.go b/tools/nbictl/nbictl.go index 53d343f..13a9501 100644 --- a/tools/nbictl/nbictl.go +++ b/tools/nbictl/nbictl.go @@ -15,48 +15,334 @@ package nbictl import ( - "context" "errors" - "flag" "fmt" "os" "path/filepath" + "sort" "strings" + "time" + commonpb "aalyria.com/spacetime/api/common" pb "aalyria.com/spacetime/api/nbi/v1alpha" + resourcespb "aalyria.com/spacetime/api/nbi/v1alpha/resources" + "github.com/urfave/cli/v2" + intervalpb "google.golang.org/genproto/googleapis/type/interval" "google.golang.org/protobuf/encoding/prototext" + "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/types/known/durationpb" + "google.golang.org/protobuf/types/known/timestamppb" ) -var typeList = generateTypeList() - const ( - clientName = "nbictl" + confFileName = "config.textproto" + + // modified from + // https://github.com/urfave/cli/blob/c023d9bc5a3122830c9355a0a8c17137e0c8556f/template.go#L98 + readmeDocTemplate = `{{if gt .SectionNum 0}}% {{ .App.Name }} {{ .SectionNum }} + +{{end}}# NAME + +{{ .App.Name }}{{ if .App.Usage }} - {{ .App.Usage }}{{ end }} + +# SYNOPSIS - // if oidcURL is empty, the Spacetime OAUTH library will use its default URL - oidcURLDefault = "" +{{ if .SynopsisArgs }}` + "```" + ` +{{ .App.Name }} {{ range $f := .App.VisibleFlags -}}{{ range $n := $f.Names }}[{{ if len $n | lt 1 }}--{{ else }}-{{ end }}{{ $n }}{{ if $f.TakesValue }}=value{{ end }}] {{ end }}{{ end }} [COMMAND OPTIONS] [ARGUMENTS...] +` + "```" + ` +{{ end }}{{ if .GlobalArgs }} +# GLOBAL OPTIONS +{{ range $v := .GlobalArgs }} +{{ $v }}{{ end }} +{{ end }}{{ if .Commands }}# COMMANDS +{{ range $v := .Commands }} +{{ $v }}{{ end }}{{ end }}` ) -func Create(ctx context.Context, args []string) error { - fs := flag.NewFlagSet(clientName+" create", flag.ExitOnError) - contextName := fs.String("context", "", "name of context you want to use") - files := fs.String("files", "", "[REQUIRED] a `path` to the textproto file containing information of the entity you want to create") - fs.Parse(args) +var entityTypeList = generateTypeList() + +func init() { + cli.MarkdownDocTemplate = readmeDocTemplate +} + +func validateEntityType(_ *cli.Context, t string) error { + for _, et := range entityTypeList { + if t == et { + return nil + } + } - if *files == "" { + return fmt.Errorf("unknown entity type %q", t) +} + +func App() *cli.App { + return &cli.App{ + Name: "nbictl", + Usage: "Interact with the Spacetime NBI service from the command line.", + Description: "`nbctl` is a tool that allows you to interact with the Spacetime NBI APIs from the command-line.", + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "context", + Usage: "Context (configuration profile) to reference for connection settings.", + }, + }, + Commands: []*cli.Command{ + { + Name: "readme", + Category: "help", + Usage: "Prints the help information as Markdown.", + Hidden: true, + Action: func(appCtx *cli.Context) error { + md, err := appCtx.App.ToMarkdown() + if err != nil { + return err + } + fmt.Println(`") + fmt.Println() + + fmt.Println(md) + return nil + }, + }, + { + Name: "man", + Category: "help", + Usage: "Prints the help information as a man page.", + Hidden: true, + Action: func(appCtx *cli.Context) error { + man, err := appCtx.App.ToMan() + if err != nil { + return err + } + fmt.Println(man) + return nil + }, + }, + { + Name: "create", + Category: "entities", + Usage: "Create one or more entities described in textproto files.", + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "files", + Usage: "[REQUIRED] Glob of textproto files that represent one or more Entity messages.", + Aliases: []string{"f"}, + Required: true, + }, + }, + Action: Create, + }, + { + Name: "update", + Category: "entities", + Usage: "Updates one or more entities described in textproto files.", + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "files", + Usage: "[REQUIRED] Glob of textproto files that represent one or more Entity messages.", + Aliases: []string{"f"}, + Required: true, + }, + }, + Action: Update, + }, + { + Name: "list", + Category: "entities", + Usage: "Lists all entities of a given type.", + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "type", + Usage: fmt.Sprintf("[REQUIRED] Type of entities to query. Allowed values: [%s]", strings.Join(entityTypeList, ", ")), + Aliases: []string{"t"}, + Required: true, + Action: validateEntityType, + }, + }, + Action: List, + }, + { + Name: "delete", + Category: "entities", + Usage: "Deletes the entity with the given type and ID.", + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "type", + Usage: fmt.Sprintf("[REQUIRED] Type of entity to delete. Allowed values: [%s]", strings.Join(entityTypeList, ", ")), + Aliases: []string{"t"}, + Required: true, + Action: validateEntityType, + }, + &cli.StringFlag{ + Name: "id", + Usage: "[REQUIRED] ID of entity to delete.", + Aliases: []string{}, + Required: true, + }, + &cli.IntFlag{ + Name: "timestamp", + Usage: "[REQUIRED] Commit timestamp of entity to delete.", + Aliases: []string{"commit_time"}, + Required: true, + }, + }, + Action: Delete, + }, + { + Name: "get-link-budget", + Usage: "Gets link budget details", + Category: "entities", + Description: "Gets link budget details for a given signal propagation request between a transmitter and a target platform.", + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "input_file", + Usage: "A path to a textproto file containing a SignalPropagationRequest message. If set, it will be used as the request to the SignalPropagation service. If unset, the request will be built from the other flags.", + }, + &cli.StringFlag{ + Name: "tx_platform_id", + Usage: "The Entity ID of the PlatformDefinition that represents the transmitter.", + }, + &cli.StringFlag{ + Name: "tx_transceiver_model_id", + Usage: "The ID of the transceiver model on the transmitter.", + }, + &cli.StringFlag{ + Name: "target_platform_id", + Usage: "The Entity ID of the PlatformDefinition that represents the target. Leave unset if the antenna is fixed or non-steerable, in which case coverage calculations will be returned.", + }, + &cli.StringFlag{ + Name: "target_transceiver_model_id", + Usage: "The ID of the transceiver model on the target.Leave unset if the antenna is fixed or non-steerable, in which case coverage calculations will be returned.", + }, + &cli.StringFlag{ + Name: "band_profile_id", + Usage: "The Entity ID of the BandProfile used for this link.", + }, + &cli.TimestampFlag{ + Name: "analysis_start_timestamp", + Layout: time.RFC3339, + Usage: "An RFC3339 formatted timestamp for the beginning of the interval to evaluate the signal propagation. Defaults to the current local timestamp.", + }, + &cli.TimestampFlag{ + Name: "analysis_end_timestamp", + Layout: time.RFC3339, + Usage: "An RFC3339 formatted timestamp for the end of the interval to evaluate the signal propagation. If unset, the signal propagation is evaluated at the instant of the `analysis_start_timestamp.`", + }, + &cli.DurationFlag{ + Name: "step_size", + DefaultText: "1m", + Usage: "The analysis step size and the temporal resolution of the response.", + }, + &cli.DurationFlag{ + Name: "spatial_propagation_step_size", + DefaultText: "1m", + Usage: "The analysis step size for spatial propagation metrics.", + }, + &cli.BoolFlag{ + Name: "explain_inaccessibility", + DefaultText: "false", + Usage: "If true, the server will spend additional computational time determining the specific set of access constraints that were not satisfied and including these reasons in the response.", + }, + &cli.TimestampFlag{ + Name: "reference_data_timestamp", + Layout: time.RFC3339, + Usage: "An RFC3339 formatted timestamp for the instant at which to reference the versions of the platforms. Defaults to `analysis_start_timestamp`.", + DefaultText: "analysis_start_timestamp", + }, + &cli.PathFlag{ + Name: "output_file", + Usage: "Path to a textproto file to write the response. If unset, defaults to stdout.", + DefaultText: "/dev/stdout", + }, + }, + Action: GetLinkBudget, + }, + { + Name: "generate-keys", + Category: "configuration", + Usage: "Generate RSA keys to use for authentication with the Spacetime APIs.", + UsageText: "After creating the Private-Public keypair, you will need to request API access by sharing the `.crt` file (a self-signed x509 certificate containing the public key) with Aalyria to receive the `USER_ID` and a `KEY_ID` needed to complete the nbictl configuration. Only share the public certificate (`.crt`) with Aalyria or third-parties. The private key (`.key`) must be protected and should never be sent by email or communicated to others.", + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "dir", + Usage: "Directory to store the generated RSA keys in.", + DefaultText: "~/.config/nbictl/keys", + Aliases: []string{"directory"}, + }, + &cli.StringFlag{ + Name: "org", + Usage: "[REQUIRED] Organization of certificate.", + Aliases: []string{"organization"}, + Required: true, + }, + &cli.StringFlag{ + Name: "country", + Usage: "Country of certificate.", + }, + &cli.StringFlag{ + Name: "state", + Usage: "State of certificate.", + }, + &cli.StringFlag{ + Name: "location", + Usage: "Location of certificate.", + }, + }, + Action: GenerateKeys, + }, + { + Name: "set-config", + Usage: "Sets or updates a configuration profile that contains NBI connection settings. You can create multiple configs by specifying the name of the configuration using the `--context` flag (defaults to \"DEFAULT\").", + Category: "configuration", + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "priv_key", + Usage: "Path to the private key to use for authentication.", + }, + &cli.StringFlag{ + Name: "key_id", + Usage: "Key ID associated with the private key provided by Aalyria.", + }, + &cli.StringFlag{ + Name: "user_id", + Usage: "User ID associated with the private key provided by Aalyria.", + }, + &cli.StringFlag{ + Name: "url", + Usage: "URL of the NBI endpoint.", + }, + &cli.StringFlag{ + Name: "transport_security", + Usage: "Transport security to use when connecting to the NBI service. Allowed values: [insecure, system_cert_pool]", + }, + }, + Action: SetConfig, + }, + }, + } +} + +func Create(appCtx *cli.Context) error { + files := appCtx.String("files") + if files == "" { return errors.New("--files required") } + ctxName := appCtx.String("context") configDir, err := os.UserConfigDir() if err != nil { return fmt.Errorf("unable to obtain the default config directory: %w", err) } - filePath := filepath.Join(configDir, clientName, confFileName) + confPath := filepath.Join(configDir, appCtx.App.Name, confFileName) - setting, err := GetContext(*contextName, filePath) + setting, err := GetConfig(ctxName, confPath) if err != nil { return fmt.Errorf("unable to obtain context information: %w", err) } - conn, err := OpenConnection(ctx, setting) + conn, err := OpenConnection(appCtx.Context, setting) if err != nil { return err } @@ -64,14 +350,14 @@ func Create(ctx context.Context, args []string) error { client := pb.NewNetOpsClient(conn) - textprotoFiles, err := filepath.Glob(*files) + textprotoFiles, err := filepath.Glob(files) if err != nil { return fmt.Errorf("unable to expand the file path: %w", err) } else if len(textprotoFiles) == 0 { - return fmt.Errorf("no files found under the given file path: %s", *files) + return fmt.Errorf("no files found under the given file path: %s", files) } - var entities []*pb.Entity + entities := []*pb.Entity{} for _, textProtoFile := range textprotoFiles { entity := &pb.Entity{} if err := readFromFile(textProtoFile, entity); err != nil { @@ -84,7 +370,7 @@ func Create(ctx context.Context, args []string) error { entityType := entity.Group.GetType().String() createEntityRequest := &pb.CreateEntityRequest{Type: &entityType, Entity: entity} - res, err := client.CreateEntity(ctx, createEntityRequest) + res, err := client.CreateEntity(appCtx.Context, createEntityRequest) if err != nil { return fmt.Errorf("unable to create an entity: %w", err) } @@ -99,28 +385,22 @@ func Create(ctx context.Context, args []string) error { return nil } -func Update(ctx context.Context, args []string) error { - fs := flag.NewFlagSet(clientName+" update", flag.ExitOnError) - contextName := fs.String("context", "", "name of context you want to use") - files := fs.String("files", "", "[REQUIRED] a `path` to the textproto file containing information of the entity you want to update") - fs.Parse(args) - - if *files == "" { - return errors.New("--files required") - } +func Update(appCtx *cli.Context) error { + files := appCtx.String("files") + ctxName := appCtx.String("context") configDir, err := os.UserConfigDir() if err != nil { return fmt.Errorf("unable to obtain the default config directory: %w", err) } - filePath := filepath.Join(configDir, clientName, confFileName) + confPath := filepath.Join(configDir, appCtx.App.Name, confFileName) - setting, err := GetContext(*contextName, filePath) + setting, err := GetConfig(ctxName, confPath) if err != nil { return fmt.Errorf("unable to obtain context information: %w", err) } - conn, err := OpenConnection(ctx, setting) + conn, err := OpenConnection(appCtx.Context, setting) if err != nil { return err } @@ -128,15 +408,14 @@ func Update(ctx context.Context, args []string) error { client := pb.NewNetOpsClient(conn) - textprotoFiles, err := filepath.Glob(*files) + textprotoFiles, err := filepath.Glob(files) if err != nil { return fmt.Errorf("unable to expand the file path %w", err) } else if len(textprotoFiles) == 0 { - return fmt.Errorf("no files found under the given file path: %s", *files) + return fmt.Errorf("no files found under the given file path: %s", files) } - var entities []*pb.Entity - + entities := []*pb.Entity{} for _, textProtoFile := range textprotoFiles { entity := &pb.Entity{} @@ -147,11 +426,10 @@ func Update(ctx context.Context, args []string) error { } for idx, entity := range entities { - entityType := entity.Group.GetType().String() entityID := entity.GetId() updateEntityRequest := &pb.UpdateEntityRequest{Type: &entityType, Id: &entityID, Entity: entity} - res, err := client.UpdateEntity(ctx, updateEntityRequest) + res, err := client.UpdateEntity(appCtx.Context, updateEntityRequest) if err != nil { return fmt.Errorf("unable to update the entity: %w", err) } @@ -167,84 +445,64 @@ func Update(ctx context.Context, args []string) error { return nil } -func Delete(ctx context.Context, args []string) error { - fs := flag.NewFlagSet(clientName+" delete", flag.ExitOnError) - entityType := fs.String("type", "", fmt.Sprintf("[REQUIRED] type of entities you want to delete. list of possible types: %v", typeList)) - id := fs.String("id", "", "[REQUIRED] the id of the entity you want to delete") - commitTime := fs.Int64("commit_time", -1, "[REQUIRED] commit timestamp of the entity you want to delete") - contextName := fs.String("context", "", "name of context you want to use") +func Delete(appCtx *cli.Context) error { + ctxName := appCtx.String("context") - fs.Parse(args) - switch { - case *entityType == "": - return errors.New("--type required") - case *id == "": - return errors.New("--id required") - case *commitTime == -1: - return errors.New("--commit_time required") - } + entityType := appCtx.String("type") + id := appCtx.String("id") + commitTime := appCtx.Int64("timestamp") configDir, err := os.UserConfigDir() if err != nil { return fmt.Errorf("unable to obtain the default config directory: %w", err) } - filePath := filepath.Join(configDir, clientName, confFileName) + confPath := filepath.Join(configDir, appCtx.App.Name, confFileName) - setting, err := GetContext(*contextName, filePath) + setting, err := GetConfig(ctxName, confPath) if err != nil { return fmt.Errorf("unable to obtain context information: %w", err) } - conn, err := OpenConnection(ctx, setting) + conn, err := OpenConnection(appCtx.Context, setting) if err != nil { return err } defer conn.Close() - client := pb.NewNetOpsClient(conn) - - deleteEntityRequest := &pb.DeleteEntityRequest{Type: entityType, Id: id, CommitTimestamp: commitTime} - - if _, err := client.DeleteEntity(ctx, deleteEntityRequest); err != nil { + if _, err := pb.NewNetOpsClient(conn).DeleteEntity(appCtx.Context, &pb.DeleteEntityRequest{Type: &entityType, Id: &id, CommitTimestamp: &commitTime}); err != nil { return fmt.Errorf("unable to delete the entity: %w", err) } fmt.Fprintln(os.Stderr, "deletion successful") return nil } -func List(ctx context.Context, args []string) error { - list := flag.NewFlagSet(clientName+" list", flag.ExitOnError) - contextName := list.String("context", "", "name of context you want to use") - listType := list.String("type", "", fmt.Sprintf("[REQUIRED] type of entities you want to query. list of possible types: %v", typeList)) - list.Parse(args) - - if *listType == "" { - return errors.New("--type required") - } +func List(appCtx *cli.Context) error { + entityType := appCtx.String("type") + ctxName := appCtx.String("context") configDir, err := os.UserConfigDir() if err != nil { return fmt.Errorf("unable to obtain the default config directory: %w", err) } - filePath := filepath.Join(configDir, clientName, confFileName) + confPath := filepath.Join(configDir, appCtx.App.Name, confFileName) - setting, err := GetContext(*contextName, filePath) + setting, err := GetConfig(ctxName, confPath) if err != nil { return fmt.Errorf("unable to obtain context information: %w", err) } - conn, err := OpenConnection(ctx, setting) + conn, err := OpenConnection(appCtx.Context, setting) if err != nil { return err } client := pb.NewNetOpsClient(conn) - if _, exists := pb.EntityType_value[*listType]; !exists { - return fmt.Errorf("unknown entity type %q is not one of [%s]", *listType, strings.Join(typeList, ", ")) + if _, exists := pb.EntityType_value[entityType]; !exists { + return fmt.Errorf("unknown entity type %q is not one of [%s]", entityType, strings.Join(entityTypeList, ", ")) } - res, err := client.ListEntities(ctx, &pb.ListEntitiesRequest{Type: listType}) + res, err := client.ListEntities(appCtx.Context, &pb.ListEntitiesRequest{Type: &entityType}) if err != nil { return fmt.Errorf("unable to list entities: %w", err) } @@ -257,6 +515,158 @@ func List(ctx context.Context, args []string) error { return nil } +func GetLinkBudget(appCtx *cli.Context) error { + ctxName := appCtx.String("context") + + configDir, err := os.UserConfigDir() + if err != nil { + return fmt.Errorf("unable to obtain the default config directory: %w", err) + } + confPath := filepath.Join(configDir, appCtx.App.Name, confFileName) + + setting, err := GetConfig(ctxName, confPath) + if err != nil { + return fmt.Errorf("unable to obtain context information: %w", err) + } + + conn, err := OpenConnection(appCtx.Context, setting) + if err != nil { + return err + } + defer conn.Close() + client := pb.NewSignalPropagationClient(conn) + + spReq := &pb.SignalPropagationRequest{} + if appCtx.IsSet("input_file") { + reqPath := appCtx.String("input_file") + // If the user input a textproto file, use it to build the request. + req, err := os.ReadFile(reqPath) + if err != nil { + return fmt.Errorf("invalid file path: %w", err) + } + + if err := prototext.Unmarshal(req, spReq); err != nil { + return fmt.Errorf("reading SignalPropagationRequest from file %s: %w", reqPath, err) + } + } else { + txPlatformID := appCtx.String("tx_platform_id") + txTransceiverModelID := appCtx.String("tx_transceiver_model_id") + bandProfileID := appCtx.String("band_profile_id") + + errs := []error{} + if txPlatformID == "" { + errs = append(errs, errors.New("--tx_platform_id required")) + } + if txTransceiverModelID == "" { + errs = append(errs, errors.New("--tx_transceiver_model_id required")) + } + if bandProfileID == "" { + // TODO: Output a list of valid band profile IDs. + errs = append(errs, errors.New("--band_profile_id required")) + } + + startTime := appCtx.Timestamp("analysis_start_timestamp") + if startTime == nil { + // If the user did not specify the start of the analysis interval, + // it is set to the current local time. + now := time.Now() + startTime = &now + } + + endTime := appCtx.Timestamp("analysis_end_timestamp") + if endTime == nil { + // If the user did not provide the end of the analysis interval, it + // is set to the start time. Therefore, the signal propagation will + // be evaluated at the instant of the start time. + endTime = startTime + } + + refDataTime := appCtx.Timestamp("reference_data_timestamp") + if refDataTime == nil { + // If the user did not specify a reference data time, it is set to + // the start of the analysis interval. Therefore, the version of + // the entities used in the signal propagation analysis will match + // the start of the analysis interval. + refDataTime = startTime + } + + target := &resourcespb.TransceiverProvider{} + switch { + case appCtx.IsSet("target_platform_id") && appCtx.IsSet("target_transceiver_model_id"): + target = &resourcespb.TransceiverProvider{ + Source: &resourcespb.TransceiverProvider_IdInStore{ + IdInStore: &commonpb.TransceiverModelId{ + PlatformId: proto.String(appCtx.String("target_platform_id")), + TransceiverModelId: proto.String(appCtx.String("target_transceiver_model_id")), + }, + }, + } + case !appCtx.IsSet("target_platform_id") && !appCtx.IsSet("target_transceiver_model_id"): + // When the target's platform ID and transceiver model ID are not + // specified, the target field should be left unset (as opposed to + // setting it to an empty TransceiverProvider) to model the case of + // a fixed antenna. + target = nil + case !appCtx.IsSet("target_platform_id"): + errs = append(errs, errors.New("--target_platform_id required")) + case !appCtx.IsSet("target_transceiver_model_id"): + errs = append(errs, errors.New("--target_transceiver_model_id required.")) + } + + if err := errors.Join(errs...); err != nil { + return err + } + + stepSize := appCtx.Duration("step_size") + spatialPropagationStepSize := appCtx.Duration("spatial_propagation_step_size") + explainInaccessibility := appCtx.Bool("explain_inaccessibility") + + spReq = &pb.SignalPropagationRequest{ + TransmitterModel: &resourcespb.TransceiverProvider{ + Source: &resourcespb.TransceiverProvider_IdInStore{ + IdInStore: &commonpb.TransceiverModelId{ + PlatformId: &txPlatformID, + TransceiverModelId: &txTransceiverModelID, + }, + }, + }, + BandProfileId: &bandProfileID, + Target: target, + AnalysisTime: &pb.SignalPropagationRequest_AnalysisInterval{ + AnalysisInterval: &intervalpb.Interval{ + StartTime: timestamppb.New(*startTime), + EndTime: timestamppb.New(*endTime), + }, + }, + StepSize: durationpb.New(stepSize), + SpatialPropagationStepSize: durationpb.New(spatialPropagationStepSize), + ExplainInaccessibility: &explainInaccessibility, + ReferenceDataTime: timestamppb.New(*refDataTime), + } + } + + spRes, err := client.Evaluate(appCtx.Context, spReq) + if err != nil { + return fmt.Errorf("SignalPropagation.Evaluate: %w", err) + } + spResProto, err := prototext.MarshalOptions{Multiline: true}.Marshal(spRes) + if err != nil { + return fmt.Errorf("unable to convert the response into textproto format: %w", err) + } + + if !appCtx.IsSet("output_file") { + fmt.Println(string(spResProto)) + } else { + outPath := appCtx.Path("output_file") + // Creates the output file, if necessary, with read and write permissions. + if err := os.WriteFile(outPath, spResProto, 0666); err != nil { + return fmt.Errorf("writing to output file %s: %w", outPath, err) + } + } + fmt.Fprintln(os.Stderr, "successfully retrieved link budget.") + return nil +} + func generateTypeList() []string { var typeList []string for _, val := range pb.EntityType_name { @@ -264,6 +674,7 @@ func generateTypeList() []string { typeList = append(typeList, val) } } + sort.Strings(typeList) return typeList } diff --git a/tools/nbictl/proto/BUILD b/tools/nbictl/proto/BUILD index 3b06c6c..c748f1c 100644 --- a/tools/nbictl/proto/BUILD +++ b/tools/nbictl/proto/BUILD @@ -28,6 +28,6 @@ proto_library( go_proto_library( name = "nbictl_go_proto", compilers = ["@io_bazel_rules_go//proto:go_grpc"], - importpath = "aalyria.com/spacetime/github/tools/nbictl/resource", + importpath = "aalyria.com/spacetime/github/tools/nbictl/nbictlpb", protos = [":nbictl_proto"], ) diff --git a/tools/nbictl/proto/nbi_ctl_config.proto b/tools/nbictl/proto/nbi_ctl_config.proto index ce4376d..e0e1f74 100644 --- a/tools/nbictl/proto/nbi_ctl_config.proto +++ b/tools/nbictl/proto/nbi_ctl_config.proto @@ -18,23 +18,18 @@ package aalyria.spacetime.github.tools.nbictl; import "google/protobuf/empty.proto"; -option go_package = "aalyria.com/spacetime/github/tools/nbictl/resource"; +option go_package = "aalyria.com/spacetime/github/tools/nbictl/nbictlpb"; -message NbiCtlConfig { - repeated Context contexts = 1; +message AppConfig { + repeated Config configs = 1; } -message Context { +message Config { string name = 1; - string key_id = 2; - string email = 3; - string priv_key = 4; - string url = 5; - string oidc_url = 6; message TransportSecurity { diff --git a/tools/nbictl/set_config.go b/tools/nbictl/set_config.go new file mode 100644 index 0000000..c94466e --- /dev/null +++ b/tools/nbictl/set_config.go @@ -0,0 +1,180 @@ +// Copyright 2023 Aalyria Technologies, Inc., and its affiliates. +// +// 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 nbictl + +import ( + "errors" + "fmt" + "os" + "path/filepath" + + "aalyria.com/spacetime/github/tools/nbictl/nbictlpb" + "github.com/urfave/cli/v2" + "google.golang.org/protobuf/encoding/prototext" +) + +func GetConfig(context, confDir string) (*nbictlpb.Config, error) { + confs, err := getConfigs(confDir) + if err != nil { + return nil, fmt.Errorf("unable to get config contexts: %w", err) + } + + // if the context name is not specified and there is only one context in the config file + // the function will return that configuration context + if context == "" { + switch { + case len(confs.GetConfigs()) == 1: + return confs.GetConfigs()[0], nil + default: + return nil, errors.New("--context flag required because there are multiple contexts defined in the configuration.") + } + } + + var confNames []string + for _, conf := range confs.GetConfigs() { + if conf.GetName() == context { + return conf, nil + } + confNames = append(confNames, conf.GetName()) + } + return nil, fmt.Errorf("unable to get the context with the name: %s. the list of available context names are the following: %v", context, confNames) +} + +func getConfigs(confFilePath string) (*nbictlpb.AppConfig, error) { + confBytes, err := os.ReadFile(confFilePath) + confProto := &nbictlpb.AppConfig{} + if err != nil { + if os.IsNotExist(err) { + return confProto, nil + } + return nil, fmt.Errorf("unable to read file: %w", err) + } + + if err := prototext.Unmarshal(confBytes, confProto); err != nil { + return nil, fmt.Errorf("invalid file content: %w", err) + } + return confProto, nil +} + +func SetConfig(appCtx *cli.Context) error { + confName := "DEFAULT" + if appCtx.IsSet("context") { + confName = appCtx.String("context") + } + privKey := appCtx.String("priv_key") + keyID := appCtx.String("key_id") + userID := appCtx.String("user_id") + url := appCtx.String("url") + transportSecurity := appCtx.String("transport_security") + + confDir, err := os.UserConfigDir() + if err != nil { + return fmt.Errorf("unable to obtain the default root directory: %w", err) + } + confPath := filepath.Join(confDir, appCtx.App.Name, confFileName) + + var transportSecurityPb *nbictlpb.Config_TransportSecurity + + switch transportSecurity { + case "insecure": + transportSecurityPb = &nbictlpb.Config_TransportSecurity{ + Type: &nbictlpb.Config_TransportSecurity_Insecure{}, + } + + case "system_cert_pool": + transportSecurityPb = &nbictlpb.Config_TransportSecurity{ + Type: &nbictlpb.Config_TransportSecurity_SystemCertPool{}, + } + + case "": + transportSecurityPb = nil + + default: + return fmt.Errorf("unexpected transport security selection: %s", transportSecurity) + } + + contextToCreate := &nbictlpb.Config{ + Name: confName, + KeyId: keyID, + Email: userID, + PrivKey: privKey, + Url: url, + TransportSecurity: transportSecurityPb, + } + + return setConfig(contextToCreate, confPath) +} + +func setConfig(confToCreate *nbictlpb.Config, confFile string) error { + if confToCreate.GetName() == "" { + return errors.New("missing required --context flag") + } + + confProto, err := getConfigs(confFile) + if err != nil { + return fmt.Errorf("unable to get configs from file %s: %w", confFile, err) + } + + found := false + for _, confProto := range confProto.GetConfigs() { + if confProto.GetName() != confToCreate.GetName() { + continue + } + if confToCreate.GetEmail() != "" { + confProto.Email = confToCreate.GetEmail() + } + if confToCreate.GetPrivKey() != "" { + confProto.PrivKey = confToCreate.GetPrivKey() + } + if confToCreate.GetKeyId() != "" { + confProto.KeyId = confToCreate.GetKeyId() + } + if confToCreate.GetUrl() != "" { + confProto.Url = confToCreate.GetUrl() + } + if confToCreate.GetTransportSecurity() != nil { + confProto.TransportSecurity = confToCreate.GetTransportSecurity() + } + found = true + confToCreate = confProto + break + } + + if !found { + confProto.Configs = append(confProto.Configs, confToCreate) + } + + nbiConfigTextProto, err := prototext.Marshal(confProto) + if err != nil { + return fmt.Errorf("unable to convert proto into textproto format: %w", err) + } + + contextDir := filepath.Dir(confFile) + if err = os.MkdirAll(contextDir, 0o777); err != nil { + return fmt.Errorf("unable to create directory: %w", err) + } + + if err = os.WriteFile(confFile, nbiConfigTextProto, 0o777); err != nil { + return fmt.Errorf("unable to update the configuration information: %w", err) + } + + protoMessage, err := prototext.MarshalOptions{Multiline: true}.Marshal(confToCreate) + if err != nil { + return fmt.Errorf("unable to convert the nbictl context into textproto format: %w", err) + } + fmt.Fprintf(os.Stderr, "configuration successfully updated; the configuration file is stored under: %s\n", confFile) + fmt.Println(string(protoMessage)) + return nil +} diff --git a/tools/nbictl/set_context_test.go b/tools/nbictl/set_config_test.go similarity index 60% rename from tools/nbictl/set_context_test.go rename to tools/nbictl/set_config_test.go index 807f795..620df98 100644 --- a/tools/nbictl/set_context_test.go +++ b/tools/nbictl/set_config_test.go @@ -21,7 +21,7 @@ import ( "strings" "testing" - pb "aalyria.com/spacetime/github/tools/nbictl/resource" + "aalyria.com/spacetime/github/tools/nbictl/nbictlpb" "github.com/bazelbuild/rules_go/go/tools/bazel" "github.com/google/go-cmp/cmp" "google.golang.org/protobuf/proto" @@ -29,7 +29,7 @@ import ( ) var ( - testContext = &pb.Context{ + testConfig = &nbictlpb.Config{ Name: "unit_testing", KeyId: "privateKey.id", Email: "privateKey.userID", @@ -37,7 +37,7 @@ var ( Url: "test_url", OidcUrl: "test_oidc", } - testContextForUpdate = &pb.Context{ + testConfigForUpdate = &nbictlpb.Config{ Name: "test update", KeyId: "update_key_id", Email: "update_user_id", @@ -45,41 +45,41 @@ var ( Url: "update_url", OidcUrl: "update_oidc", } - testContexts = &pb.NbiCtlConfig{ - Contexts: []*pb.Context{testContext}, + testConfigs = &nbictlpb.AppConfig{ + Configs: []*nbictlpb.Config{testConfig}, } ) -func TestGetContext(t *testing.T) { +func TestGetConfig(t *testing.T) { t.Parallel() nbictlConfig, err := bazel.NewTmpDir("nbictl") checkErr(t, err) nbictlConfig = filepath.Join(nbictlConfig, confFileName) - err = setContext(testContext, nbictlConfig) + err = setConfig(testConfig, nbictlConfig) checkErr(t, err) - got, err := GetContext(testContext.GetName(), nbictlConfig) + got, err := GetConfig(testConfig.GetName(), nbictlConfig) checkErr(t, err) - assertProtosEqual(t, testContext, got) + assertProtosEqual(t, testConfig, got) } -func TestGetContext_WithOnlyOneContextInConfig(t *testing.T) { +func TestGetConfig_WithOnlyOneContextInConfig(t *testing.T) { t.Parallel() nbictlConfig, err := bazel.NewTmpDir("nbictl") checkErr(t, err) nbictlConfig = filepath.Join(nbictlConfig, confFileName) - checkErr(t, setContext(testContext, nbictlConfig)) + checkErr(t, setConfig(testConfig, nbictlConfig)) - got, err := GetContext("", nbictlConfig) + got, err := GetConfig("", nbictlConfig) checkErr(t, err) - assertProtosEqual(t, testContext, got) + assertProtosEqual(t, testConfig, got) } -func TestGetContext_WithNoContextWithMultipleContextInConfig(t *testing.T) { +func TestGetConfig_WithNoContextWithMultipleContextInConfig(t *testing.T) { t.Parallel() nbictlConfig, err := bazel.NewTmpDir("nbictl") checkErr(t, err) @@ -93,20 +93,20 @@ func TestGetContext_WithNoContextWithMultipleContextInConfig(t *testing.T) { wantErrMsg := "--context flag required because there are multiple contexts defined in the configuration." for _, contextToCreate := range contextsToCreate { - context := &pb.Context{ + context := &nbictlpb.Config{ Name: contextToCreate, } - checkErr(t, setContext(context, nbictlConfig)) + checkErr(t, setConfig(context, nbictlConfig)) } - _, err = GetContext("", nbictlConfig) + _, err = GetConfig("", nbictlConfig) gotErrMsg := err.Error() if !strings.Contains(gotErrMsg, wantErrMsg) { t.Fatalf("want: %s, got %s", wantErrMsg, gotErrMsg) } } -func TestGetContexts_WithFileWithNoPermission(t *testing.T) { +func TestGetConfigs_WithFileWithNoPermission(t *testing.T) { t.Parallel() nbictlConfig, err := bazel.NewTmpDir("nbictl") checkErr(t, err) @@ -118,14 +118,14 @@ func TestGetContexts_WithFileWithNoPermission(t *testing.T) { checkErr(t, os.Chmod(nbictlConfig, 0000)) - if _, err = getContexts(nbictlConfig); err == nil { + if _, err = getConfigs(nbictlConfig); err == nil { t.Fatal("unable to detect that issues with selected config file") checkErr(t, os.Remove(nbictlConfig)) t.FailNow() } } -func TestGetContext_WithNonExistingContextName(t *testing.T) { +func TestGetConfig_WithNonExistingContextName(t *testing.T) { t.Parallel() nbictlConfig, err := bazel.NewTmpDir("nbictl") checkErr(t, err) @@ -140,20 +140,20 @@ func TestGetContext_WithNonExistingContextName(t *testing.T) { wantErrMsg := fmt.Sprintf("unable to get the context with the name: %s. the list of available context names are the following: %v", nonExistingContext, contextsToCreate) for _, contextToCreate := range contextsToCreate { - context := &pb.Context{ + context := &nbictlpb.Config{ Name: contextToCreate, } - checkErr(t, setContext(context, nbictlConfig)) + checkErr(t, setConfig(context, nbictlConfig)) } - _, err = GetContext(nonExistingContext, nbictlConfig) + _, err = GetConfig(nonExistingContext, nbictlConfig) gotErrMsg := err.Error() if !strings.Contains(gotErrMsg, wantErrMsg) { t.Fatalf("want: %s, got %s", wantErrMsg, gotErrMsg) } } -func TestSetContext_WithNoUpdate(t *testing.T) { +func TestSetConfig_WithNoUpdate(t *testing.T) { t.Parallel() // create a temporary directory nbictlConfig, err := bazel.NewTmpDir("nbictl") @@ -161,22 +161,22 @@ func TestSetContext_WithNoUpdate(t *testing.T) { nbictlConfig = filepath.Join(nbictlConfig, confFileName) // initial setup - checkErr(t, setContext(testContext, nbictlConfig)) - wantContexts, err := getContexts(nbictlConfig) + checkErr(t, setConfig(testConfig, nbictlConfig)) + wantContexts, err := getConfigs(nbictlConfig) checkErr(t, err) - assertProtosEqual(t, testContexts, wantContexts) + assertProtosEqual(t, testConfigs, wantContexts) - contextWithNoChange := &pb.Context{ - Name: testContext.GetName(), + contextWithNoChange := &nbictlpb.Config{ + Name: testConfig.GetName(), } - checkErr(t, setContext(contextWithNoChange, nbictlConfig)) + checkErr(t, setConfig(contextWithNoChange, nbictlConfig)) - gotContexts, err := getContexts(nbictlConfig) + gotContexts, err := getConfigs(nbictlConfig) checkErr(t, err) assertProtosEqual(t, wantContexts, gotContexts) } -func TestSetContext_UpdatePrivateKey(t *testing.T) { +func TestSetConfig_UpdatePrivateKey(t *testing.T) { t.Parallel() // create a temporary directory nbictlConfig, err := bazel.NewTmpDir("nbictl") @@ -184,28 +184,28 @@ func TestSetContext_UpdatePrivateKey(t *testing.T) { nbictlConfig = filepath.Join(nbictlConfig, confFileName) // initial setup - checkErr(t, setContext(testContext, nbictlConfig)) - checkErr(t, setContext(testContextForUpdate, nbictlConfig)) + checkErr(t, setConfig(testConfig, nbictlConfig)) + checkErr(t, setConfig(testConfigForUpdate, nbictlConfig)) // update the existing context with a new private key - checkErr(t, setContext(&pb.Context{ - Name: testContext.GetName(), + checkErr(t, setConfig(&nbictlpb.Config{ + Name: testConfig.GetName(), PrivKey: "private_key.updated", }, nbictlConfig)) - updatedContext := proto.Clone(testContext).(*pb.Context) - updatedContext.PrivKey = "private_key.updated" - wantContexts := &pb.NbiCtlConfig{ - Contexts: []*pb.Context{updatedContext, testContextForUpdate}, + updatedConfig := proto.Clone(testConfig).(*nbictlpb.Config) + updatedConfig.PrivKey = "private_key.updated" + wantContexts := &nbictlpb.AppConfig{ + Configs: []*nbictlpb.Config{updatedConfig, testConfigForUpdate}, } // check if the private key is updated - gotContexts, err := getContexts(nbictlConfig) + gotContexts, err := getConfigs(nbictlConfig) checkErr(t, err) assertProtosEqual(t, wantContexts, gotContexts) } -func TestSetContext_UpdateKeyId(t *testing.T) { +func TestSetConfig_UpdateKeyId(t *testing.T) { t.Parallel() // create a temporary directory nbictlConfig, err := bazel.NewTmpDir("nbictl") @@ -213,29 +213,29 @@ func TestSetContext_UpdateKeyId(t *testing.T) { nbictlConfig = filepath.Join(nbictlConfig, confFileName) // initial setup - checkErr(t, setContext(testContext, nbictlConfig)) - checkErr(t, setContext(testContextForUpdate, nbictlConfig)) + checkErr(t, setConfig(testConfig, nbictlConfig)) + checkErr(t, setConfig(testConfigForUpdate, nbictlConfig)) // update the existing context with a new key id - checkErr(t, setContext(&pb.Context{ - Name: testContextForUpdate.GetName(), + checkErr(t, setConfig(&nbictlpb.Config{ + Name: testConfigForUpdate.GetName(), KeyId: "key_id.updated", }, nbictlConfig)) - updatedContext := proto.Clone(testContextForUpdate).(*pb.Context) - updatedContext.KeyId = "key_id.updated" - wantContexts := &pb.NbiCtlConfig{ - Contexts: []*pb.Context{testContext, updatedContext}, + updatedConfig := proto.Clone(testConfigForUpdate).(*nbictlpb.Config) + updatedConfig.KeyId = "key_id.updated" + wantContexts := &nbictlpb.AppConfig{ + Configs: []*nbictlpb.Config{testConfig, updatedConfig}, } // check if the key id is updated - gotContexts, err := getContexts(nbictlConfig) + gotContexts, err := getConfigs(nbictlConfig) checkErr(t, err) assertProtosEqual(t, wantContexts, gotContexts) } -func TestSetContext_UpdateUserID(t *testing.T) { +func TestSetConfig_UpdateUserID(t *testing.T) { t.Parallel() // create a temporary directory nbictlConfig, err := bazel.NewTmpDir("nbictl") @@ -243,30 +243,30 @@ func TestSetContext_UpdateUserID(t *testing.T) { nbictlConfig = filepath.Join(nbictlConfig, confFileName) // initial setup - checkErr(t, setContext(testContext, nbictlConfig)) - checkErr(t, setContext(testContextForUpdate, nbictlConfig)) + checkErr(t, setConfig(testConfig, nbictlConfig)) + checkErr(t, setConfig(testConfigForUpdate, nbictlConfig)) // update the existing context with a new user id - checkErr(t, setContext(&pb.Context{ - Name: testContext.GetName(), + checkErr(t, setConfig(&nbictlpb.Config{ + Name: testConfig.GetName(), Email: "email.updated", }, nbictlConfig)) - updatedContext := proto.Clone(testContext).(*pb.Context) - updatedContext.Email = "email.updated" - wantContexts := &pb.NbiCtlConfig{ - Contexts: []*pb.Context{updatedContext, testContextForUpdate}, + updatedConfig := proto.Clone(testConfig).(*nbictlpb.Config) + updatedConfig.Email = "email.updated" + wantContexts := &nbictlpb.AppConfig{ + Configs: []*nbictlpb.Config{updatedConfig, testConfigForUpdate}, } // check if the user id is updated - gotContexts, err := getContexts(nbictlConfig) + gotContexts, err := getConfigs(nbictlConfig) checkErr(t, err) assertProtosEqual(t, wantContexts, gotContexts) } -func TestSetContext_UpdateUrl(t *testing.T) { +func TestSetConfig_UpdateUrl(t *testing.T) { t.Parallel() // create a temporary directory nbictlConfig, err := bazel.NewTmpDir("nbictl") @@ -274,27 +274,27 @@ func TestSetContext_UpdateUrl(t *testing.T) { nbictlConfig = filepath.Join(nbictlConfig, confFileName) // initial setup - checkErr(t, setContext(testContext, nbictlConfig)) - checkErr(t, setContext(testContextForUpdate, nbictlConfig)) + checkErr(t, setConfig(testConfig, nbictlConfig)) + checkErr(t, setConfig(testConfigForUpdate, nbictlConfig)) // update the existing context with a new url - checkErr(t, setContext(&pb.Context{ - Name: testContext.GetName(), + checkErr(t, setConfig(&nbictlpb.Config{ + Name: testConfig.GetName(), Url: "url.updated", }, nbictlConfig)) - updatedContext := proto.Clone(testContext).(*pb.Context) - updatedContext.Url = "url.updated" - wantContexts := &pb.NbiCtlConfig{ - Contexts: []*pb.Context{updatedContext, testContextForUpdate}, + updatedConfig := proto.Clone(testConfig).(*nbictlpb.Config) + updatedConfig.Url = "url.updated" + wantContexts := &nbictlpb.AppConfig{ + Configs: []*nbictlpb.Config{updatedConfig, testConfigForUpdate}, } // check if the url is updated - gotContexts, err := getContexts(nbictlConfig) + gotConfigs, err := getConfigs(nbictlConfig) checkErr(t, err) - assertProtosEqual(t, wantContexts, gotContexts) + assertProtosEqual(t, wantContexts, gotConfigs) } func checkErr(t *testing.T, err error) { diff --git a/tools/nbictl/set_context.go b/tools/nbictl/set_context.go deleted file mode 100644 index 7017f6a..0000000 --- a/tools/nbictl/set_context.go +++ /dev/null @@ -1,182 +0,0 @@ -// Copyright 2023 Aalyria Technologies, Inc., and its affiliates. -// -// 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 nbictl - -import ( - "context" - "errors" - "flag" - "fmt" - "os" - "path/filepath" - - pb "aalyria.com/spacetime/github/tools/nbictl/resource" - "google.golang.org/protobuf/encoding/prototext" -) - -const confFileName = "config.textproto" - -func GetContext(context, contextDir string) (*pb.Context, error) { - configContexts, err := getContexts(contextDir) - if err != nil { - return nil, fmt.Errorf("unable to get config contexts: %w", err) - } - - // if the context name is not specified and there is only one context in the config file - // the function will return that configuration context - if context == "" { - switch { - case len(configContexts.GetContexts()) == 1: - return configContexts.GetContexts()[0], nil - default: - return nil, errors.New("--context flag required because there are multiple contexts defined in the configuration.") - } - } - - var contextNames []string - for _, ctx := range configContexts.GetContexts() { - if ctx.GetName() == context { - return ctx, nil - } - contextNames = append(contextNames, ctx.GetName()) - } - return nil, fmt.Errorf("unable to get the context with the name: %s. the list of available context names are the following: %v", context, contextNames) -} - -func getContexts(contextFilePath string) (*pb.NbiCtlConfig, error) { - configBytes, err := os.ReadFile(contextFilePath) - configProto := &pb.NbiCtlConfig{} - if err != nil { - if os.IsNotExist(err) { - return configProto, nil - } - return nil, fmt.Errorf("unable to read file: %w", err) - } - - if err := prototext.Unmarshal(configBytes, configProto); err != nil { - return nil, fmt.Errorf("invalid file content: %w", err) - } - return configProto, nil -} - -func SetContext(ctx context.Context, args []string) error { - fs := flag.NewFlagSet(clientName+" set-context", flag.ExitOnError) - contextName := fs.String("context", "DEFAULT", "context of NBI API environment") - privKey := fs.String("priv_key", "", "path to your private key for authentication to NBI API") - keyID := fs.String("key_id", "", "key id associated with the provate key provided by Aalyria") - userID := fs.String("user_id", "", "user id address associated with the private key provided by Aalyria") - url := fs.String("url", "", "url of NBI endpoint") - transportSecurity := fs.String("transport_security", "", "transport security to use when connecting to NBI. Values: insecure, system_cert_pool") - fs.Parse(args) - - configDir, err := os.UserConfigDir() - if err != nil { - return fmt.Errorf("unable to obtain the default root directory: %w", err) - } - filePath := filepath.Join(configDir, clientName, confFileName) - - var transportSecurityPb *pb.Context_TransportSecurity - - switch *transportSecurity { - case "insecure": - transportSecurityPb = &pb.Context_TransportSecurity{ - Type: &pb.Context_TransportSecurity_Insecure{}, - } - - case "system_cert_pool": - transportSecurityPb = &pb.Context_TransportSecurity{ - Type: &pb.Context_TransportSecurity_SystemCertPool{}, - } - - case "": - transportSecurityPb = nil - - default: - return fmt.Errorf("unexpected transport security selection: %s", *transportSecurity) - } - - contextToCreate := &pb.Context{ - Name: *contextName, - KeyId: *keyID, - Email: *userID, - PrivKey: *privKey, - Url: *url, - TransportSecurity: transportSecurityPb, - } - - return setContext(contextToCreate, filePath) -} - -func setContext(contextToCreate *pb.Context, contextFile string) error { - if contextToCreate.GetName() == "" { - return errors.New("--context required") - } - - configProto, err := getContexts(contextFile) - if err != nil { - return fmt.Errorf("unable to get contexts: %w", err) - } - - found := false - for _, confProto := range configProto.GetContexts() { - if confProto.GetName() != contextToCreate.GetName() { - continue - } - if contextToCreate.GetEmail() != "" { - confProto.Email = contextToCreate.GetEmail() - } - if contextToCreate.GetPrivKey() != "" { - confProto.PrivKey = contextToCreate.GetPrivKey() - } - if contextToCreate.GetKeyId() != "" { - confProto.KeyId = contextToCreate.GetKeyId() - } - if contextToCreate.GetUrl() != "" { - confProto.Url = contextToCreate.GetUrl() - } - if contextToCreate.GetTransportSecurity() != nil { - confProto.TransportSecurity = contextToCreate.GetTransportSecurity() - } - found = true - contextToCreate = confProto - break - } - - if !found { - configProto.Contexts = append(configProto.Contexts, contextToCreate) - } - - nbiConfigTextProto, err := prototext.Marshal(configProto) - if err != nil { - return fmt.Errorf("unable to convert proto into textproto format: %w", err) - } - - contextDir := filepath.Dir(contextFile) - if err = os.MkdirAll(contextDir, 0o777); err != nil { - return fmt.Errorf("unable to create directory: %w", err) - } - - if err = os.WriteFile(contextFile, nbiConfigTextProto, 0o777); err != nil { - return fmt.Errorf("unable to update the configuration information: %w", err) - } - - protoMessage, err := prototext.MarshalOptions{Multiline: true}.Marshal(contextToCreate) - if err != nil { - return fmt.Errorf("unable to convert the nbictl context into textproto format: %w", err) - } - fmt.Printf("context successfully set. the configuration file is stored under: %s\n", contextFile) - fmt.Printf("context: %v\n", string(protoMessage)) - return nil -}