From 23dfc53eaf35d877975c61a714e52b0353965196 Mon Sep 17 00:00:00 2001 From: Dawei Huang Date: Thu, 5 Feb 2026 09:26:37 -0600 Subject: [PATCH] Add support for gNOI File Services (#406) Signed-off-by: Dawei Huang --- gnmi_server/gnoi.go | 74 ---- gnmi_server/gnoi_file.go | 148 +++++++ gnmi_server/gnoi_file_test.go | 402 ++++++++++++++++++ gnoi_client/config/flag.go | 2 + gnoi_client/file/file.go | 210 ++++++++- gnoi_client/gnoi_client.go | 8 + go.mod | 5 +- sonic_service_client/dbus_fake_client.go | 44 ++ sonic_service_client/dbus_fake_client_test.go | 36 ++ 9 files changed, 851 insertions(+), 78 deletions(-) create mode 100644 gnmi_server/gnoi_file.go create mode 100644 gnmi_server/gnoi_file_test.go create mode 100644 sonic_service_client/dbus_fake_client.go create mode 100644 sonic_service_client/dbus_fake_client_test.go diff --git a/gnmi_server/gnoi.go b/gnmi_server/gnoi.go index dd3b39ab..796d9279 100644 --- a/gnmi_server/gnoi.go +++ b/gnmi_server/gnoi.go @@ -5,7 +5,6 @@ import ( "encoding/json" jwt "github.com/dgrijalva/jwt-go" log "github.com/golang/glog" - gnoi_file_pb "github.com/openconfig/gnoi/file" gnoi_os_pb "github.com/openconfig/gnoi/os" spb "github.com/sonic-net/sonic-gnmi/proto/gnoi" spb_jwt "github.com/sonic-net/sonic-gnmi/proto/gnoi/jwt" @@ -15,7 +14,6 @@ import ( "google.golang.org/grpc/status" "os" "os/user" - "strconv" "strings" "time" ) @@ -24,78 +22,6 @@ const ( stateDB string = "STATE_DB" ) -func ReadFileStat(path string) (*gnoi_file_pb.StatInfo, error) { - sc, err := ssc.NewDbusClient() - if err != nil { - return nil, err - } - defer sc.Close() - - log.V(2).Infof("Reading file stat at path %s...", path) - data, err := sc.GetFileStat(path) - if err != nil { - log.V(2).Infof("Failed to read file stat at path %s: %v. Error ", path, err) - return nil, err - } - // Parse the data and populate StatInfo - lastModified, err := strconv.ParseUint(data["last_modified"], 10, 64) - if err != nil { - return nil, err - } - - permissions, err := strconv.ParseUint(data["permissions"], 8, 32) - if err != nil { - return nil, err - } - - size, err := strconv.ParseUint(data["size"], 10, 64) - if err != nil { - return nil, err - } - - umaskStr := data["umask"] - if strings.HasPrefix(umaskStr, "o") { - umaskStr = umaskStr[1:] // Remove leading "o" - } - umask, err := strconv.ParseUint(umaskStr, 8, 32) - if err != nil { - return nil, err - } - - statInfo := &gnoi_file_pb.StatInfo{ - Path: data["path"], - LastModified: lastModified, - Permissions: uint32(permissions), - Size: size, - Umask: uint32(umask), - } - return statInfo, nil -} - -func (srv *FileServer) Stat(ctx context.Context, req *gnoi_file_pb.StatRequest) (*gnoi_file_pb.StatResponse, error) { - _, err := authenticate(srv.config, ctx, "gnoi", false) - if err != nil { - return nil, err - } - path := req.GetPath() - log.V(1).Info("gNOI: Read File Stat") - log.V(1).Info("Request: ", req) - statInfo, err := ReadFileStat(path) - if err != nil { - return nil, err - } - resp := &gnoi_file_pb.StatResponse{ - Stats: []*gnoi_file_pb.StatInfo{statInfo}, - } - return resp, nil -} - -// TODO: Support GNOI File Get -func (srv *FileServer) Get(req *gnoi_file_pb.GetRequest, stream gnoi_file_pb.File_GetServer) error { - log.V(1).Info("gNOI: File Get") - return status.Errorf(codes.Unimplemented, "") -} - func (srv *OSServer) Verify(ctx context.Context, req *gnoi_os_pb.VerifyRequest) (*gnoi_os_pb.VerifyResponse, error) { _, err := authenticate(srv.config, ctx, "gnoi", false) if err != nil { diff --git a/gnmi_server/gnoi_file.go b/gnmi_server/gnoi_file.go new file mode 100644 index 00000000..27a45398 --- /dev/null +++ b/gnmi_server/gnoi_file.go @@ -0,0 +1,148 @@ +package gnmi + +import ( + "context" + "strconv" + "strings" + + log "github.com/golang/glog" + gnoi_file_pb "github.com/openconfig/gnoi/file" + ssc "github.com/sonic-net/sonic-gnmi/sonic_service_client" + + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +func (srv *FileServer) Stat(ctx context.Context, req *gnoi_file_pb.StatRequest) (*gnoi_file_pb.StatResponse, error) { + log.Infof("GNOI File Stat RPC called with request: %+v", req) + _, err := authenticate(srv.config, ctx, "gnoi", false) + if err != nil { + log.Errorf("authentication failed in Stat RPC: %v", err) + return nil, err + } + path := req.GetPath() + log.V(1).Info("Request: ", req) + statInfo, err := readFileStat(path) + if err != nil { + log.Errorf("readFileStat error: %v", err) + return nil, err + } + resp := &gnoi_file_pb.StatResponse{ + Stats: []*gnoi_file_pb.StatInfo{statInfo}, + } + return resp, nil +} + +func readFileStat(path string) (*gnoi_file_pb.StatInfo, error) { + sc, err := ssc.NewDbusClient() + if err != nil { + log.Errorf("DbusClient init failed: %v", err) + return nil, status.Errorf(codes.Internal, "DBus client init failed: %v", err) + } + defer sc.Close() + data, err := sc.GetFileStat(path) + if err != nil { + log.V(2).Infof("Failed to read file stat at path %s: %v. Error ", path, err) + return nil, err + } + // Parse the data and populate StatInfo + lastModified, err := strconv.ParseUint(data["last_modified"], 10, 64) + if err != nil { + log.Errorf("Stat Fails on Invalid last_modified %v", err) + return nil, err + } + + permissions, err := strconv.ParseUint(data["permissions"], 8, 32) + if err != nil { + log.Errorf("Stat Fails on Invalid permissions: %v", err) + return nil, err + } + + size, err := strconv.ParseUint(data["size"], 10, 64) + if err != nil { + log.Errorf("Stat Fails on Invalid size: %v", err) + return nil, err + } + + umaskStr := data["umask"] + if strings.HasPrefix(umaskStr, "o") { + umaskStr = umaskStr[1:] // Remove leading "o" + } + umask, err := strconv.ParseUint(umaskStr, 8, 32) + if err != nil { + log.Errorf("Stat Fails on Invalid umaskStr: %v", err) + return nil, err + } + + statInfo := &gnoi_file_pb.StatInfo{ + Path: data["path"], + LastModified: lastModified, + Permissions: uint32(permissions), + Size: size, + Umask: uint32(umask), + } + return statInfo, nil +} + +// Get RPC is unimplemented. +func (srv *FileServer) Get(req *gnoi_file_pb.GetRequest, stream gnoi_file_pb.File_GetServer) error { + log.Infof("GNOI File Get RPC called with request: %+v", req) + _, err := authenticate(srv.config, stream.Context(), "gnoi", false) + if err != nil { + log.Errorf("authentication failed in Get RPC: %v", err) + return err + } + log.Warning("file.Get RPC is unimplemented") + return status.Errorf(codes.Unimplemented, "Method file.Get is unimplemented.") +} + +// TransferToRemote RPC is unimplemented. +func (srv *FileServer) TransferToRemote(ctx context.Context, req *gnoi_file_pb.TransferToRemoteRequest) (*gnoi_file_pb.TransferToRemoteResponse, error) { + log.Infof("GNOI File TransferToRemote RPC called with request: %+v", req) + _, err := authenticate(srv.config, ctx, "gnoi", false) + if err != nil { + log.Errorf("authentication failed in TransferToRemote RPC: %v", err) + return nil, err + } + log.Warning("file.TransferToRemote RPC is unimplemented") + return nil, status.Errorf(codes.Unimplemented, "Method file.TransferToRemote is unimplemented.") +} + +// Put RPC is unimplemented. +func (srv *FileServer) Put(stream gnoi_file_pb.File_PutServer) error { + log.Infof("GNOI File Put RPC called") + _, err := authenticate(srv.config, stream.Context(), "gnoi", false) + if err != nil { + log.Errorf("authentication failed in Put RPC: %v", err) + return err + } + log.Warning("file.Put RPC is unimplemented") + return status.Errorf(codes.Unimplemented, "Method file.Put is unimplemented.") +} + +// Remove implements the corresponding RPC. +func (srv *FileServer) Remove(ctx context.Context, req *gnoi_file_pb.RemoveRequest) (*gnoi_file_pb.RemoveResponse, error) { + log.Infof("GNOI File Remove RPC called with request: %+v", req) + if req == nil { + log.Errorf("Nil request received") + return nil, status.Error(codes.InvalidArgument, "Invalid nil request.") + } + _, err := authenticate(srv.config, ctx, "gnoi", false) + if err != nil { + log.Errorf("authentication failed in Remove RPC: %v", err) + return nil, err + } + if req.GetRemoteFile() == "" { + log.Errorf("Invalid request: remote_file field is empty") + return nil, status.Error(codes.InvalidArgument, "Invalid request: remote_file field is empty.") + } + sc, err := ssc.NewDbusClient() + if err != nil { + log.Errorf("NewDbusClient error: %v", err) + return nil, err + } + defer sc.Close() + err = sc.RemoveFile(req.GetRemoteFile()) + log.Errorf("Remove RPC failed: %v", err) + return &gnoi_file_pb.RemoveResponse{}, err +} diff --git a/gnmi_server/gnoi_file_test.go b/gnmi_server/gnoi_file_test.go new file mode 100644 index 00000000..21dbdf33 --- /dev/null +++ b/gnmi_server/gnoi_file_test.go @@ -0,0 +1,402 @@ +package gnmi + +import ( + "context" + "fmt" + "net" + "reflect" + "testing" + + "github.com/agiledragon/gomonkey/v2" + gnoi_common "github.com/openconfig/gnoi/common" + gnoi_file_pb "github.com/openconfig/gnoi/file" + ssc "github.com/sonic-net/sonic-gnmi/sonic_service_client" + + "github.com/stretchr/testify/assert" + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/credentials/insecure" + "google.golang.org/grpc/status" +) + +// === Test Setup Helpers === +func createFileServer(t *testing.T, port int) *grpc.Server { + listener, err := net.Listen("tcp", fmt.Sprintf(":%d", port)) + if err != nil { + t.Fatalf("Failed to listen: %v", err) + } + + s := grpc.NewServer() + fileServer := &FileServer{ + Server: &Server{ + config: &Config{}, // Add config fields if required + }, + } + gnoi_file_pb.RegisterFileServer(s, fileServer) + + go func() { + if err := s.Serve(listener); err != nil { + t.Errorf("Failed to serve: %v", err) + } + }() + + return s +} + +// === Actual Tests === +func TestGnoiFileServer(t *testing.T) { + s := createFileServer(t, 8081) + defer s.Stop() + + //tlsConfig := &tls.Config{InsecureSkipVerify: true} + //opts := []grpc.DialOption{grpc.WithTransportCredentials(credentials.NewTLS(tlsConfig))} + opts := []grpc.DialOption{grpc.WithTransportCredentials(insecure.NewCredentials())} + + conn, err := grpc.Dial("127.0.0.1:8081", opts...) + if err != nil { + t.Fatalf("Failed to dial server: %v", err) + } + defer conn.Close() + + client := gnoi_file_pb.NewFileClient(conn) + + t.Run("Stat Success", func(t *testing.T) { + patch1 := gomonkey.ApplyFuncReturn(authenticate, nil, nil) + patch2 := gomonkey.ApplyFuncReturn(ssc.NewDbusClient, &ssc.FakeClient{}, nil) + defer patch1.Reset() + defer patch2.Reset() + + req := &gnoi_file_pb.StatRequest{Path: "/tmp/test.txt"} + resp, err := client.Stat(context.Background(), req) + if err != nil { + t.Fatalf("Expected success, got error: %v", err) + } + if len(resp.GetStats()) == 0 || resp.Stats[0].Path != "/tmp/test.txt" { + t.Fatalf("Unexpected Stat response: %+v", resp) + } + }) + + t.Run("Stat Fails with Auth Error", func(t *testing.T) { + patch := gomonkey.ApplyFuncReturn(authenticate, nil, status.Error(codes.Unauthenticated, "unauth")) + defer patch.Reset() + + req := &gnoi_file_pb.StatRequest{Path: "/tmp/test.txt"} + _, err := client.Stat(context.Background(), req) + if err == nil || status.Code(err) != codes.Unauthenticated { + t.Fatalf("Expected unauthenticated error, got: %v", err) + } + }) + + t.Run("Stat Fails with Dbus Error", func(t *testing.T) { + patch1 := gomonkey.ApplyFuncReturn(authenticate, nil, nil) + patch2 := gomonkey.ApplyFuncReturn(ssc.NewDbusClient, nil, fmt.Errorf("dbus failure")) + defer patch1.Reset() + defer patch2.Reset() + + req := &gnoi_file_pb.StatRequest{Path: "/tmp/test.txt"} + _, err := client.Stat(context.Background(), req) + if err == nil || status.Code(err) != codes.Internal { + t.Fatalf("Expected internal error, got: %v", err) + } + }) + + t.Run("Stat Fails on Invalid last_modified", func(t *testing.T) { + patches := gomonkey.NewPatches() + defer patches.Reset() + + badClient := &ssc.FakeClient{} + patches.ApplyFuncReturn(authenticate, nil, nil) + patches.ApplyFuncReturn(ssc.NewDbusClient, badClient, nil) + patches.ApplyMethod(reflect.TypeOf(badClient), "GetFileStat", func(_ *ssc.FakeClient, path string) (map[string]string, error) { + return map[string]string{ + "path": path, + "last_modified": "not_a_number", + "permissions": "644", + "size": "100", + "umask": "022", + }, nil + }) + + _, err := readFileStat("/path/to/file") + assert.Error(t, err) + assert.Contains(t, err.Error(), "invalid syntax") + }) + + t.Run("Stat Fails on Invalid permissions", func(t *testing.T) { + patches := gomonkey.NewPatches() + defer patches.Reset() + + badClient := &ssc.FakeClient{} + patches.ApplyFuncReturn(authenticate, nil, nil) + patches.ApplyFuncReturn(ssc.NewDbusClient, badClient, nil) + patches.ApplyMethod(reflect.TypeOf(badClient), "GetFileStat", func(_ *ssc.FakeClient, path string) (map[string]string, error) { + return map[string]string{ + "path": path, + "last_modified": "1686999999", + "permissions": "xyz", + "size": "100", + "umask": "022", + }, nil + }) + + _, err := readFileStat("/path/to/file") + assert.Error(t, err) + assert.Contains(t, err.Error(), "invalid syntax") + }) + + t.Run("Stat Fails on Invalid size", func(t *testing.T) { + patches := gomonkey.NewPatches() + defer patches.Reset() + + badClient := &ssc.FakeClient{} + patches.ApplyFuncReturn(authenticate, nil, nil) + patches.ApplyFuncReturn(ssc.NewDbusClient, badClient, nil) + patches.ApplyMethod(reflect.TypeOf(badClient), "GetFileStat", func(_ *ssc.FakeClient, path string) (map[string]string, error) { + return map[string]string{ + "path": path, + "last_modified": "1686999999", + "permissions": "644", + "size": "abc", + "umask": "022", + }, nil + }) + + _, err := readFileStat("/path/to/file") + assert.Error(t, err) + assert.Contains(t, err.Error(), "invalid syntax") + }) + + t.Run("Stat Fails on Invalid umask", func(t *testing.T) { + patches := gomonkey.NewPatches() + defer patches.Reset() + + badClient := &ssc.FakeClient{} + patches.ApplyFuncReturn(authenticate, nil, nil) + patches.ApplyFuncReturn(ssc.NewDbusClient, badClient, nil) + patches.ApplyMethod(reflect.TypeOf(badClient), "GetFileStat", func(_ *ssc.FakeClient, path string) (map[string]string, error) { + return map[string]string{ + "path": path, + "last_modified": "1686999999", + "permissions": "644", + "size": "100", + "umask": "oXYZ", + }, nil + }) + + _, err := readFileStat("/path/to/file") + assert.Error(t, err) + assert.Contains(t, err.Error(), "invalid syntax") + }) + + t.Run("Put Fails with Unimplemented Error", func(t *testing.T) { + patch := gomonkey.ApplyFuncReturn(authenticate, nil, nil) + defer patch.Reset() + + putStream, err := client.Put(context.Background()) + if err != nil { + t.Fatalf("Failed to create Put stream: %v", err) + } + + // Expect Unimplemented error on CloseAndRecv + _, err = putStream.CloseAndRecv() + if err == nil || status.Code(err) != codes.Unimplemented { + t.Fatalf("Expected Unimplemented error, got: %v", err) + } + }) + t.Run("Put Fails with Auth Error", func(t *testing.T) { + patch := gomonkey.ApplyFuncReturn(authenticate, nil, status.Error(codes.Unauthenticated, "unauthenticated")) + defer patch.Reset() + + putStream, err := client.Put(context.Background()) + if err != nil { + t.Fatalf("Failed to create Put stream: %v", err) + } + + _, err = putStream.CloseAndRecv() + // This is expected because authentication fails before stream is fully established + if err == nil || status.Code(err) != codes.Unauthenticated { + t.Fatalf("Expected Unauthenticated error, got: %v", err) + } + }) + + t.Run("TransferToRemote Fails with Unimplemented Error", func(t *testing.T) { + patch := gomonkey.ApplyFuncReturn(authenticate, nil, nil) + defer patch.Reset() + + req := &gnoi_file_pb.TransferToRemoteRequest{ + LocalPath: "/tmp/test.txt", + RemoteDownload: &gnoi_common.RemoteDownload{ + Path: "https://example.com/file", + Protocol: gnoi_common.RemoteDownload_HTTPS, + }, + } + _, err := client.TransferToRemote(context.Background(), req) + if err == nil || status.Code(err) != codes.Unimplemented { + t.Fatalf("Expected Unimplemented error, got: %v", err) + } + }) + + t.Run("TransferToRemote Fails with Auth Error", func(t *testing.T) { + patch := gomonkey.ApplyFuncReturn(authenticate, nil, status.Error(codes.Unauthenticated, "unauthenticated")) + defer patch.Reset() + + req := &gnoi_file_pb.TransferToRemoteRequest{ + LocalPath: "/tmp/test.txt", + RemoteDownload: &gnoi_common.RemoteDownload{ + Path: "https://example.com/file", + Protocol: gnoi_common.RemoteDownload_HTTPS, + }, + } + _, err := client.TransferToRemote(context.Background(), req) + if err == nil || status.Code(err) != codes.Unauthenticated { + t.Fatalf("Expected Unauthenticated error, got: %v", err) + } + }) + + t.Run("Remove_Success", func(t *testing.T) { + patches := gomonkey.NewPatches() + defer patches.Reset() + + // Patch authenticate to succeed + patches.ApplyFuncReturn(authenticate, nil, nil) + + // Patch NewDbusClient to return FakeClient + patches.ApplyFuncReturn(ssc.NewDbusClient, &ssc.FakeClient{}, nil) + + fs := &FileServer{ + Server: &Server{ + config: &Config{}, + }, + } + req := &gnoi_file_pb.RemoveRequest{RemoteFile: "/tmp/test.txt"} + resp, err := fs.Remove(context.Background(), req) + + assert.NoError(t, err) + assert.NotNil(t, resp) + }) + + t.Run("Remove_Fails_NilRequest", func(t *testing.T) { + fs := &FileServer{ + Server: &Server{ + config: &Config{}, + }, + } + _, err := fs.Remove(context.Background(), nil) + assert.Error(t, err) + assert.Equal(t, codes.InvalidArgument, status.Code(err)) + }) + + t.Run("Remove_Fails_EmptyRemoteFile", func(t *testing.T) { + patches := gomonkey.NewPatches() + defer patches.Reset() + + patches.ApplyFuncReturn(authenticate, nil, nil) + + fs := &FileServer{ + Server: &Server{ + config: &Config{}, + }, + } + req := &gnoi_file_pb.RemoveRequest{RemoteFile: ""} + _, err := fs.Remove(context.Background(), req) + + assert.Error(t, err) + assert.Equal(t, codes.InvalidArgument, status.Code(err)) + }) + + t.Run("Remove_Fails_With_Auth_Error", func(t *testing.T) { + patches := gomonkey.NewPatches() + defer patches.Reset() + + // Simulate auth failure + patches.ApplyFuncReturn(authenticate, nil, status.Error(codes.PermissionDenied, "unauthenticated")) + + fs := &FileServer{ + Server: &Server{ + config: &Config{}, + }, + } + req := &gnoi_file_pb.RemoveRequest{RemoteFile: "/tmp/test.txt"} + _, err := fs.Remove(context.Background(), req) + + assert.Error(t, err) + assert.Equal(t, codes.PermissionDenied, status.Code(err)) + }) + + t.Run("Remove_Fails_With_RemoveFile_Error", func(t *testing.T) { + patches := gomonkey.NewPatches() + defer patches.Reset() + + patches.ApplyFuncReturn(authenticate, nil, nil) + + // Patch NewDbusClient to return an erroring client + patches.ApplyFuncReturn(ssc.NewDbusClient, &ssc.FakeClientWithError{}, nil) + + fs := &FileServer{ + Server: &Server{ + config: &Config{}, + }, + } + req := &gnoi_file_pb.RemoveRequest{RemoteFile: "/tmp/bad.txt"} + _, err := fs.Remove(context.Background(), req) + + assert.Error(t, err) + assert.Equal(t, "simulated failure", err.Error()) + }) + + t.Run("Remove_Fails_With_DbusClient_Error", func(t *testing.T) { + patches := gomonkey.NewPatches() + defer patches.Reset() + + patches.ApplyFuncReturn(authenticate, nil, nil) + + // Force NewDbusClient to return an error + patches.ApplyFuncReturn(ssc.NewDbusClient, nil, fmt.Errorf("mock dbus client error")) + + req := &gnoi_file_pb.RemoveRequest{ + RemoteFile: "/tmp/testfile", + } + + fs := &FileServer{ + Server: &Server{ + config: &Config{}, + }, + } + + resp, err := fs.Remove(context.Background(), req) + + assert.Nil(t, resp) + assert.Error(t, err) + assert.Contains(t, err.Error(), "mock dbus client error") + }) + + t.Run("Get_Fails_With_Auth_Error", func(t *testing.T) { + patch := gomonkey.ApplyFuncReturn(authenticate, nil, status.Error(codes.Unauthenticated, "unauthenticated")) + defer patch.Reset() + + stream, err := client.Get(context.Background(), &gnoi_file_pb.GetRequest{}) + if err == nil { + // since Get returns Unimplemented after auth, this should be hit + _, err = stream.Recv() + } + + if err == nil || status.Code(err) != codes.Unauthenticated { + t.Fatalf("Expected Unauthenticated error, got: %v", err) + } + }) + + t.Run("Get_Fails_With_Unimplemented_Error", func(t *testing.T) { + patch := gomonkey.ApplyFuncReturn(authenticate, nil, nil) + defer patch.Reset() + + stream, err := client.Get(context.Background(), &gnoi_file_pb.GetRequest{}) + if err == nil { + _, err = stream.Recv() + } + + if err == nil || status.Code(err) != codes.Unimplemented { + t.Fatalf("Expected Unimplemented error, got: %v", err) + } + }) + +} diff --git a/gnoi_client/config/flag.go b/gnoi_client/config/flag.go index 5f3f90b9..d496730c 100644 --- a/gnoi_client/config/flag.go +++ b/gnoi_client/config/flag.go @@ -11,6 +11,8 @@ var ( Args = flag.String("jsonin", "", "RPC Arguments in json format") JwtToken = flag.String("jwt_token", "", "JWT Token if required") TargetName = flag.String("target_name", "hostname.com", "The target name use to verify the hostname returned by TLS handshake") + OutputFile = flag.String("output_file", "", "Optional path to write received file data from Get RPC") + InputFile = flag.String("input_file", "", "Local input file to upload via Put RPC") ) func ParseFlag() { diff --git a/gnoi_client/file/file.go b/gnoi_client/file/file.go index 464a7a67..2a1a3286 100644 --- a/gnoi_client/file/file.go +++ b/gnoi_client/file/file.go @@ -2,30 +2,234 @@ package file import ( "context" + "crypto/sha256" "encoding/json" "fmt" + "io" + "os" + "path/filepath" + pb "github.com/openconfig/gnoi/file" + gnoitypes "github.com/openconfig/gnoi/types" "github.com/sonic-net/sonic-gnmi/gnoi_client/config" "github.com/sonic-net/sonic-gnmi/gnoi_client/utils" "google.golang.org/grpc" ) +const chunkSize = 64 * 1024 // 64 KB + +func logErrorAndExit(format string, a ...any) { + fmt.Fprintf(os.Stderr, format+"\n", a...) + os.Exit(1) +} + func Stat(conn *grpc.ClientConn, ctx context.Context) { fmt.Println("File Stat") ctx = utils.SetUserCreds(ctx) fc := pb.NewFileClient(conn) + req := &pb.StatRequest{} err := json.Unmarshal([]byte(*config.Args), req) if err != nil { - panic(err.Error()) + logErrorAndExit("Failed to parse input JSON: %v", err) } resp, err := fc.Stat(ctx, req) if err != nil { - panic(err.Error()) + logErrorAndExit("Stat RPC failed: %v", err) + } + respstr, err := json.Marshal(resp) + if err != nil { + logErrorAndExit("Failed to marshal StatResponse: %v", err) + } + fmt.Println(string(respstr)) +} + +func Get(conn *grpc.ClientConn, ctx context.Context) { + fmt.Println("File Get") + ctx = utils.SetUserCreds(ctx) + fc := pb.NewFileClient(conn) + + req := &pb.GetRequest{} + err := json.Unmarshal([]byte(*config.Args), req) + if err != nil { + logErrorAndExit("Failed to parse input JSON: %v", err) + } + + // Start Get stream + stream, err := fc.Get(ctx, req) + if err != nil { + logErrorAndExit("Get RPC failed: %v", err) + } + + // Determine output path + outputPath := *config.OutputFile + if outputPath == "" { + fileName := filepath.Base(req.GetRemoteFile()) + outputPath = filepath.Join("/tmp", fileName) + } + + outputFile, err := os.Create(outputPath) + if err != nil { + logErrorAndExit("Failed to create output file: %v", err) + } + defer outputFile.Close() + + // Receive and write chunks + for { + resp, err := stream.Recv() + if err != nil { + if err == io.EOF { + break + } + logErrorAndExit("Error receiving stream: %v", err) + } + + switch r := resp.Response.(type) { + case *pb.GetResponse_Contents: + _, err := outputFile.Write(r.Contents) + if err != nil { + logErrorAndExit("Failed to write to file: %v", err) + } + case *pb.GetResponse_Hash: + fmt.Printf("Received file hash: %v\n", r.Hash) + default: + fmt.Println("Unknown GetResponse type") + } + } + fmt.Printf("File successfully written to: %s\n", outputPath) +} + +func Put(conn *grpc.ClientConn, ctx context.Context) { + ctx = utils.SetUserCreds(ctx) + fc := pb.NewFileClient(conn) + + // Parse JSON input into PutRequest.Details + var input struct { + RemoteFile string `json:"remote_file"` + Permissions uint32 `json:"permissions"` + } + err := json.Unmarshal([]byte(*config.Args), &input) + if err != nil { + logErrorAndExit("failed to parse --jsonin: %v", err) + } + + if *config.InputFile == "" { + logErrorAndExit("--input_file is required for Put RPC") + } + + // Open the local file + f, err := os.Open(*config.InputFile) + if err != nil { + logErrorAndExit("failed to open input file %s: %v", *config.InputFile, err) + } + defer f.Close() + + stream, err := fc.Put(ctx) + if err != nil { + logErrorAndExit("Put RPC failed: %w", err) + } + + // Send the initial Open message + err = stream.Send(&pb.PutRequest{ + Request: &pb.PutRequest_Open{ + Open: &pb.PutRequest_Details{ + RemoteFile: input.RemoteFile, + Permissions: input.Permissions, + }, + }, + }) + if err != nil { + logErrorAndExit("failed to send open message: %v", err) + } + + // Send file content in chunks + hasher := sha256.New() + buf := make([]byte, chunkSize) + for { + n, err := f.Read(buf) + if err != nil && err != io.EOF { + logErrorAndExit("failed reading input file: %v", err) + } + if n == 0 { + break + } + + chunk := buf[:n] + hasher.Write(chunk) + + err = stream.Send(&pb.PutRequest{ + Request: &pb.PutRequest_Contents{ + Contents: chunk, + }, + }) + if err != nil { + logErrorAndExit("failed sending chunk: %v", err) + } + } + + // Send final hash message + hasher = sha256.New() + fileHash := hasher.Sum(nil) + err = stream.Send(&pb.PutRequest{ + Request: &pb.PutRequest_Hash{ + Hash: &gnoitypes.HashType{ + Method: gnoitypes.HashType_SHA256, + Hash: fileHash, + }, + }, + }) + if err != nil { + logErrorAndExit("failed sending hash: %v", err) + } + + // Close and receive final response + _, err = stream.CloseAndRecv() + if err != nil { + logErrorAndExit("Put RPC error on close: %v", err) + } + + fmt.Printf("Put operation succeeded. Remote file: %s\n", input.RemoteFile) +} + +func Remove(conn *grpc.ClientConn, ctx context.Context) { + fmt.Println("File Remove") + ctx = utils.SetUserCreds(ctx) + fc := pb.NewFileClient(conn) + + req := &pb.RemoveRequest{} + err := json.Unmarshal([]byte(*config.Args), req) + if err != nil { + logErrorAndExit("Failed to parse input JSON: %v", err) + } + resp, err := fc.Remove(ctx, req) + if err != nil { + logErrorAndExit("Remove RPC failed: %v", err) + } + + respstr, err := json.Marshal(resp) + if err != nil { + logErrorAndExit("Failed to marshal RemoveResponse: %v", err) + } + fmt.Println(string(respstr)) +} + +func TransferToRemote(conn *grpc.ClientConn, ctx context.Context) { + fmt.Println("File TransferToRemote") + ctx = utils.SetUserCreds(ctx) + fc := pb.NewFileClient(conn) + + req := &pb.TransferToRemoteRequest{} + err := json.Unmarshal([]byte(*config.Args), req) + if err != nil { + logErrorAndExit("Failed to parse input JSON: %v", err) + } + resp, err := fc.TransferToRemote(ctx, req) + if err != nil { + logErrorAndExit("TransferToRemote RPC failed: %v", err) } respstr, err := json.Marshal(resp) if err != nil { - panic(err.Error()) + logErrorAndExit("Failed to marshal TransferToRemoteResponse: %v", err) } fmt.Println(string(respstr)) } diff --git a/gnoi_client/gnoi_client.go b/gnoi_client/gnoi_client.go index f89506ab..d87fa832 100644 --- a/gnoi_client/gnoi_client.go +++ b/gnoi_client/gnoi_client.go @@ -53,6 +53,14 @@ func main() { switch *config.Rpc { case "Stat": file.Stat(conn, ctx) + case "Get": + file.Get(conn, ctx) + case "Put": + file.Put(conn, ctx) + case "Remove": + file.Remove(conn, ctx) + case "TransferToRemote": + file.TransferToRemote(conn, ctx) default: panic("Invalid RPC Name") } diff --git a/go.mod b/go.mod index f8a1990d..7f082045 100644 --- a/go.mod +++ b/go.mod @@ -21,6 +21,7 @@ require ( github.com/openconfig/gnmi v0.0.0-20200617225440-d2b4e6a45802 github.com/openconfig/gnoi v0.3.0 github.com/openconfig/ygot v0.7.1 + github.com/stretchr/testify v1.9.0 golang.org/x/crypto v0.24.0 golang.org/x/net v0.26.0 google.golang.org/grpc v1.64.1 @@ -35,6 +36,7 @@ require ( github.com/antchfx/xpath v1.1.10 // indirect github.com/bgentry/speakeasy v0.1.0 // indirect github.com/cenkalti/backoff/v4 v4.0.0 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect github.com/go-redis/redis/v7 v7.0.0-beta.3.0.20190824101152-d19aba07b476 // indirect github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e // indirect github.com/google/go-cmp v0.6.0 // indirect @@ -43,12 +45,13 @@ require ( github.com/onsi/gomega v1.7.1 // indirect github.com/openconfig/goyang v0.0.0-20200309174518-a00bece872fc // indirect github.com/philopon/go-toposort v0.0.0-20170620085441-9be86dbd762f // indirect - github.com/stretchr/testify v1.9.0 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect go4.org/intern v0.0.0-20211027215823-ae77deb06f29 // indirect go4.org/unsafe/assume-no-moving-gc v0.0.0-20230525183740-e7c30c78aeb2 // indirect golang.org/x/sys v0.26.0 // indirect golang.org/x/text v0.16.0 // indirect google.golang.org/genproto v0.0.0-20230410155749-daa745c078e1 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect inet.af/netaddr v0.0.0-20230525184311-b8eac61e914a // indirect ) diff --git a/sonic_service_client/dbus_fake_client.go b/sonic_service_client/dbus_fake_client.go new file mode 100644 index 00000000..47e8b1a1 --- /dev/null +++ b/sonic_service_client/dbus_fake_client.go @@ -0,0 +1,44 @@ +package host_service + +import "errors" + +// FakeClient is a mock implementation of the Service interface. +type FakeClient struct{} + +func (f *FakeClient) Close() error { return nil } +func (f *FakeClient) ConfigReload(fileName string) error { return nil } +func (f *FakeClient) ConfigReplace(fileName string) error { return nil } +func (f *FakeClient) ConfigSave(fileName string) error { return nil } +func (f *FakeClient) ApplyPatchYang(fileName string) error { return nil } +func (f *FakeClient) ApplyPatchDb(fileName string) error { return nil } +func (f *FakeClient) CreateCheckPoint(cpName string) error { return nil } +func (f *FakeClient) DeleteCheckPoint(cpName string) error { return nil } +func (f *FakeClient) StopService(service string) error { return nil } +func (f *FakeClient) RestartService(service string) error { return nil } +func (f *FakeClient) GetFileStat(path string) (map[string]string, error) { + return map[string]string{ + "path": path, + "last_modified": "1686999999", // any valid Unix timestamp + "permissions": "644", + "size": "100", + "umask": "022", + }, nil +} +func (f *FakeClient) DownloadFile(host, username, password, remotePath, localPath, protocol string) error { + return nil +} +func (f *FakeClient) RemoveFile(path string) error { return nil } +func (f *FakeClient) DownloadImage(url string, save_as string) error { return nil } +func (f *FakeClient) InstallImage(where string) error { return nil } +func (f *FakeClient) ListImages() (string, error) { return "image1", nil } +func (f *FakeClient) ActivateImage(image string) error { return nil } +func (f *FakeClient) LoadDockerImage(image string) error { return nil } + +// FakeClientWithError simulates failure in specific methods. +type FakeClientWithError struct { + FakeClient +} + +func (f *FakeClientWithError) RemoveFile(path string) error { + return errors.New("simulated failure") +} diff --git a/sonic_service_client/dbus_fake_client_test.go b/sonic_service_client/dbus_fake_client_test.go new file mode 100644 index 00000000..431aaa18 --- /dev/null +++ b/sonic_service_client/dbus_fake_client_test.go @@ -0,0 +1,36 @@ +package host_service + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestFakeClientMethods(t *testing.T) { + client := &FakeClient{} + + assert.NoError(t, client.Close()) + assert.NoError(t, client.ConfigReload("test.conf")) + assert.NoError(t, client.ConfigReplace("replace.conf")) + assert.NoError(t, client.ConfigSave("save.conf")) + assert.NoError(t, client.ApplyPatchYang("yang.patch")) + assert.NoError(t, client.ApplyPatchDb("db.patch")) + assert.NoError(t, client.CreateCheckPoint("cp1")) + assert.NoError(t, client.DeleteCheckPoint("cp1")) + assert.NoError(t, client.StopService("swss")) + assert.NoError(t, client.RestartService("bgp")) + + stat, err := client.GetFileStat("/etc/sonic/config_db.json") + assert.NoError(t, err) + assert.Equal(t, "022", stat["umask"]) + + assert.NoError(t, client.DownloadFile("host", "user", "pass", "/remote", "/local", "scp")) + assert.NoError(t, client.RemoveFile("/tmp/test")) + assert.NoError(t, client.DownloadImage("http://example.com/image", "image.bin")) + assert.NoError(t, client.InstallImage("ONIE")) + img, err := client.ListImages() + assert.NoError(t, err) + assert.Equal(t, "image1", img) + assert.NoError(t, client.ActivateImage("image1")) + assert.NoError(t, client.LoadDockerImage("docker-image")) +}