diff --git a/gnmi_server/boot_cli_test.go b/gnmi_server/boot_cli_test.go new file mode 100644 index 00000000..515038a4 --- /dev/null +++ b/gnmi_server/boot_cli_test.go @@ -0,0 +1,478 @@ +package gnmi + +import ( + "context" + "crypto/tls" + "errors" + "fmt" + "testing" + "time" + + pb "github.com/openconfig/gnmi/proto/gnmi" + + "github.com/agiledragon/gomonkey/v2" + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/credentials" + + "github.com/sonic-net/sonic-gnmi/show_client/helpers/boot_helpers" +) + +// Mock bootloader for testing +type mockBootloader struct { + name string + currentImage string + nextImage string + installedImages []string + currentErr error + nextErr error + installedErr error +} + +func (m *mockBootloader) Name() string { + return m.name +} + +func (m *mockBootloader) GetCurrentImage() (string, error) { + return m.currentImage, m.currentErr +} + +func (m *mockBootloader) GetNextImage() (string, error) { + return m.nextImage, m.nextErr +} + +func (m *mockBootloader) GetInstalledImages() ([]string, error) { + return m.installedImages, m.installedErr +} + +func TestGetShowBoot(t *testing.T) { + s := createServer(t, ServerPort) + go runServer(t, s) + defer s.ForceStop() + defer ResetDataSetsAndMappings(t) + + tlsConfig := &tls.Config{InsecureSkipVerify: true} + opts := []grpc.DialOption{grpc.WithTransportCredentials(credentials.NewTLS(tlsConfig))} + + conn, err := grpc.Dial(TargetAddr, opts...) + if err != nil { + t.Fatalf("Dialing to %q failed: %v", TargetAddr, err) + } + defer conn.Close() + + gClient := pb.NewGNMIClient(conn) + ctx, cancel := context.WithTimeout(context.Background(), QueryTimeout*time.Second) + defer cancel() + + tests := []struct { + desc string + pathTarget string + textPbPath string + wantRetCode codes.Code + wantRespVal []byte + valTest bool + setupFunc func() *gomonkey.Patches + }{ + { + desc: "query SHOW boot success", + pathTarget: "SHOW", + textPbPath: ` + elem: + `, + wantRetCode: codes.OK, + wantRespVal: []byte(`{"current":"SONiC-20240101.01","next":"SONiC-20240201.01","available":["SONiC-20240101.01","SONiC-20240201.01","SONiC-20240301.01"]}`), + valTest: true, + setupFunc: func() *gomonkey.Patches { + patches := gomonkey.NewPatches() + mockBL := &mockBootloader{ + name: "grub", + currentImage: "SONiC-20240101.01", + nextImage: "SONiC-20240201.01", + installedImages: []string{"SONiC-20240101.01", "SONiC-20240201.01", "SONiC-20240301.01"}, + } + patches.ApplyFunc(helpers.DetectBootloader, func() (helpers.Bootloader, error) { + return mockBL, nil + }) + return patches + }, + }, + { + desc: "query SHOW boot with empty installed images", + pathTarget: "SHOW", + textPbPath: ` + elem: + `, + wantRetCode: codes.OK, + wantRespVal: []byte(`{"current":"SONiC-20240101.01","next":"SONiC-20240201.01","available":[]}`), + valTest: true, + setupFunc: func() *gomonkey.Patches { + patches := gomonkey.NewPatches() + mockBL := &mockBootloader{ + name: "aboot", + currentImage: "SONiC-20240101.01", + nextImage: "SONiC-20240201.01", + installedImages: nil, // Should be converted to empty array + } + patches.ApplyFunc(helpers.DetectBootloader, func() (helpers.Bootloader, error) { + return mockBL, nil + }) + return patches + }, + }, + { + desc: "query SHOW boot with uboot", + pathTarget: "SHOW", + textPbPath: ` + elem: + `, + wantRetCode: codes.OK, + wantRespVal: []byte(`{"current":"SONiC-uboot.01","next":"SONiC-uboot.02","available":["SONiC-uboot.01","SONiC-uboot.02"]}`), + valTest: true, + setupFunc: func() *gomonkey.Patches { + patches := gomonkey.NewPatches() + mockBL := &mockBootloader{ + name: "uboot", + currentImage: "SONiC-uboot.01", + nextImage: "SONiC-uboot.02", + installedImages: []string{"SONiC-uboot.01", "SONiC-uboot.02"}, + } + patches.ApplyFunc(helpers.DetectBootloader, func() (helpers.Bootloader, error) { + return mockBL, nil + }) + return patches + }, + }, + { + desc: "query SHOW boot detect bootloader error", + pathTarget: "SHOW", + textPbPath: ` + elem: + `, + wantRetCode: codes.NotFound, + wantRespVal: nil, + valTest: false, + setupFunc: func() *gomonkey.Patches { + patches := gomonkey.NewPatches() + patches.ApplyFunc(helpers.DetectBootloader, func() (helpers.Bootloader, error) { + return nil, errors.New("no supported bootloader detected") + }) + return patches + }, + }, + { + desc: "query SHOW boot get current image error", + pathTarget: "SHOW", + textPbPath: ` + elem: + `, + wantRetCode: codes.NotFound, + wantRespVal: nil, + valTest: false, + setupFunc: func() *gomonkey.Patches { + patches := gomonkey.NewPatches() + mockBL := &mockBootloader{ + name: "grub", + currentErr: errors.New("failed to get current image"), + } + patches.ApplyFunc(helpers.DetectBootloader, func() (helpers.Bootloader, error) { + return mockBL, nil + }) + return patches + }, + }, + { + desc: "query SHOW boot get next image error", + pathTarget: "SHOW", + textPbPath: ` + elem: + `, + wantRetCode: codes.NotFound, + wantRespVal: nil, + valTest: false, + setupFunc: func() *gomonkey.Patches { + patches := gomonkey.NewPatches() + mockBL := &mockBootloader{ + name: "grub", + currentImage: "SONiC-20240101.01", + nextErr: errors.New("failed to get next image"), + } + patches.ApplyFunc(helpers.DetectBootloader, func() (helpers.Bootloader, error) { + return mockBL, nil + }) + return patches + }, + }, + { + desc: "query SHOW boot get installed images error", + pathTarget: "SHOW", + textPbPath: ` + elem: + `, + wantRetCode: codes.NotFound, + wantRespVal: nil, + valTest: false, + setupFunc: func() *gomonkey.Patches { + patches := gomonkey.NewPatches() + mockBL := &mockBootloader{ + name: "grub", + currentImage: "SONiC-20240101.01", + nextImage: "SONiC-20240201.01", + installedErr: errors.New("failed to get installed images"), + } + patches.ApplyFunc(helpers.DetectBootloader, func() (helpers.Bootloader, error) { + return mockBL, nil + }) + return patches + }, + }, + { + desc: "query SHOW boot with special characters", + pathTarget: "SHOW", + textPbPath: ` + elem: + `, + wantRetCode: codes.OK, + wantRespVal: []byte(`{"current":"SONiC-OS.2024-01-01","next":"SONiC-OS.2024-02-01","available":["SONiC-OS.2024-01-01","SONiC-OS.2024-02-01"]}`), + valTest: true, + setupFunc: func() *gomonkey.Patches { + patches := gomonkey.NewPatches() + mockBL := &mockBootloader{ + name: "grub", + currentImage: "SONiC-OS.2024-01-01", + nextImage: "SONiC-OS.2024-02-01", + installedImages: []string{"SONiC-OS.2024-01-01", "SONiC-OS.2024-02-01"}, + } + patches.ApplyFunc(helpers.DetectBootloader, func() (helpers.Bootloader, error) { + return mockBL, nil + }) + return patches + }, + }, + { + desc: "query SHOW boot with single image", + pathTarget: "SHOW", + textPbPath: ` + elem: + `, + wantRetCode: codes.OK, + wantRespVal: []byte(`{"current":"SONiC-single","next":"SONiC-single","available":["SONiC-single"]}`), + valTest: true, + setupFunc: func() *gomonkey.Patches { + patches := gomonkey.NewPatches() + mockBL := &mockBootloader{ + name: "grub", + currentImage: "SONiC-single", + nextImage: "SONiC-single", + installedImages: []string{"SONiC-single"}, + } + patches.ApplyFunc(helpers.DetectBootloader, func() (helpers.Bootloader, error) { + return mockBL, nil + }) + return patches + }, + }, + { + desc: "query SHOW boot with large image list", + pathTarget: "SHOW", + textPbPath: ` + elem: + `, + wantRetCode: codes.OK, + wantRespVal: []byte(`{"current":"SONiC-20240101.00","next":"SONiC-20240101.01","available":["SONiC-20240101.00","SONiC-20240101.01","SONiC-20240101.02","SONiC-20240101.03","SONiC-20240101.04"]}`), + valTest: true, + setupFunc: func() *gomonkey.Patches { + patches := gomonkey.NewPatches() + // Create a list of 5 images for testing + imageList := make([]string, 5) + for i := 0; i < 5; i++ { + imageList[i] = fmt.Sprintf("SONiC-20240101.%02d", i) + } + mockBL := &mockBootloader{ + name: "grub", + currentImage: "SONiC-20240101.00", + nextImage: "SONiC-20240101.01", + installedImages: imageList, + } + patches.ApplyFunc(helpers.DetectBootloader, func() (helpers.Bootloader, error) { + return mockBL, nil + }) + return patches + }, + }, + } + + for _, test := range tests { + t.Run(test.desc, func(t *testing.T) { + var patches *gomonkey.Patches + if test.setupFunc != nil { + patches = test.setupFunc() + defer patches.Reset() + } + runTestGet(t, ctx, gClient, test.pathTarget, test.textPbPath, test.wantRetCode, test.wantRespVal, test.valTest) + }) + } +} + +// Test edge cases and bootloader-specific behaviors +func TestGetShowBootEdgeCases(t *testing.T) { + s := createServer(t, ServerPort) + go runServer(t, s) + defer s.ForceStop() + defer ResetDataSetsAndMappings(t) + + tlsConfig := &tls.Config{InsecureSkipVerify: true} + opts := []grpc.DialOption{grpc.WithTransportCredentials(credentials.NewTLS(tlsConfig))} + + conn, err := grpc.Dial(TargetAddr, opts...) + if err != nil { + t.Fatalf("Dialing to %q failed: %v", TargetAddr, err) + } + defer conn.Close() + + gClient := pb.NewGNMIClient(conn) + ctx, cancel := context.WithTimeout(context.Background(), QueryTimeout*time.Second) + defer cancel() + + tests := []struct { + desc string + pathTarget string + textPbPath string + wantRetCode codes.Code + wantRespVal []byte + valTest bool + setupFunc func() *gomonkey.Patches + }{ + { + desc: "query SHOW boot with empty strings", + pathTarget: "SHOW", + textPbPath: ` + elem: + `, + wantRetCode: codes.OK, + wantRespVal: []byte(`{"current":"","next":"","available":[]}`), + valTest: true, + setupFunc: func() *gomonkey.Patches { + patches := gomonkey.NewPatches() + mockBL := &mockBootloader{ + name: "test", + currentImage: "", + nextImage: "", + installedImages: []string{}, + } + patches.ApplyFunc(helpers.DetectBootloader, func() (helpers.Bootloader, error) { + return mockBL, nil + }) + return patches + }, + }, + { + desc: "query SHOW boot with very long image names", + pathTarget: "SHOW", + textPbPath: ` + elem: + `, + wantRetCode: codes.OK, + wantRespVal: []byte(`{"current":"SONiC-very-long-image-name-with-multiple-components-2024.01.01.build.123456","next":"SONiC-very-long-image-name-with-multiple-components-2024.02.01.build.234567","available":["SONiC-very-long-image-name-with-multiple-components-2024.01.01.build.123456","SONiC-very-long-image-name-with-multiple-components-2024.02.01.build.234567"]}`), + valTest: true, + setupFunc: func() *gomonkey.Patches { + patches := gomonkey.NewPatches() + mockBL := &mockBootloader{ + name: "grub", + currentImage: "SONiC-very-long-image-name-with-multiple-components-2024.01.01.build.123456", + nextImage: "SONiC-very-long-image-name-with-multiple-components-2024.02.01.build.234567", + installedImages: []string{"SONiC-very-long-image-name-with-multiple-components-2024.01.01.build.123456", "SONiC-very-long-image-name-with-multiple-components-2024.02.01.build.234567"}, + } + patches.ApplyFunc(helpers.DetectBootloader, func() (helpers.Bootloader, error) { + return mockBL, nil + }) + return patches + }, + }, + } + + for _, test := range tests { + t.Run(test.desc, func(t *testing.T) { + var patches *gomonkey.Patches + if test.setupFunc != nil { + patches = test.setupFunc() + defer patches.Reset() + } + runTestGet(t, ctx, gClient, test.pathTarget, test.textPbPath, test.wantRetCode, test.wantRespVal, test.valTest) + }) + } +} + +// Test different bootloader types +func TestGetShowBootDifferentBootloaders(t *testing.T) { + s := createServer(t, ServerPort) + go runServer(t, s) + defer s.ForceStop() + defer ResetDataSetsAndMappings(t) + + tlsConfig := &tls.Config{InsecureSkipVerify: true} + opts := []grpc.DialOption{grpc.WithTransportCredentials(credentials.NewTLS(tlsConfig))} + + conn, err := grpc.Dial(TargetAddr, opts...) + if err != nil { + t.Fatalf("Dialing to %q failed: %v", TargetAddr, err) + } + defer conn.Close() + + gClient := pb.NewGNMIClient(conn) + ctx, cancel := context.WithTimeout(context.Background(), QueryTimeout*time.Second) + defer cancel() + + bootloaderTests := []struct { + name string + bootloaderName string + currentImage string + nextImage string + installedImages []string + expectedJSON string + }{ + { + name: "Aboot", + bootloaderName: "aboot", + currentImage: "SONiC-aboot.01", + nextImage: "SONiC-aboot.02", + installedImages: []string{"SONiC-aboot.01", "SONiC-aboot.02"}, + expectedJSON: `{"current":"SONiC-aboot.01","next":"SONiC-aboot.02","available":["SONiC-aboot.01","SONiC-aboot.02"]}`, + }, + { + name: "GRUB", + bootloaderName: "grub", + currentImage: "SONiC-grub.01", + nextImage: "SONiC-grub.02", + installedImages: []string{"SONiC-grub.01", "SONiC-grub.02"}, + expectedJSON: `{"current":"SONiC-grub.01","next":"SONiC-grub.02","available":["SONiC-grub.01","SONiC-grub.02"]}`, + }, + { + name: "U-Boot", + bootloaderName: "uboot", + currentImage: "SONiC-uboot.01", + nextImage: "SONiC-uboot.02", + installedImages: []string{"SONiC-uboot.01", "SONiC-uboot.02"}, + expectedJSON: `{"current":"SONiC-uboot.01","next":"SONiC-uboot.02","available":["SONiC-uboot.01","SONiC-uboot.02"]}`, + }, + } + + for _, bt := range bootloaderTests { + t.Run("SHOW boot with "+bt.name, func(t *testing.T) { + patches := gomonkey.NewPatches() + defer patches.Reset() + + mockBL := &mockBootloader{ + name: bt.bootloaderName, + currentImage: bt.currentImage, + nextImage: bt.nextImage, + installedImages: bt.installedImages, + } + + patches.ApplyFunc(helpers.DetectBootloader, func() (helpers.Bootloader, error) { + return mockBL, nil + }) + + runTestGet(t, ctx, gClient, "SHOW", `elem: `, codes.OK, []byte(bt.expectedJSON), true) + }) + } +} diff --git a/gnmi_server/boot_helpers_test.go b/gnmi_server/boot_helpers_test.go new file mode 100644 index 00000000..bad5fafb --- /dev/null +++ b/gnmi_server/boot_helpers_test.go @@ -0,0 +1,402 @@ +package gnmi + +import ( + "os" + "runtime" + "strings" + "testing" + "time" + + "github.com/agiledragon/gomonkey/v2" + "github.com/sonic-net/sonic-gnmi/show_client/common" + helpers "github.com/sonic-net/sonic-gnmi/show_client/helpers/boot_helpers" + sdc "github.com/sonic-net/sonic-gnmi/sonic_data_client" +) + +func TestBootHelperDetectBootloader(t *testing.T) { + tests := []struct { + name string + mockCmdline string + mockCmdlineErr error + grubCfgExists bool + expectedType string + expectError bool + }{ + { + name: "Aboot detection", + mockCmdline: "console=ttyS0,9600 Aboot=Aboot-veos-8.0.0", + grubCfgExists: false, + expectedType: "aboot", + expectError: false, + }, + { + name: "GRUB detection", + mockCmdline: "BOOT_IMAGE=/vmlinuz-4.19.0-12-amd64", + grubCfgExists: true, + expectedType: "grub", + expectError: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + patches := gomonkey.NewPatches() + defer patches.Reset() + + // Mock file operations for proc/cmdline + patches.ApplyFunc(os.ReadFile, func(name string) ([]byte, error) { + if name == "/proc/cmdline" { + if tt.mockCmdlineErr != nil { + return nil, tt.mockCmdlineErr + } + return []byte(tt.mockCmdline), nil + } + return nil, os.ErrNotExist + }) + + // Mock os.Stat for GRUB detection + patches.ApplyFunc(os.Stat, func(name string) (os.FileInfo, error) { + if strings.Contains(name, "grub.cfg") && tt.grubCfgExists { + return &mockFileInfo{name: "grub.cfg", isDir: false}, nil + } + return nil, os.ErrNotExist + }) + + bl, err := helpers.DetectBootloader() + + if tt.expectError { + if err == nil { + t.Errorf("Expected error but got none") + } + return + } + + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + + if bl.Name() != tt.expectedType { + t.Errorf("Expected bootloader %s, got %s", tt.expectedType, bl.Name()) + } + }) + } +} + +// Mock FileInfo for testing +type mockFileInfo struct { + name string + isDir bool +} + +func (m *mockFileInfo) Name() string { return m.name } +func (m *mockFileInfo) Size() int64 { return 0 } +func (m *mockFileInfo) Mode() os.FileMode { return 0 } +func (m *mockFileInfo) ModTime() time.Time { return time.Time{} } +func (m *mockFileInfo) IsDir() bool { return m.isDir } +func (m *mockFileInfo) Sys() interface{} { return nil } + +func TestBootHelperAbootBootloader(t *testing.T) { + patches := gomonkey.NewPatches() + defer patches.Reset() + + // Store original and restore after test + origImplIoutilReadFile := sdc.ImplIoutilReadFile + defer func() { sdc.ImplIoutilReadFile = origImplIoutilReadFile }() + + // Mock file operations for simplified Aboot implementation + patches.ApplyFunc(os.ReadFile, func(name string) ([]byte, error) { + if name == "/proc/cmdline" { + return []byte("loop=image-20240101.01/fs.squashfs"), nil + } + return nil, os.ErrNotExist + }) + + // Mock sdc.ImplIoutilReadFile for boot-config (used by common.ReadConfToMap) + sdc.ImplIoutilReadFile = func(filePath string) ([]byte, error) { + if filePath == "/host/boot-config" { + return []byte("# Boot configuration\nSWI=flash:/image-20240201.01/sonic.swi\n"), nil + } + return origImplIoutilReadFile(filePath) + } + + patches.ApplyFunc(os.ReadDir, func(name string) ([]os.DirEntry, error) { + if name == "/host" { + return []os.DirEntry{ + &mockDirEntry{name: "image-20240101.01", isDir: true}, + &mockDirEntry{name: "image-20240201.01", isDir: true}, + }, nil + } + return nil, os.ErrNotExist + }) + + bl := &helpers.AbootBootloader{} + + // Test Name() + if bl.Name() != "aboot" { + t.Errorf("Expected name 'aboot', got %q", bl.Name()) + } + + // Test GetCurrentImage() + current, err := bl.GetCurrentImage() + if err != nil { + t.Fatalf("GetCurrentImage error: %v", err) + } + expected := "SONiC-OS-20240101.01" + if current != expected { + t.Errorf("Expected current image %q, got %q", expected, current) + } + + // Test GetInstalledImages() + images, err := bl.GetInstalledImages() + if err != nil { + t.Fatalf("GetInstalledImages error: %v", err) + } + if len(images) != 2 { + t.Errorf("Expected 2 images, got %d", len(images)) + } + + // Test GetNextImage() + next, err := bl.GetNextImage() + if err != nil { + t.Fatalf("GetNextImage error: %v", err) + } + expected = "SONiC-OS-20240201.01" + if next != expected { + t.Errorf("Expected next image %q, got %q", expected, next) + } +} + +type mockDirEntry struct { + name string + isDir bool +} + +func (m *mockDirEntry) Name() string { return m.name } +func (m *mockDirEntry) IsDir() bool { return m.isDir } +func (m *mockDirEntry) Type() os.FileMode { return 0 } +func (m *mockDirEntry) Info() (os.FileInfo, error) { return nil, nil } + +func TestBootHelperGrubBootloader(t *testing.T) { + patches := gomonkey.NewPatches() + defer patches.Reset() + + // Mock file operations for simplified GRUB implementation + patches.ApplyFunc(os.ReadFile, func(name string) ([]byte, error) { + if name == "/proc/cmdline" { + return []byte("loop=image-grub-test.01/fs.squashfs"), nil + } + if strings.Contains(name, "grub.cfg") { + return []byte(` +menuentry 'SONiC-OS-20240101.01' { + linux /vmlinuz +} +menuentry 'SONiC-OS-20240201.01' { + linux /vmlinuz +} +`), nil + } + return nil, os.ErrNotExist + }) + + // Mock common.GetDataFromHostCommand for grub-editenv + patches.ApplyFunc(common.GetDataFromHostCommand, func(command string) (string, error) { + if strings.Contains(command, "grub-editenv") && strings.Contains(command, "list") { + return "next_entry=1\nsaved_entry=0\n", nil + } + return "", os.ErrNotExist + }) + + bl := &helpers.GrubBootloader{} + + // Test Name() + if bl.Name() != "grub" { + t.Errorf("Expected name 'grub', got %q", bl.Name()) + } + + // Test GetCurrentImage() + current, err := bl.GetCurrentImage() + if err != nil { + t.Fatalf("GetCurrentImage error: %v", err) + } + expected := "SONiC-OS-grub-test.01" + if current != expected { + t.Errorf("Expected current image %q, got %q", expected, current) + } + + // Test GetInstalledImages() + images, err := bl.GetInstalledImages() + if err != nil { + t.Fatalf("GetInstalledImages error: %v", err) + } + if len(images) != 2 { + t.Errorf("Expected 2 images, got %d", len(images)) + } + + // Test GetNextImage() + next, err := bl.GetNextImage() + if err != nil { + t.Fatalf("GetNextImage error: %v", err) + } + expected = "SONiC-OS-20240201.01" + if next != expected { + t.Errorf("Expected next image %q, got %q", expected, next) + } +} + +func TestBootHelperUbootBootloader(t *testing.T) { + patches := gomonkey.NewPatches() + defer patches.Reset() + + // Mock file operations for /proc/cmdline + patches.ApplyFunc(os.ReadFile, func(name string) ([]byte, error) { + if name == "/proc/cmdline" { + return []byte("loop=image-uboot-test.01/fs.squashfs"), nil + } + return nil, os.ErrNotExist + }) + + // Mock common.GetDataFromHostCommand for fw_printenv + patches.ApplyFunc(common.GetDataFromHostCommand, func(command string) (string, error) { + if strings.Contains(command, "fw_printenv") { + if strings.Contains(command, "sonic_version_1") { + return "SONiC-OS-20240101.01", nil + } + if strings.Contains(command, "sonic_version_2") { + return "SONiC-OS-20240201.01", nil + } + if strings.Contains(command, "boot_next") { + return "sonic_image_2", nil + } + } + return "", os.ErrNotExist + }) + + bl := &helpers.UbootBootloader{} + + // Test Name() + if bl.Name() != "uboot" { + t.Errorf("Expected name 'uboot', got %q", bl.Name()) + } + + // Test GetCurrentImage() + current, err := bl.GetCurrentImage() + if err != nil { + t.Fatalf("GetCurrentImage error: %v", err) + } + expected := "SONiC-OS-uboot-test.01" + if current != expected { + t.Errorf("Expected current image %q, got %q", expected, current) + } + + // Test GetInstalledImages() + images, err := bl.GetInstalledImages() + if err != nil { + t.Fatalf("GetInstalledImages error: %v", err) + } + if len(images) != 2 { + t.Errorf("Expected 2 images, got %d", len(images)) + } + + // Test GetNextImage() + next, err := bl.GetNextImage() + if err != nil { + t.Fatalf("GetNextImage error: %v", err) + } + expected = "SONiC-OS-20240201.01" + if next != expected { + t.Errorf("Expected next image %q, got %q", expected, next) + } +} + +func TestBootHelperIntegration(t *testing.T) { + // Test that DetectBootloader returns valid interface in current environment + if runtime.GOARCH == "amd64" { + _, err := helpers.DetectBootloader() + // We expect an error in test environment, which is fine + if err != nil { + t.Logf("Expected error in test environment: %v", err) + } + } +} + +func TestAbootCurrentImageFromCmdlineRegexFix(t *testing.T) { + tests := []struct { + name string + cmdline string + expected string + shouldError bool + description string + }{ + { + name: "Aboot flexible pattern without fs.squashfs", + cmdline: "console=ttyS0,9600 Aboot=Aboot-veos-8.0.0 loop=/image-20240101.01/ quiet", + expected: "SONiC-OS-20240101.01", + shouldError: false, + description: "Should work with aboot's flexible regex pattern (matches Python behavior)", + }, + { + name: "Aboot flexible pattern with fs.squashfs", + cmdline: "console=ttyS0,9600 Aboot=Aboot-veos-8.0.0 loop=image-20240201.02/fs.squashfs quiet", + expected: "SONiC-OS-20240201.02", + shouldError: false, + description: "Should also work with the standard onie pattern", + }, + { + name: "Aboot with multiple slashes", + cmdline: "console=ttyS0,9600 loop=//image-test.03/ quiet", + expected: "SONiC-OS-test.03", + shouldError: false, + description: "Should handle multiple slashes in loop parameter", + }, + { + name: "No loop parameter", + cmdline: "console=ttyS0,9600 quiet", + expected: "", + shouldError: true, + description: "Should fail when no loop parameter is present", + }, + { + name: "Invalid loop parameter format", + cmdline: "console=ttyS0,9600 loop=invalid-format quiet", + expected: "", + shouldError: true, + description: "Should fail when loop parameter doesn't match pattern", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + patches := gomonkey.NewPatches() + defer patches.Reset() + + // Create aboot bootloader instance + bl := &helpers.AbootBootloader{} + + // Mock readProcCmdline to return our test cmdline + patches.ApplyFunc(os.ReadFile, func(name string) ([]byte, error) { + if name == "/proc/cmdline" { + return []byte(tt.cmdline), nil + } + return nil, os.ErrNotExist + }) + + result, err := bl.GetCurrentImage() + + if tt.shouldError { + if err == nil { + t.Errorf("Expected error but got none. Test: %s", tt.description) + } + return + } + + if err != nil { + t.Errorf("Unexpected error: %v. Test: %s", err, tt.description) + return + } + + if result != tt.expected { + t.Errorf("Expected %q, got %q. Test: %s", tt.expected, result, tt.description) + } + }) + } +} diff --git a/show_client/boot_cli.go b/show_client/boot_cli.go new file mode 100644 index 00000000..166a9be1 --- /dev/null +++ b/show_client/boot_cli.go @@ -0,0 +1,50 @@ +package show_client + +import ( + "encoding/json" + + log "github.com/golang/glog" + "github.com/sonic-net/sonic-gnmi/show_client/helpers/boot_helpers" + sdc "github.com/sonic-net/sonic-gnmi/sonic_data_client" +) + +type bootResponse struct { + Current string `json:"current"` + Next string `json:"next"` + Available []string `json:"available"` +} + +func getBoot(_ sdc.CmdArgs, _ sdc.OptionMap) ([]byte, error) { + bl, err := helpers.DetectBootloader() + if err != nil { + log.Errorf("Failed to detect bootloader: %v", err) + return nil, err + } + + current, err := bl.GetCurrentImage() + if err != nil { + return nil, err + } + + next, err := bl.GetNextImage() + if err != nil { + return nil, err + } + + images, err := bl.GetInstalledImages() + if err != nil { + return nil, err + } + + if images == nil { + images = []string{} + } + + resp := bootResponse{ + Current: current, + Next: next, + Available: images, + } + + return json.Marshal(resp) +} diff --git a/show_client/common/file.go b/show_client/common/file.go index 31c6145f..5a7b6d8f 100644 --- a/show_client/common/file.go +++ b/show_client/common/file.go @@ -47,6 +47,13 @@ func ReadConfToMap(filePath string) (map[string]interface{}, error) { content := string(dataBytes) lines := strings.Split(content, "\n") for _, line := range lines { + line = strings.TrimSpace(line) + + // Skip empty lines and comment lines (starting with #) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + if strings.Contains(line, "=") { parts := strings.SplitN(line, "=", 2) key := strings.TrimSpace(parts[0]) diff --git a/show_client/helpers/boot_helpers/aboot.go b/show_client/helpers/boot_helpers/aboot.go new file mode 100644 index 00000000..da8b8df9 --- /dev/null +++ b/show_client/helpers/boot_helpers/aboot.go @@ -0,0 +1,67 @@ +package helpers + +import ( + "fmt" + "os" + "regexp" + "strings" + + "github.com/sonic-net/sonic-gnmi/show_client/common" +) + +type AbootBootloader struct{} + +func (a *AbootBootloader) Name() string { return "aboot" } + +func (a *AbootBootloader) GetCurrentImage() (string, error) { + // Use aboot-specific regex pattern + return getCurrentImageFromCmdline(`loop=/*(\S+)/`) +} + +func (a *AbootBootloader) GetInstalledImages() ([]string, error) { + files, err := os.ReadDir(HostPath) + if err != nil { + return nil, err + } + + var images []string + for _, file := range files { + if file.IsDir() && strings.HasPrefix(file.Name(), ImageDirPrefix) { + image := strings.Replace(file.Name(), ImageDirPrefix, ImagePrefix, 1) + images = append(images, image) + } + } + + return images, nil +} + +func (a *AbootBootloader) GetNextImage() (string, error) { + configData, err := common.ReadConfToMap(AbootBootConfigPath) + if err != nil { + return "", err + } + + swiInterface, exists := configData["SWI"] + if !exists { + return "", fmt.Errorf("SWI not found in boot config") + } + + swi, ok := swiInterface.(string) + if !ok { + return "", fmt.Errorf("SWI value is not a string") + } + + re := regexp.MustCompile(`flash:/*(\S+)/`) + m := re.FindStringSubmatch(swi) + if len(m) >= 2 { + return strings.Replace(m[1], ImageDirPrefix, ImagePrefix, 1), nil + } + + // Fallback: swi.split(':', 1)[-1] + parts := strings.SplitN(swi, ":", 2) + if len(parts) == 2 { + return parts[1], nil + } + + return swi, nil +} diff --git a/show_client/helpers/boot_helpers/boot_helper.go b/show_client/helpers/boot_helpers/boot_helper.go new file mode 100644 index 00000000..978a3708 --- /dev/null +++ b/show_client/helpers/boot_helpers/boot_helper.go @@ -0,0 +1,49 @@ +package helpers + +import ( + "fmt" + "os" + "runtime" + "strings" +) + +// Shared constants +const ( + HostPath = "/host" + ImageDirPrefix = "image-" + ImagePrefix = "SONiC-OS-" + + // Aboot + AbootBootConfigPath = "/host/boot-config" + + // GRUB + GrubCfgPath = "/host/grub/grub.cfg" + GrubEnvPath = "/host/grub/grubenv" +) + +type Bootloader interface { + Name() string + GetCurrentImage() (string, error) + GetNextImage() (string, error) + GetInstalledImages() ([]string, error) +} + +func DetectBootloader() (Bootloader, error) { + // 1. Check for Aboot + cmdline, err := readProcCmdline() + if err == nil && strings.Contains(cmdline, "Aboot=") { + return &AbootBootloader{}, nil + } + + // 2. Check for GRUB + if _, err := os.Stat(GrubCfgPath); err == nil { + return &GrubBootloader{}, nil + } + + // 3. Check for U-Boot + if runtime.GOARCH == "arm" || runtime.GOARCH == "arm64" { + return &UbootBootloader{}, nil + } + + return nil, fmt.Errorf("No supported bootloader detected") +} diff --git a/show_client/helpers/boot_helpers/grub.go b/show_client/helpers/boot_helpers/grub.go new file mode 100644 index 00000000..a61c5e9f --- /dev/null +++ b/show_client/helpers/boot_helpers/grub.go @@ -0,0 +1,82 @@ +package helpers + +import ( + "fmt" + "os" + "regexp" + "strconv" + "strings" + + "github.com/sonic-net/sonic-gnmi/show_client/common" +) + +type GrubBootloader struct{} + +func (g *GrubBootloader) Name() string { return "grub" } + +func (g *GrubBootloader) GetCurrentImage() (string, error) { + return getCurrentImageFromCmdline(`loop=(\S+)/fs\.squashfs`) +} + +func (g *GrubBootloader) GetInstalledImages() ([]string, error) { + data, err := os.ReadFile(GrubCfgPath) + if err != nil { + return nil, err + } + + var images []string + lines := strings.Split(string(data), "\n") + + for _, line := range lines { + if strings.HasPrefix(line, "menuentry") { + parts := strings.Fields(line) + if len(parts) >= 2 { + image := strings.Trim(parts[1], "'\"") + if strings.Contains(image, ImagePrefix) { + images = append(images, image) + } + } + } + } + + return images, nil +} + +func (g *GrubBootloader) GetNextImage() (string, error) { + images, err := g.GetInstalledImages() + if err != nil { + return "", err + } + + if len(images) == 0 { + return "", fmt.Errorf("no installed images found") + } + + command := fmt.Sprintf("/usr/bin/grub-editenv %s list", GrubEnvPath) + output, err := common.GetDataFromHostCommand(command) + if err != nil { + return images[0], nil + } + + nextImageIndex := 0 + + re := regexp.MustCompile(`next_entry=(\d+)`) + if m := re.FindStringSubmatch(output); len(m) >= 2 { + if idx, err := strconv.Atoi(m[1]); err == nil { + nextImageIndex = idx + } + } else { + re = regexp.MustCompile(`saved_entry=(\d+)`) + if m := re.FindStringSubmatch(output); len(m) >= 2 { + if idx, err := strconv.Atoi(m[1]); err == nil { + nextImageIndex = idx + } + } + } + + if nextImageIndex < 0 || nextImageIndex >= len(images) { + nextImageIndex = 0 + } + + return images[nextImageIndex], nil +} diff --git a/show_client/helpers/boot_helpers/onie.go b/show_client/helpers/boot_helpers/onie.go new file mode 100644 index 00000000..51e614a2 --- /dev/null +++ b/show_client/helpers/boot_helpers/onie.go @@ -0,0 +1,34 @@ +package helpers + +import ( + "fmt" + "os" + "regexp" + "strings" +) + +func readProcCmdline() (string, error) { + data, err := os.ReadFile("/proc/cmdline") + if err != nil { + return "", err + } + return strings.TrimSpace(string(data)), nil +} + +func getCurrentImageFromCmdline(regexPattern string) (string, error) { + cmdline, err := readProcCmdline() + if err != nil { + return "", err + } + + re := regexp.MustCompile(regexPattern) + m := re.FindStringSubmatch(cmdline) + if len(m) < 2 { + return "", fmt.Errorf("loop mount pattern not found in cmdline") + } + + current := m[1] + result := strings.Replace(current, ImageDirPrefix, ImagePrefix, 1) + + return result, nil +} diff --git a/show_client/helpers/boot_helpers/uboot.go b/show_client/helpers/boot_helpers/uboot.go new file mode 100644 index 00000000..a8964f62 --- /dev/null +++ b/show_client/helpers/boot_helpers/uboot.go @@ -0,0 +1,58 @@ +package helpers + +import ( + "fmt" + "strings" + + "github.com/sonic-net/sonic-gnmi/show_client/common" +) + +type UbootBootloader struct{} + +func (u *UbootBootloader) Name() string { return "uboot" } + +func (u *UbootBootloader) GetCurrentImage() (string, error) { + return getCurrentImageFromCmdline(`loop=(\S+)/fs\.squashfs`) +} + +func (u *UbootBootloader) GetInstalledImages() ([]string, error) { + var images []string + + for i := 1; i <= 2; i++ { + cmd := fmt.Sprintf("/usr/bin/fw_printenv -n sonic_version_%d", i) + if output, err := common.GetDataFromHostCommand(cmd); err == nil { + image := strings.TrimSpace(output) + if strings.Contains(image, ImagePrefix) { + images = append(images, image) + } + } + } + + return images, nil +} + +func (u *UbootBootloader) GetNextImage() (string, error) { + images, err := u.GetInstalledImages() + if err != nil { + return "", err + } + + output, err := common.GetDataFromHostCommand("/usr/bin/fw_printenv -n boot_next") + if err != nil { + if len(images) > 0 { + return images[0], nil + } + return "", err + } + + bootNext := strings.TrimSpace(output) + if strings.Contains(bootNext, "sonic_image_2") && len(images) == 2 { + return images[1], nil + } + + if len(images) > 0 { + return images[0], nil + } + + return "", nil +} diff --git a/show_client/show_paths.go b/show_client/show_paths.go index 1c90c3c7..dbc4ba60 100644 --- a/show_client/show_paths.go +++ b/show_client/show_paths.go @@ -1132,6 +1132,16 @@ func init() { nil, ) + //SHOW/boot + sdc.RegisterCliPath( + []string{"SHOW", "boot"}, + getBoot, + "SHOW/boot[OPTIONS]: Show boot configuration", + 0, + 0, + nil, + ) + // SHOW/platform/ssdhealth sdc.RegisterCliPath( []string{"SHOW", "platform", "ssdhealth"},