From aab34c686d0cd02c0be3c218dae9c2f2439b5964 Mon Sep 17 00:00:00 2001 From: Lukasz Piotrowski Date: Fri, 21 Nov 2025 21:40:28 +0100 Subject: [PATCH 01/11] implement new feature: extend userdata file; --- cfgutils/cfgutils.go | 88 +++++++++++++++++++++++++++-- cfgutils/cfgutils_test.go | 38 +++++++++---- cfgutils/cloud_config.go | 84 +++++++++++++++++++++++++++ cfgutils/mock/mock_CfgManager.go | 97 +++++++++++++++++++++++++++++++- pkg/drivers/fsas/fsas.go | 17 +++++- pkg/drivers/fsas/fsas_test.go | 17 ++++++ 6 files changed, 324 insertions(+), 17 deletions(-) create mode 100644 cfgutils/cloud_config.go diff --git a/cfgutils/cfgutils.go b/cfgutils/cfgutils.go index d2522ae..19edc5b 100644 --- a/cfgutils/cfgutils.go +++ b/cfgutils/cfgutils.go @@ -3,14 +3,19 @@ package cfgutils import ( "encoding/json" "fmt" + "os" "strings" slog "github.com/fujitsu/docker-machine-driver-fsas/logger" "github.com/fujitsu/docker-machine-driver-fsas/models" + "gopkg.in/yaml.v3" ) var ( - isInit = false + isInit = false + osWriteFile = os.WriteFile + osReadFile = os.ReadFile + osStat = os.Stat ) // CfgManager interface defines the methods for interacting with the Configuration Manager. @@ -18,24 +23,28 @@ type CfgManager interface { IsInit() bool PrepareMetadata(instanceId, hostname string) string PrepareRke2ConfigScript(configName, machineUUID string) string + ExtendUserdataRunCmd(commands []string) error + ExtendUserdataWriteFiles(fileObjects []CloudConfigItem) error } // StandardCfgManager struct holds configuration for Configuration Manager interaction. type StandardCfgManager struct { - resources []models.Resource + resources []models.Resource + userDataFile string } var _ CfgManager = (*StandardCfgManager)(nil) // NewStandardCfgManager Returns new instance of Standard Configuration Manager -func NewStandardCfgManager(devicesSpecJson string) *StandardCfgManager { +func NewStandardCfgManager(devicesSpecJson, userDataFile string) *StandardCfgManager { var resources []models.Resource if err := json.Unmarshal([]byte(devicesSpecJson), &resources); err != nil { slog.Warn("Failed to parse DevicesSpecJson, proceeding with empty resources: ", "err", err) resources = []models.Resource{} } + isInit = true - return &StandardCfgManager{resources: resources} + return &StandardCfgManager{resources: resources, userDataFile: userDataFile} } // IsInit Returns true if constructor succeed else false @@ -58,6 +67,7 @@ func (sc *StandardCfgManager) PrepareRke2ConfigScript(configName, machineUUID st slog.Debug(fmt.Sprintf("Prepare RKE2 Config Script: %s", configName)) providerIdEntry := sc.prepareRke2ConfigProviderId(machineUUID) nodeLabelEntry := sc.prepareRke2ConfigNodeLabelsForGpu() + var configContent string if nodeLabelEntry != "" { configContent = fmt.Sprintf("%s\n%s", providerIdEntry, nodeLabelEntry) @@ -91,6 +101,7 @@ func (sc *StandardCfgManager) prepareRke2ConfigProviderId(MachineUUID string) st // prepareRke2ConfigNodeLabelsForGpu returns a string with node labels func (sc *StandardCfgManager) prepareRke2ConfigNodeLabelsForGpu() string { slog.Debug("Prepare RKE2 Config Node Labels") + // GPU map (short names to full names) allowedGPUs := map[string]string{ "nvidia-a100-40g": "nvidia-a100-40g", @@ -100,11 +111,14 @@ func (sc *StandardCfgManager) prepareRke2ConfigNodeLabelsForGpu() string { "a100-80g": "nvidia-a100-80g", "h100": "nvidia-h100", } + labels := []string{} + for _, res := range sc.resources { if res.ResourceType != "gpu" || res.ResourceSpec == nil { continue } + model := "" for _, cond := range res.ResourceSpec.Condition { if cond.Column == "model" && cond.Operator == "eq" { @@ -112,29 +126,95 @@ func (sc *StandardCfgManager) prepareRke2ConfigNodeLabelsForGpu() string { break } } + fullModel, ok := allowedGPUs[model] if !ok { slog.Warn("Skipping labels because GPU model not allowed: ", "value", model) continue } + if res.MinResourceCount > res.MaxResourceCount { slog.Warn("Invalid GPU config: MinResourceCount > MaxResourceCount ", "model", fullModel, "min", res.MinResourceCount, "max", res.MaxResourceCount) continue } + if res.MinResourceCount > 0 { labels = append(labels, fmt.Sprintf("cohdi.io/%s-size-min=%d", fullModel, res.MinResourceCount)) } else { slog.Warn("MinResourceCount missing for GPU: ", "model", fullModel) } + if res.MaxResourceCount > 0 { labels = append(labels, fmt.Sprintf("cohdi.io/%s-size-max=%d", fullModel, res.MaxResourceCount)) } else { slog.Warn("MaxResourceCount missing for GPU: ", "model", fullModel) } } + if len(labels) == 0 { slog.Debug("No GPU labels generated because of empty GPU resources") return "" } + return fmt.Sprintf(`kubelet-arg+: "node-labels=%s"`, strings.Join(labels, ",")) } + +func (sc *StandardCfgManager) ExtendUserdataRunCmd(commands []string) error { + cloudConfigItems := []CloudConfigItem{NewCloudConfigItemRunCmd(commands)} + return sc.extendUserdata(cloudConfigItems) +} + +func (sc *StandardCfgManager) ExtendUserdataWriteFiles(fileObjects []CloudConfigItem) error { + return sc.extendUserdata(fileObjects) +} + +// extendUserdata Extends cloud config userdata file +func (sc *StandardCfgManager) extendUserdata(cci []CloudConfigItem) error { + + userDataFile := sc.userDataFile + if userDataFile != "" { + if _, err := osStat(userDataFile); os.IsNotExist(err) { + slog.Error("User data file does not exist: ", "path", userDataFile, "err", err) + return err + } + + var userdata []byte + userdata, err := osReadFile(userDataFile) + if err != nil { + return err + } + + cloudConfig := make(map[any]any) + if err = yaml.Unmarshal(userdata, &cloudConfig); err != nil { + return err + } + + for _, i := range cci { + newContent, err := i.addToCloudConfigFile() + if err != nil { + return fmt.Errorf("error while appending userdata file; section= %s; %w", i.section(), err) + } + + if _, ok := cloudConfig[i.section()]; !ok { + // key does not exist then create fresh list + cloudConfig[i.section()] = []any{} + } + + origSectionContent := cloudConfig[i.section()].([]any) + cloudConfig[i.section()] = append(origSectionContent, newContent...) + } + + userdataContent, err := yaml.Marshal(cloudConfig) + if err != nil { + return err + } + + finalContent := append([]byte("#cloud-config\n"), userdataContent...) + err = osWriteFile(userDataFile, finalContent, 0644) + if err != nil { + return err + } + + } + return nil +} diff --git a/cfgutils/cfgutils_test.go b/cfgutils/cfgutils_test.go index 9260fd7..e912b29 100644 --- a/cfgutils/cfgutils_test.go +++ b/cfgutils/cfgutils_test.go @@ -16,7 +16,7 @@ func TestIsInit_Fail(t *testing.T) { } func TestIsInit_Success(t *testing.T) { - manager := NewStandardCfgManager("[]") + manager := NewStandardCfgManager("[]", "") observed := manager.IsInit() assert.Equal(t, true, observed) } @@ -44,7 +44,7 @@ hostname: `, for _, tc := range testCases { t.Run(tc.hostname, func(t *testing.T) { - manager := NewStandardCfgManager("[]") + manager := NewStandardCfgManager("[]", "") observed := manager.PrepareMetadata(tc.instanceId, tc.hostname) assert.Equal(t, tc.expected, observed) }) @@ -62,7 +62,7 @@ func Test_prepareRke2ConfigProviderId(t *testing.T) { expected: `kubelet-arg+: "provider-id=fsas://"`}, } - manager := NewStandardCfgManager("[]") + manager := NewStandardCfgManager("[]", "") for _, tc := range testCases { t.Run(tc.machineUUID, func(t *testing.T) { @@ -80,7 +80,9 @@ func Test_prepareRke2ConfigNodeLabelsForGpu(t *testing.T) { {name: "no GPU resources", expected: ""}, } - manager := NewStandardCfgManager("[]") + + manager := NewStandardCfgManager("[]", "") + for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { observed := manager.prepareRke2ConfigNodeLabelsForGpu() @@ -113,9 +115,13 @@ func Test_prepareRke2ConfigNodeLabels_Dynamic(t *testing.T) { "max_resource_count": 3 } ]` - manager := NewStandardCfgManager(devicesSpecJson) + + manager := NewStandardCfgManager(devicesSpecJson, "") + labelStr := manager.prepareRke2ConfigNodeLabelsForGpu() + expected := `kubelet-arg+: "node-labels=cohdi.io/nvidia-h100-size-min=2,cohdi.io/nvidia-h100-size-max=3"` + assert.Equal(t, expected, labelStr) } @@ -127,16 +133,19 @@ func TestPrepareRke2ConfigScript(t *testing.T) { }{ {machineUUID: "cdd792f2-5591-4c18-a8bd-1c39e55dedfa", expected: fmt.Sprintf(rke2ConfigScriptContent, configName, - `kubelet-arg+: "provider-id=fsas://cdd792f2-5591-4c18-a8bd-1c39e55dedfa"`)}, + `kubelet-arg+: "provider-id=fsas://cdd792f2-5591-4c18-a8bd-1c39e55dedfa"`), + }, {machineUUID: "1234", expected: fmt.Sprintf(rke2ConfigScriptContent, configName, - `kubelet-arg+: "provider-id=fsas://1234"`)}, + `kubelet-arg+: "provider-id=fsas://1234"`), + }, {machineUUID: "", expected: fmt.Sprintf(rke2ConfigScriptContent, configName, - `kubelet-arg+: "provider-id=fsas://"`)}, + `kubelet-arg+: "provider-id=fsas://"`), + }, } - manager := NewStandardCfgManager("[]") + manager := NewStandardCfgManager("[]", "") for _, tc := range testCases { t.Run(tc.machineUUID, func(t *testing.T) { @@ -160,21 +169,28 @@ func TestPrepareRke2ConfigScript_WithGPUResources(t *testing.T) { "max_resource_count": 2 } ]` - manager := NewStandardCfgManager(devicesSpecJson) + manager := NewStandardCfgManager(devicesSpecJson, "") + configName := "100-gpu-labels" script := manager.PrepareRke2ConfigScript(configName, "my-machine-uuid") + expected := fmt.Sprintf(rke2ConfigScriptContent, configName, `kubelet-arg+: "provider-id=fsas://my-machine-uuid" kubelet-arg+: "node-labels=cohdi.io/nvidia-a100-40g-size-min=1,cohdi.io/nvidia-a100-40g-size-max=2"`) + assert.Equal(t, expected, script) } + func Test_prepareRke2ConfigNodeLabels_FromExactJSON(t *testing.T) { devicesSpecJson := `testJson` + var resources []models.Resource if err := json.Unmarshal([]byte(devicesSpecJson), &resources); err != nil { t.Logf("Failed to unmarshal JSON: %v", err) } - manager := NewStandardCfgManager(devicesSpecJson) + + manager := NewStandardCfgManager(devicesSpecJson, "") + labels := manager.prepareRke2ConfigNodeLabelsForGpu() t.Logf("Generated GPU label: %s", labels) } diff --git a/cfgutils/cloud_config.go b/cfgutils/cloud_config.go new file mode 100644 index 0000000..c94d594 --- /dev/null +++ b/cfgutils/cloud_config.go @@ -0,0 +1,84 @@ +package cfgutils + +import ( + "bytes" + "compress/gzip" + "encoding/base64" +) + +type CloudConfigItem interface { + section() string + addToCloudConfigFile() ([]any, error) +} + +// structure for storing items that correspond to cloud config userdata file items from section 'runcmd' +type cloudConfigItemRunCmd struct { + commands []string +} + +func NewCloudConfigItemRunCmd(cmds []string) cloudConfigItemRunCmd { + return cloudConfigItemRunCmd{cmds} +} + +func (c cloudConfigItemRunCmd) addToCloudConfigFile() ([]any, error) { + list := []any{} + for _, cmd := range c.commands { + list = append(list, cmd) + } + return list, nil +} + +func (c cloudConfigItemRunCmd) section() string { + return "runcmd" +} + +// structure for storing items that corresponds to cloud config userdata file items from section 'write_files' +type cloudConfigItemWriteFiles struct { + encoding string + content string + permissions string + path string +} + +func NewCloudConfigItemWriteFiles(path, content string) cloudConfigItemWriteFiles { + return cloudConfigItemWriteFiles{ + encoding: "gzip+b64", + content: content, + permissions: "0664", + path: path, + } +} + +func (c cloudConfigItemWriteFiles) addToCloudConfigFile() ([]any, error) { + zippedContent, err := gzipEncode([]byte(c.content)) + b64Encoded := base64.StdEncoding.EncodeToString(zippedContent) + if err != nil { + return []any{}, err + } + return []any{ + map[string]string{ + "encoding": c.encoding, + "content": b64Encoded, + "permissions": c.permissions, + "path": c.path, + }}, nil +} + +func (c cloudConfigItemWriteFiles) section() string { + return "write_files" +} + +// gzipEncode Returns input data packed/compressed with gzip +func gzipEncode(data []byte) ([]byte, error) { + var b bytes.Buffer + gz := gzip.NewWriter(&b) + gz.Flush() + if _, err := gz.Write(data); err != nil { + return []byte{}, err + } + if err := gz.Close(); err != nil { + return []byte{}, err + } + + return b.Bytes(), nil +} diff --git a/cfgutils/mock/mock_CfgManager.go b/cfgutils/mock/mock_CfgManager.go index 9afcc48..e8f2517 100644 --- a/cfgutils/mock/mock_CfgManager.go +++ b/cfgutils/mock/mock_CfgManager.go @@ -2,7 +2,10 @@ package cfgutils -import mock "github.com/stretchr/testify/mock" +import ( + cfgutils "github.com/fujitsu/docker-machine-driver-fsas/cfgutils" + mock "github.com/stretchr/testify/mock" +) // MockCfgManager is an autogenerated mock type for the CfgManager type type MockCfgManager struct { @@ -17,6 +20,98 @@ func (_m *MockCfgManager) EXPECT() *MockCfgManager_Expecter { return &MockCfgManager_Expecter{mock: &_m.Mock} } +// ExtendUserdataRunCmd provides a mock function with given fields: commands +func (_m *MockCfgManager) ExtendUserdataRunCmd(commands []string) error { + ret := _m.Called(commands) + + if len(ret) == 0 { + panic("no return value specified for ExtendUserdataRunCmd") + } + + var r0 error + if rf, ok := ret.Get(0).(func([]string) error); ok { + r0 = rf(commands) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// MockCfgManager_ExtendUserdataRunCmd_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ExtendUserdataRunCmd' +type MockCfgManager_ExtendUserdataRunCmd_Call struct { + *mock.Call +} + +// ExtendUserdataRunCmd is a helper method to define mock.On call +// - commands []string +func (_e *MockCfgManager_Expecter) ExtendUserdataRunCmd(commands interface{}) *MockCfgManager_ExtendUserdataRunCmd_Call { + return &MockCfgManager_ExtendUserdataRunCmd_Call{Call: _e.mock.On("ExtendUserdataRunCmd", commands)} +} + +func (_c *MockCfgManager_ExtendUserdataRunCmd_Call) Run(run func(commands []string)) *MockCfgManager_ExtendUserdataRunCmd_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].([]string)) + }) + return _c +} + +func (_c *MockCfgManager_ExtendUserdataRunCmd_Call) Return(_a0 error) *MockCfgManager_ExtendUserdataRunCmd_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *MockCfgManager_ExtendUserdataRunCmd_Call) RunAndReturn(run func([]string) error) *MockCfgManager_ExtendUserdataRunCmd_Call { + _c.Call.Return(run) + return _c +} + +// ExtendUserdataWriteFiles provides a mock function with given fields: fileObjects +func (_m *MockCfgManager) ExtendUserdataWriteFiles(fileObjects []cfgutils.CloudConfigItem) error { + ret := _m.Called(fileObjects) + + if len(ret) == 0 { + panic("no return value specified for ExtendUserdataWriteFiles") + } + + var r0 error + if rf, ok := ret.Get(0).(func([]cfgutils.CloudConfigItem) error); ok { + r0 = rf(fileObjects) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// MockCfgManager_ExtendUserdataWriteFiles_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ExtendUserdataWriteFiles' +type MockCfgManager_ExtendUserdataWriteFiles_Call struct { + *mock.Call +} + +// ExtendUserdataWriteFiles is a helper method to define mock.On call +// - fileObjects []cfgutils.CloudConfigItem +func (_e *MockCfgManager_Expecter) ExtendUserdataWriteFiles(fileObjects interface{}) *MockCfgManager_ExtendUserdataWriteFiles_Call { + return &MockCfgManager_ExtendUserdataWriteFiles_Call{Call: _e.mock.On("ExtendUserdataWriteFiles", fileObjects)} +} + +func (_c *MockCfgManager_ExtendUserdataWriteFiles_Call) Run(run func(fileObjects []cfgutils.CloudConfigItem)) *MockCfgManager_ExtendUserdataWriteFiles_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].([]cfgutils.CloudConfigItem)) + }) + return _c +} + +func (_c *MockCfgManager_ExtendUserdataWriteFiles_Call) Return(_a0 error) *MockCfgManager_ExtendUserdataWriteFiles_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *MockCfgManager_ExtendUserdataWriteFiles_Call) RunAndReturn(run func([]cfgutils.CloudConfigItem) error) *MockCfgManager_ExtendUserdataWriteFiles_Call { + _c.Call.Return(run) + return _c +} + // IsInit provides a mock function with no fields func (_m *MockCfgManager) IsInit() bool { ret := _m.Called() diff --git a/pkg/drivers/fsas/fsas.go b/pkg/drivers/fsas/fsas.go index 0006fd2..c2f8d22 100644 --- a/pkg/drivers/fsas/fsas.go +++ b/pkg/drivers/fsas/fsas.go @@ -610,10 +610,25 @@ func (d *Driver) innerCreate() error { } if !d.CfgManager.IsInit() { - cfgManager := cfgutils.NewStandardCfgManager(d.DevicesSpecJson) + cfgManager := cfgutils.NewStandardCfgManager(d.DevicesSpecJson, d.UserDataFile) d.CfgManager = cfgManager } + // write sample data using modified userdata file + d.CfgManager.ExtendUserdataRunCmd([]string{ + `echo "Boot completed at $(date)" >> /tmp/cloud-config-test-runcmd.log`, + `echo "Cloud config test succeeded" >> /tmp/cloud-config-test-runcmd.log`, + }) + + items := []cfgutils.CloudConfigItem{ + cfgutils.NewCloudConfigItemWriteFiles("/tmp/cloud-config-test-write-files.log", "Cloud config succeeded for write_files"), + cfgutils.NewCloudConfigItemWriteFiles("/tmp/cloud-config-test-write-files-2.log", "Cloud config succeeded for write_files part 2"), + } + d.CfgManager.ExtendUserdataWriteFiles(items) + + slog.Info("Logging content of cloud config userdata file after extending it") + logContentOfCloudConfigFile(d.UserDataFile) + // Prepare scripts execution parameters scriptPath := "" // Random paths removeOnFinish := true diff --git a/pkg/drivers/fsas/fsas_test.go b/pkg/drivers/fsas/fsas_test.go index 45de34a..d1ba5e8 100644 --- a/pkg/drivers/fsas/fsas_test.go +++ b/pkg/drivers/fsas/fsas_test.go @@ -13,6 +13,7 @@ import ( "testing" "time" + "github.com/fujitsu/docker-machine-driver-fsas/cfgutils" cfgMock "github.com/fujitsu/docker-machine-driver-fsas/cfgutils/mock" "github.com/fujitsu/docker-machine-driver-fsas/fm" fmmock "github.com/fujitsu/docker-machine-driver-fsas/fm/mock" @@ -907,6 +908,7 @@ func TestCreate(t *testing.T) { mockSSH.On("RebootCloudInit").Return(nil) mockSSH.On("DisablePasswordSSHLogin").Return(nil) mockClock.On("Sleep", WAIT_FOR_START_AFTER_REBOOT).Return(nil) + applyMockForExtendedUserMethods(mockCfg) // Mock implementation of os.ReadFile originalOsReadFile := osReadFile @@ -919,6 +921,18 @@ func TestCreate(t *testing.T) { assert.NoError(t, err) } +func applyMockForExtendedUserMethods(mockCfg *cfgMock.MockCfgManager) { + mockCfg.On("ExtendUserdataRunCmd", []string{ + `echo "Boot completed at $(date)" >> /tmp/cloud-config-test-runcmd.log`, + `echo "Cloud config test succeeded" >> /tmp/cloud-config-test-runcmd.log`, + }).Return(nil).Once() + items := []cfgutils.CloudConfigItem{ + cfgutils.NewCloudConfigItemWriteFiles("/tmp/cloud-config-test-write-files.log", "Cloud config succeeded for write_files"), + cfgutils.NewCloudConfigItemWriteFiles("/tmp/cloud-config-test-write-files-2.log", "Cloud config succeeded for write_files part 2"), + } + mockCfg.On("ExtendUserdataWriteFiles", items).Return(nil).Once() +} + func TestCreateCloudInitFail(t *testing.T) { mockClock := timeutilsmock.NewMockClock(t) statusClock = mockClock @@ -990,6 +1004,7 @@ func TestCreateCloudInitFail(t *testing.T) { mockSSH.On("DeregisterOS").Return(nil) mockFM.On("RemoveMachine", driver.MachineUUID, driver.TenantUuid, models.AccessTokenExample).Return(nil) mockFM.On("GetMachineDetails", driver.TenantUuid, driver.MachineUUID, models.AccessTokenExample).Return(models.ExpectedLanports, bootSsdUUID, 17, nil).Once() + applyMockForExtendedUserMethods(mockCfg) // Mock implementation of os.ReadFile originalOsReadFile := osReadFile @@ -1560,6 +1575,7 @@ func TestCreateExecuteScriptFail(t *testing.T) { mockFM.On("RemoveMachine", driver.MachineUUID, driver.TenantUuid, models.AccessTokenExample).Return(nil) // waitForStatus in Remove call mockFM.On("GetMachineDetails", driver.TenantUuid, driver.MachineUUID, models.AccessTokenExample).Return([]models.Lanport{}, "", 17, nil) + applyMockForExtendedUserMethods(mockCfg) err := driver.Create() assert.EqualError(t, err, mockError.Error()) @@ -1630,6 +1646,7 @@ func TestCreateFailRemoveFail(t *testing.T) { removeError := fmt.Errorf("Remove after failed inner Create failed as well") mockSSH.On("DeregisterOS").Return(nil) mockFM.On("RemoveMachine", driver.MachineUUID, driver.TenantUuid, models.AccessTokenExample).Return(removeError) + applyMockForExtendedUserMethods(mockCfg) err := driver.Create() assert.EqualError(t, err, "error during Create: 'ExecuteScript unsuccessful'; followed by error during Remove: 'Remove after failed inner Create failed as well'") From bc10ed076d31142e3895ea8669e83d5305a1f888 Mon Sep 17 00:00:00 2001 From: Lukasz Piotrowski Date: Mon, 24 Nov 2025 22:38:01 +0100 Subject: [PATCH 02/11] dirty commit; working unit tests --- cfgutils/cfgutils_test.go | 178 ++++++++++++++++++++++++++++++++ cfgutils/resources_for_tests.go | 65 ++++++++++++ 2 files changed, 243 insertions(+) create mode 100644 cfgutils/resources_for_tests.go diff --git a/cfgutils/cfgutils_test.go b/cfgutils/cfgutils_test.go index e912b29..509ce10 100644 --- a/cfgutils/cfgutils_test.go +++ b/cfgutils/cfgutils_test.go @@ -2,11 +2,16 @@ package cfgutils import ( "encoding/json" + "errors" "fmt" + "io/fs" + "os" + "reflect" "testing" "github.com/fujitsu/docker-machine-driver-fsas/models" "github.com/stretchr/testify/assert" + "gopkg.in/yaml.v3" ) func TestIsInit_Fail(t *testing.T) { @@ -194,3 +199,176 @@ func Test_prepareRke2ConfigNodeLabels_FromExactJSON(t *testing.T) { labels := manager.prepareRke2ConfigNodeLabelsForGpu() t.Logf("Generated GPU label: %s", labels) } + +// ---------------------------------------------------------- + +var ( + osStatErrorMessage = "" + osStatMock = func(name string) (os.FileInfo, error) { + if osStatErrorMessage != "" { + if osStatErrorMessage == "no such file" { + return nil, &os.PathError{ + Op: "stat", + Path: name, + Err: fs.ErrNotExist, + } + } else { + return nil, errors.New(osStatErrorMessage) + } + } else { + return nil, nil + } + } + mockOsReadFileContent = []byte{} + osReadFileMock = func(name string) ([]byte, error) { return mockOsReadFileContent, nil } + mockOsWriteFileContent = []byte{} + osWriteFileMock = func(name string, data []byte, perm os.FileMode) error { + mockOsWriteFileContent = data + return nil + } +) + +func resetOsMocks() { + osStatErrorMessage = "" + osStatMock = func(name string) (os.FileInfo, error) { + if osStatErrorMessage != "" { + if osStatErrorMessage == "no such file" { + return nil, &os.PathError{ + Op: "stat", + Path: name, + Err: fs.ErrNotExist, + } + } else { + return nil, errors.New(osStatErrorMessage) + } + } else { + return nil, nil + } + } + mockOsReadFileContent = []byte{} + osReadFileMock = func(name string) ([]byte, error) { return mockOsReadFileContent, nil } + mockOsWriteFileContent = []byte{} + osWriteFileMock = func(name string, data []byte, perm os.FileMode) error { + mockOsWriteFileContent = data + return nil + } + + osStat = osStatMock + mockOsReadFileContent = []byte(userdataSampleContent) + osReadFile = osReadFileMock + osWriteFile = osWriteFileMock + +} + +func TestExtendUserdataRunCmd_Success(t *testing.T) { + sc := NewStandardCfgManager("", "/tmp/userdata.yaml") + + testCases := []struct { + action func() + name string + input []string + expectedStr string + nrExpectedItemsRuncmd int + expectedError error + }{ + {name: "case 1: empty list", + action: func() { resetOsMocks() }, + input: []string{}, + expectedStr: userdataSampleContent, + nrExpectedItemsRuncmd: 1, + expectedError: nil, + }, + + {name: "case 2: add one item to section 'runcmd'", + action: func() { resetOsMocks() }, + input: inputOneItemRunCmd, + expectedStr: case2ExpectedStr, + nrExpectedItemsRuncmd: 2, + expectedError: nil, + }, + + {name: "case 3: add two items for section 'runcmd'", + action: func() { resetOsMocks() }, + input: inputTwoItemsRunCmd, + expectedStr: case3ExpectedStr, + nrExpectedItemsRuncmd: 3, + expectedError: nil, + }, + + {name: "case 4: section runcmd does not exists", + action: func() { + resetOsMocks() + mockOsReadFileContent = []byte(userdataSampleContentNoSectionRunCmd) + }, + input: inputOneItemRunCmd, + expectedStr: case4ExpectedStr, + nrExpectedItemsRuncmd: 1, + expectedError: nil, + }, + + {name: "case 5: no usedata file", + action: func() { + resetOsMocks() + osStatErrorMessage = "no such file" + }, + input: nil, + expectedStr: "", + nrExpectedItemsRuncmd: 0, + expectedError: fs.ErrNotExist, + }, + + {name: "case 6: error while reading usedata file", + action: func() { + resetOsMocks() + osReadFileMock = func(name string) ([]byte, error) { return []byte{}, case6ExpectedError } + osReadFile = osReadFileMock + }, + input: nil, + expectedStr: "", + nrExpectedItemsRuncmd: 0, + expectedError: case6ExpectedError, + }, + } + + var expected, observed map[string][]any + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + if tc.action != nil { + tc.action() + } + err := sc.ExtendUserdataRunCmd(tc.input) + + if tc.expectedError != nil { + if !errors.Is(err, tc.expectedError) { + t.Fatalf("expected: %v, but got: %v", tc.expectedError, err) + } + } else { + + /* convert to YAML objects; + Since YAML maps do not preserve ordering, comparing YAML as raw text will always fail. Thus compare YAML semantically and not textually. + */ + if err := yaml.Unmarshal([]byte(tc.expectedStr), &expected); err != nil { + t.Fatalf("failed to unmarshal expected: %v", err) + } + + if err := yaml.Unmarshal(mockOsWriteFileContent, &observed); err != nil { + t.Fatalf("failed to unmarshal observed: %v", err) + } + + if !reflect.DeepEqual(expected, observed) { + t.Fatalf("YAML differs.\nExpected: %#v\nObserved: %#v", expected, observed) + } + + if len(observed["runcmd"]) != tc.nrExpectedItemsRuncmd { + t.Errorf("expected %d items in 'runcmd', got %d", tc.nrExpectedItemsRuncmd, len(observed["runcmd"])) + } + + if len(observed["write_files"]) != 1 { + t.Errorf("expected 1 item in section 'write_files', got %d", len(observed["write_files"])) + } + } + }) + } + +} diff --git a/cfgutils/resources_for_tests.go b/cfgutils/resources_for_tests.go new file mode 100644 index 0000000..e2a9e15 --- /dev/null +++ b/cfgutils/resources_for_tests.go @@ -0,0 +1,65 @@ +package cfgutils + +import "errors" + +var ( + userdataSampleContent = `#cloud-config +runcmd: + - timedatectl set-timezone Europe/Warsaw +write_files: + - path: /tmp/foo + content: Foo was here + encoding: "gzip+b64" + permissions: "0644"` + + userdataSampleContentNoSectionRunCmd = `#cloud-config +write_files: + - path: /tmp/foo + content: Foo was here + encoding: "gzip+b64" + permissions: "0644"` + + inputOneItemRunCmd = []string{ + `echo "Boot completed at $(date)" >> /tmp/cloud-config-test-runcmd.log`, + } + + case2ExpectedStr = ` +#cloud-config +runcmd: + - timedatectl set-timezone Europe/Warsaw + - echo "Boot completed at $(date)" >> /tmp/cloud-config-test-runcmd.log +write_files: + - path: /tmp/foo + content: Foo was here + encoding: "gzip+b64" + permissions: "0644"` + + inputTwoItemsRunCmd = []string{ + `echo "Boot completed at $(date)" >> /tmp/cloud-config-test-runcmd.log`, + `echo "Cloud config test succeeded" >> /tmp/cloud-config-test-runcmd.log`, + } + + case3ExpectedStr = ` +#cloud-config +runcmd: + - timedatectl set-timezone Europe/Warsaw + - echo "Boot completed at $(date)" >> /tmp/cloud-config-test-runcmd.log + - echo "Cloud config test succeeded" >> /tmp/cloud-config-test-runcmd.log +write_files: + - path: /tmp/foo + content: Foo was here + encoding: "gzip+b64" + permissions: "0644"` + + case6ExpectedError = errors.New("error while reading file") + + case4ExpectedStr = ` +#cloud-config +runcmd: + - echo "Boot completed at $(date)" >> /tmp/cloud-config-test-runcmd.log +write_files: + - path: /tmp/foo + content: Foo was here + encoding: "gzip+b64" + permissions: "0644"` +) From 73a6de37fa941f1576553bde0a0cfa07078325b0 Mon Sep 17 00:00:00 2001 From: Lukasz Piotrowski Date: Mon, 24 Nov 2025 22:40:03 +0100 Subject: [PATCH 03/11] remove assessment section for testing userdata extending --- pkg/drivers/fsas/fsas.go | 15 --------------- pkg/drivers/fsas/fsas_test.go | 17 ----------------- 2 files changed, 32 deletions(-) diff --git a/pkg/drivers/fsas/fsas.go b/pkg/drivers/fsas/fsas.go index c2f8d22..ccc5524 100644 --- a/pkg/drivers/fsas/fsas.go +++ b/pkg/drivers/fsas/fsas.go @@ -614,21 +614,6 @@ func (d *Driver) innerCreate() error { d.CfgManager = cfgManager } - // write sample data using modified userdata file - d.CfgManager.ExtendUserdataRunCmd([]string{ - `echo "Boot completed at $(date)" >> /tmp/cloud-config-test-runcmd.log`, - `echo "Cloud config test succeeded" >> /tmp/cloud-config-test-runcmd.log`, - }) - - items := []cfgutils.CloudConfigItem{ - cfgutils.NewCloudConfigItemWriteFiles("/tmp/cloud-config-test-write-files.log", "Cloud config succeeded for write_files"), - cfgutils.NewCloudConfigItemWriteFiles("/tmp/cloud-config-test-write-files-2.log", "Cloud config succeeded for write_files part 2"), - } - d.CfgManager.ExtendUserdataWriteFiles(items) - - slog.Info("Logging content of cloud config userdata file after extending it") - logContentOfCloudConfigFile(d.UserDataFile) - // Prepare scripts execution parameters scriptPath := "" // Random paths removeOnFinish := true diff --git a/pkg/drivers/fsas/fsas_test.go b/pkg/drivers/fsas/fsas_test.go index d1ba5e8..45de34a 100644 --- a/pkg/drivers/fsas/fsas_test.go +++ b/pkg/drivers/fsas/fsas_test.go @@ -13,7 +13,6 @@ import ( "testing" "time" - "github.com/fujitsu/docker-machine-driver-fsas/cfgutils" cfgMock "github.com/fujitsu/docker-machine-driver-fsas/cfgutils/mock" "github.com/fujitsu/docker-machine-driver-fsas/fm" fmmock "github.com/fujitsu/docker-machine-driver-fsas/fm/mock" @@ -908,7 +907,6 @@ func TestCreate(t *testing.T) { mockSSH.On("RebootCloudInit").Return(nil) mockSSH.On("DisablePasswordSSHLogin").Return(nil) mockClock.On("Sleep", WAIT_FOR_START_AFTER_REBOOT).Return(nil) - applyMockForExtendedUserMethods(mockCfg) // Mock implementation of os.ReadFile originalOsReadFile := osReadFile @@ -921,18 +919,6 @@ func TestCreate(t *testing.T) { assert.NoError(t, err) } -func applyMockForExtendedUserMethods(mockCfg *cfgMock.MockCfgManager) { - mockCfg.On("ExtendUserdataRunCmd", []string{ - `echo "Boot completed at $(date)" >> /tmp/cloud-config-test-runcmd.log`, - `echo "Cloud config test succeeded" >> /tmp/cloud-config-test-runcmd.log`, - }).Return(nil).Once() - items := []cfgutils.CloudConfigItem{ - cfgutils.NewCloudConfigItemWriteFiles("/tmp/cloud-config-test-write-files.log", "Cloud config succeeded for write_files"), - cfgutils.NewCloudConfigItemWriteFiles("/tmp/cloud-config-test-write-files-2.log", "Cloud config succeeded for write_files part 2"), - } - mockCfg.On("ExtendUserdataWriteFiles", items).Return(nil).Once() -} - func TestCreateCloudInitFail(t *testing.T) { mockClock := timeutilsmock.NewMockClock(t) statusClock = mockClock @@ -1004,7 +990,6 @@ func TestCreateCloudInitFail(t *testing.T) { mockSSH.On("DeregisterOS").Return(nil) mockFM.On("RemoveMachine", driver.MachineUUID, driver.TenantUuid, models.AccessTokenExample).Return(nil) mockFM.On("GetMachineDetails", driver.TenantUuid, driver.MachineUUID, models.AccessTokenExample).Return(models.ExpectedLanports, bootSsdUUID, 17, nil).Once() - applyMockForExtendedUserMethods(mockCfg) // Mock implementation of os.ReadFile originalOsReadFile := osReadFile @@ -1575,7 +1560,6 @@ func TestCreateExecuteScriptFail(t *testing.T) { mockFM.On("RemoveMachine", driver.MachineUUID, driver.TenantUuid, models.AccessTokenExample).Return(nil) // waitForStatus in Remove call mockFM.On("GetMachineDetails", driver.TenantUuid, driver.MachineUUID, models.AccessTokenExample).Return([]models.Lanport{}, "", 17, nil) - applyMockForExtendedUserMethods(mockCfg) err := driver.Create() assert.EqualError(t, err, mockError.Error()) @@ -1646,7 +1630,6 @@ func TestCreateFailRemoveFail(t *testing.T) { removeError := fmt.Errorf("Remove after failed inner Create failed as well") mockSSH.On("DeregisterOS").Return(nil) mockFM.On("RemoveMachine", driver.MachineUUID, driver.TenantUuid, models.AccessTokenExample).Return(removeError) - applyMockForExtendedUserMethods(mockCfg) err := driver.Create() assert.EqualError(t, err, "error during Create: 'ExecuteScript unsuccessful'; followed by error during Remove: 'Remove after failed inner Create failed as well'") From 611297ccdcf720351a8140fa537868c649d5e4a7 Mon Sep 17 00:00:00 2001 From: Lukasz Piotrowski Date: Mon, 24 Nov 2025 22:55:48 +0100 Subject: [PATCH 04/11] add test for write error; change variable names; --- cfgutils/cfgutils_test.go | 31 +++++++++++++++++++++++-------- cfgutils/resources_for_tests.go | 10 ++++++---- 2 files changed, 29 insertions(+), 12 deletions(-) diff --git a/cfgutils/cfgutils_test.go b/cfgutils/cfgutils_test.go index 509ce10..30d94ee 100644 --- a/cfgutils/cfgutils_test.go +++ b/cfgutils/cfgutils_test.go @@ -260,7 +260,7 @@ func resetOsMocks() { } -func TestExtendUserdataRunCmd_Success(t *testing.T) { +func TestExtendUserdataRunCmd(t *testing.T) { sc := NewStandardCfgManager("", "/tmp/userdata.yaml") testCases := []struct { @@ -282,15 +282,15 @@ func TestExtendUserdataRunCmd_Success(t *testing.T) { {name: "case 2: add one item to section 'runcmd'", action: func() { resetOsMocks() }, input: inputOneItemRunCmd, - expectedStr: case2ExpectedStr, + expectedStr: expectedStr2Cmd1Write, nrExpectedItemsRuncmd: 2, expectedError: nil, }, - {name: "case 3: add two items for section 'runcmd'", + {name: "case 3: add two items to section 'runcmd'", action: func() { resetOsMocks() }, input: inputTwoItemsRunCmd, - expectedStr: case3ExpectedStr, + expectedStr: expectedStr3Cmd1Write, nrExpectedItemsRuncmd: 3, expectedError: nil, }, @@ -301,7 +301,7 @@ func TestExtendUserdataRunCmd_Success(t *testing.T) { mockOsReadFileContent = []byte(userdataSampleContentNoSectionRunCmd) }, input: inputOneItemRunCmd, - expectedStr: case4ExpectedStr, + expectedStr: expectedStr1Cmd1Write, nrExpectedItemsRuncmd: 1, expectedError: nil, }, @@ -317,16 +317,31 @@ func TestExtendUserdataRunCmd_Success(t *testing.T) { expectedError: fs.ErrNotExist, }, - {name: "case 6: error while reading usedata file", + {name: "case 6: error while reading from usedata file", action: func() { resetOsMocks() - osReadFileMock = func(name string) ([]byte, error) { return []byte{}, case6ExpectedError } + osReadFileMock = func(name string) ([]byte, error) { return []byte{}, expectedErrorReadingFromFile } osReadFile = osReadFileMock }, input: nil, expectedStr: "", nrExpectedItemsRuncmd: 0, - expectedError: case6ExpectedError, + expectedError: expectedErrorReadingFromFile, + }, + + {name: "case 7: error while writing to usedata file", + action: func() { + resetOsMocks() + osWriteFileMock = func(name string, data []byte, perm os.FileMode) error { + mockOsWriteFileContent = nil + return expectedErrorWritingToFile + } + osWriteFile = osWriteFileMock + }, + input: inputOneItemRunCmd, + expectedStr: "", + nrExpectedItemsRuncmd: 0, + expectedError: expectedErrorWritingToFile, }, } diff --git a/cfgutils/resources_for_tests.go b/cfgutils/resources_for_tests.go index e2a9e15..31ee763 100644 --- a/cfgutils/resources_for_tests.go +++ b/cfgutils/resources_for_tests.go @@ -23,7 +23,7 @@ write_files: `echo "Boot completed at $(date)" >> /tmp/cloud-config-test-runcmd.log`, } - case2ExpectedStr = ` + expectedStr2Cmd1Write = ` #cloud-config runcmd: - timedatectl set-timezone Europe/Warsaw @@ -39,7 +39,7 @@ write_files: `echo "Cloud config test succeeded" >> /tmp/cloud-config-test-runcmd.log`, } - case3ExpectedStr = ` + expectedStr3Cmd1Write = ` #cloud-config runcmd: - timedatectl set-timezone Europe/Warsaw @@ -51,9 +51,9 @@ write_files: encoding: "gzip+b64" permissions: "0644"` - case6ExpectedError = errors.New("error while reading file") + expectedErrorReadingFromFile = errors.New("error while reading file") - case4ExpectedStr = ` + expectedStr1Cmd1Write = ` #cloud-config runcmd: - echo "Boot completed at $(date)" >> /tmp/cloud-config-test-runcmd.log @@ -62,4 +62,6 @@ write_files: content: Foo was here encoding: "gzip+b64" permissions: "0644"` + + expectedErrorWritingToFile = errors.New("error while writing file") ) From 0e66a38b94836bee96576659a6efe2fe5c829627 Mon Sep 17 00:00:00 2001 From: Lukasz Piotrowski Date: Mon, 24 Nov 2025 23:04:12 +0100 Subject: [PATCH 05/11] remove section write_files when testing section runcmd; --- cfgutils/cfgutils_test.go | 84 ++++++++++++++++----------------- cfgutils/resources_for_tests.go | 32 ++----------- 2 files changed, 45 insertions(+), 71 deletions(-) diff --git a/cfgutils/cfgutils_test.go b/cfgutils/cfgutils_test.go index 30d94ee..ec71f5c 100644 --- a/cfgutils/cfgutils_test.go +++ b/cfgutils/cfgutils_test.go @@ -264,35 +264,35 @@ func TestExtendUserdataRunCmd(t *testing.T) { sc := NewStandardCfgManager("", "/tmp/userdata.yaml") testCases := []struct { - action func() - name string - input []string - expectedStr string - nrExpectedItemsRuncmd int - expectedError error + action func() + name string + input []string + expectedStr string + nrExpectedItems int + expectedError error }{ - {name: "case 1: empty list", - action: func() { resetOsMocks() }, - input: []string{}, - expectedStr: userdataSampleContent, - nrExpectedItemsRuncmd: 1, - expectedError: nil, + {name: "case 1: input as empty list", + action: func() { resetOsMocks() }, + input: []string{}, + expectedStr: userdataSampleContent, + nrExpectedItems: 1, + expectedError: nil, }, {name: "case 2: add one item to section 'runcmd'", - action: func() { resetOsMocks() }, - input: inputOneItemRunCmd, - expectedStr: expectedStr2Cmd1Write, - nrExpectedItemsRuncmd: 2, - expectedError: nil, + action: func() { resetOsMocks() }, + input: inputOneItemRunCmd, + expectedStr: expectedStr2Cmd1Write, + nrExpectedItems: 2, + expectedError: nil, }, {name: "case 3: add two items to section 'runcmd'", - action: func() { resetOsMocks() }, - input: inputTwoItemsRunCmd, - expectedStr: expectedStr3Cmd1Write, - nrExpectedItemsRuncmd: 3, - expectedError: nil, + action: func() { resetOsMocks() }, + input: inputTwoItemsRunCmd, + expectedStr: expectedStr3Cmd1Write, + nrExpectedItems: 3, + expectedError: nil, }, {name: "case 4: section runcmd does not exists", @@ -300,10 +300,10 @@ func TestExtendUserdataRunCmd(t *testing.T) { resetOsMocks() mockOsReadFileContent = []byte(userdataSampleContentNoSectionRunCmd) }, - input: inputOneItemRunCmd, - expectedStr: expectedStr1Cmd1Write, - nrExpectedItemsRuncmd: 1, - expectedError: nil, + input: inputOneItemRunCmd, + expectedStr: expectedStr1Cmd1Write, + nrExpectedItems: 1, + expectedError: nil, }, {name: "case 5: no usedata file", @@ -311,10 +311,10 @@ func TestExtendUserdataRunCmd(t *testing.T) { resetOsMocks() osStatErrorMessage = "no such file" }, - input: nil, - expectedStr: "", - nrExpectedItemsRuncmd: 0, - expectedError: fs.ErrNotExist, + input: nil, + expectedStr: "", + nrExpectedItems: 0, + expectedError: fs.ErrNotExist, }, {name: "case 6: error while reading from usedata file", @@ -323,10 +323,10 @@ func TestExtendUserdataRunCmd(t *testing.T) { osReadFileMock = func(name string) ([]byte, error) { return []byte{}, expectedErrorReadingFromFile } osReadFile = osReadFileMock }, - input: nil, - expectedStr: "", - nrExpectedItemsRuncmd: 0, - expectedError: expectedErrorReadingFromFile, + input: nil, + expectedStr: "", + nrExpectedItems: 0, + expectedError: expectedErrorReadingFromFile, }, {name: "case 7: error while writing to usedata file", @@ -338,10 +338,10 @@ func TestExtendUserdataRunCmd(t *testing.T) { } osWriteFile = osWriteFileMock }, - input: inputOneItemRunCmd, - expectedStr: "", - nrExpectedItemsRuncmd: 0, - expectedError: expectedErrorWritingToFile, + input: inputOneItemRunCmd, + expectedStr: "", + nrExpectedItems: 0, + expectedError: expectedErrorWritingToFile, }, } @@ -375,12 +375,8 @@ func TestExtendUserdataRunCmd(t *testing.T) { t.Fatalf("YAML differs.\nExpected: %#v\nObserved: %#v", expected, observed) } - if len(observed["runcmd"]) != tc.nrExpectedItemsRuncmd { - t.Errorf("expected %d items in 'runcmd', got %d", tc.nrExpectedItemsRuncmd, len(observed["runcmd"])) - } - - if len(observed["write_files"]) != 1 { - t.Errorf("expected 1 item in section 'write_files', got %d", len(observed["write_files"])) + if len(observed["runcmd"]) != tc.nrExpectedItems { + t.Errorf("expected %d items in 'runcmd', got %d", tc.nrExpectedItems, len(observed["runcmd"])) } } }) diff --git a/cfgutils/resources_for_tests.go b/cfgutils/resources_for_tests.go index 31ee763..e52e26e 100644 --- a/cfgutils/resources_for_tests.go +++ b/cfgutils/resources_for_tests.go @@ -6,18 +6,9 @@ var ( userdataSampleContent = `#cloud-config runcmd: - timedatectl set-timezone Europe/Warsaw -write_files: - - path: /tmp/foo - content: Foo was here - encoding: "gzip+b64" - permissions: "0644"` +` - userdataSampleContentNoSectionRunCmd = `#cloud-config -write_files: - - path: /tmp/foo - content: Foo was here - encoding: "gzip+b64" - permissions: "0644"` + userdataSampleContentNoSectionRunCmd = `#cloud-config` inputOneItemRunCmd = []string{ `echo "Boot completed at $(date)" >> /tmp/cloud-config-test-runcmd.log`, @@ -28,11 +19,7 @@ write_files: runcmd: - timedatectl set-timezone Europe/Warsaw - echo "Boot completed at $(date)" >> /tmp/cloud-config-test-runcmd.log -write_files: - - path: /tmp/foo - content: Foo was here - encoding: "gzip+b64" - permissions: "0644"` +` inputTwoItemsRunCmd = []string{ `echo "Boot completed at $(date)" >> /tmp/cloud-config-test-runcmd.log`, @@ -45,23 +32,14 @@ runcmd: - timedatectl set-timezone Europe/Warsaw - echo "Boot completed at $(date)" >> /tmp/cloud-config-test-runcmd.log - echo "Cloud config test succeeded" >> /tmp/cloud-config-test-runcmd.log -write_files: - - path: /tmp/foo - content: Foo was here - encoding: "gzip+b64" - permissions: "0644"` - +` expectedErrorReadingFromFile = errors.New("error while reading file") expectedStr1Cmd1Write = ` #cloud-config runcmd: - echo "Boot completed at $(date)" >> /tmp/cloud-config-test-runcmd.log -write_files: - - path: /tmp/foo - content: Foo was here - encoding: "gzip+b64" - permissions: "0644"` +` expectedErrorWritingToFile = errors.New("error while writing file") ) From 6a5af2e9979e5bfa3bd4d7caf14974408ee27889 Mon Sep 17 00:00:00 2001 From: Lukasz Piotrowski Date: Tue, 25 Nov 2025 11:48:43 +0100 Subject: [PATCH 06/11] add unit test for yaml unmarshaling; replace ifs with asserts; --- cfgutils/cfgutils_test.go | 30 ++++++++++++++++++++++-------- cfgutils/resources_for_tests.go | 1 + 2 files changed, 23 insertions(+), 8 deletions(-) diff --git a/cfgutils/cfgutils_test.go b/cfgutils/cfgutils_test.go index ec71f5c..7b06a2a 100644 --- a/cfgutils/cfgutils_test.go +++ b/cfgutils/cfgutils_test.go @@ -6,7 +6,6 @@ import ( "fmt" "io/fs" "os" - "reflect" "testing" "github.com/fujitsu/docker-machine-driver-fsas/models" @@ -371,15 +370,30 @@ func TestExtendUserdataRunCmd(t *testing.T) { t.Fatalf("failed to unmarshal observed: %v", err) } - if !reflect.DeepEqual(expected, observed) { - t.Fatalf("YAML differs.\nExpected: %#v\nObserved: %#v", expected, observed) - } - - if len(observed["runcmd"]) != tc.nrExpectedItems { - t.Errorf("expected %d items in 'runcmd', got %d", tc.nrExpectedItems, len(observed["runcmd"])) - } + assert.Equal(t, expected, observed) + assert.Equal(t, len(observed["runcmd"]), tc.nrExpectedItems) } }) } } + +func TestExtendUserdataRunCmd_YamlUnmarshallinError(t *testing.T) { + sc := NewStandardCfgManager("", "/tmp/userdata.yaml") + + resetOsMocks() + mockOsReadFileContent = []byte(userdataSampleInvalidYamlContent) + err := sc.ExtendUserdataRunCmd(inputOneItemRunCmd) + if err == nil { + t.Fatal("expected error, but got nil") + } + + expectedErrMsg := []string{ + "yaml: unmarshal errors", + "line 1: cannot unmarshal !!str", + } + + for _, errMsg := range expectedErrMsg { + assert.Contains(t, err.Error(), errMsg) + } +} diff --git a/cfgutils/resources_for_tests.go b/cfgutils/resources_for_tests.go index e52e26e..4194ad6 100644 --- a/cfgutils/resources_for_tests.go +++ b/cfgutils/resources_for_tests.go @@ -9,6 +9,7 @@ runcmd: ` userdataSampleContentNoSectionRunCmd = `#cloud-config` + userdataSampleInvalidYamlContent = `.32??#(&&)58ffo:bar` inputOneItemRunCmd = []string{ `echo "Boot completed at $(date)" >> /tmp/cloud-config-test-runcmd.log`, From 10a5abfa353e2525e4c5bdf9a732baedccf42576 Mon Sep 17 00:00:00 2001 From: Lukasz Piotrowski Date: Tue, 25 Nov 2025 13:29:59 +0100 Subject: [PATCH 07/11] parametrize resetOsMocks function; add unit tests for section write_files; --- cfgutils/cfgutils_test.go | 147 ++++++++++++++++++++++++++++---- cfgutils/cloud_config.go | 2 +- cfgutils/resources_for_tests.go | 58 +++++++++++-- 3 files changed, 184 insertions(+), 23 deletions(-) diff --git a/cfgutils/cfgutils_test.go b/cfgutils/cfgutils_test.go index 7b06a2a..58fab2b 100644 --- a/cfgutils/cfgutils_test.go +++ b/cfgutils/cfgutils_test.go @@ -199,8 +199,6 @@ func Test_prepareRke2ConfigNodeLabels_FromExactJSON(t *testing.T) { t.Logf("Generated GPU label: %s", labels) } -// ---------------------------------------------------------- - var ( osStatErrorMessage = "" osStatMock = func(name string) (os.FileInfo, error) { @@ -227,7 +225,7 @@ var ( } ) -func resetOsMocks() { +func resetOsMocks(userdataContent string) { osStatErrorMessage = "" osStatMock = func(name string) (os.FileInfo, error) { if osStatErrorMessage != "" { @@ -253,7 +251,7 @@ func resetOsMocks() { } osStat = osStatMock - mockOsReadFileContent = []byte(userdataSampleContent) + mockOsReadFileContent = []byte(userdataContent) osReadFile = osReadFileMock osWriteFile = osWriteFileMock @@ -271,7 +269,7 @@ func TestExtendUserdataRunCmd(t *testing.T) { expectedError error }{ {name: "case 1: input as empty list", - action: func() { resetOsMocks() }, + action: func() { resetOsMocks(userdataSampleContent) }, input: []string{}, expectedStr: userdataSampleContent, nrExpectedItems: 1, @@ -279,35 +277,34 @@ func TestExtendUserdataRunCmd(t *testing.T) { }, {name: "case 2: add one item to section 'runcmd'", - action: func() { resetOsMocks() }, + action: func() { resetOsMocks(userdataSampleContent) }, input: inputOneItemRunCmd, - expectedStr: expectedStr2Cmd1Write, + expectedStr: expectedStr2Cmd, nrExpectedItems: 2, expectedError: nil, }, {name: "case 3: add two items to section 'runcmd'", - action: func() { resetOsMocks() }, + action: func() { resetOsMocks(userdataSampleContent) }, input: inputTwoItemsRunCmd, - expectedStr: expectedStr3Cmd1Write, + expectedStr: expectedStr3Cmd, nrExpectedItems: 3, expectedError: nil, }, - {name: "case 4: section runcmd does not exists", + {name: "case 4: section 'runcmd' does not exist", action: func() { - resetOsMocks() - mockOsReadFileContent = []byte(userdataSampleContentNoSectionRunCmd) + resetOsMocks(userdataSampleContentNoSections) }, input: inputOneItemRunCmd, - expectedStr: expectedStr1Cmd1Write, + expectedStr: expectedStr1Cmd, nrExpectedItems: 1, expectedError: nil, }, {name: "case 5: no usedata file", action: func() { - resetOsMocks() + resetOsMocks(userdataSampleContent) osStatErrorMessage = "no such file" }, input: nil, @@ -318,7 +315,7 @@ func TestExtendUserdataRunCmd(t *testing.T) { {name: "case 6: error while reading from usedata file", action: func() { - resetOsMocks() + resetOsMocks(userdataSampleContent) osReadFileMock = func(name string) ([]byte, error) { return []byte{}, expectedErrorReadingFromFile } osReadFile = osReadFileMock }, @@ -330,7 +327,7 @@ func TestExtendUserdataRunCmd(t *testing.T) { {name: "case 7: error while writing to usedata file", action: func() { - resetOsMocks() + resetOsMocks(userdataSampleContent) osWriteFileMock = func(name string, data []byte, perm os.FileMode) error { mockOsWriteFileContent = nil return expectedErrorWritingToFile @@ -381,7 +378,7 @@ func TestExtendUserdataRunCmd(t *testing.T) { func TestExtendUserdataRunCmd_YamlUnmarshallinError(t *testing.T) { sc := NewStandardCfgManager("", "/tmp/userdata.yaml") - resetOsMocks() + resetOsMocks(userdataSampleContent) mockOsReadFileContent = []byte(userdataSampleInvalidYamlContent) err := sc.ExtendUserdataRunCmd(inputOneItemRunCmd) if err == nil { @@ -397,3 +394,119 @@ func TestExtendUserdataRunCmd_YamlUnmarshallinError(t *testing.T) { assert.Contains(t, err.Error(), errMsg) } } + +func TestExtendUserdataWriteFiles(t *testing.T) { + sc := NewStandardCfgManager("", "/tmp/userdata.yaml") + + testCases := []struct { + action func() + name string + input []CloudConfigItem + expectedStr string + nrExpectedItems int + expectedError error + }{ + {name: "case 1: input as empty list", + action: func() { resetOsMocks(userdataSampleContentWriteFiles) }, + input: []CloudConfigItem{}, + expectedStr: userdataSampleContentWriteFiles, + nrExpectedItems: 1, + expectedError: nil, + }, + + {name: "case 2: add one item to section 'write_files'", + action: func() { resetOsMocks(userdataSampleContentWriteFiles) }, + input: inputOneItemWriteFiles, + expectedStr: expectedStr2Write, + nrExpectedItems: 2, + expectedError: nil, + }, + + {name: "case 3: add two items to section 'write_files'", + action: func() { resetOsMocks(userdataSampleContentWriteFiles) }, + input: inputTwoItemsWriteFiles, + expectedStr: expectedStr3Write, + nrExpectedItems: 3, + expectedError: nil, + }, + + {name: "case 4: section 'write_files' does not exist", + action: func() { resetOsMocks(userdataSampleContentNoSections) }, + input: inputOneItemWriteFiles, + expectedStr: expectedStr1Write, + nrExpectedItems: 1, + expectedError: nil, + }, + + {name: "case 5: no usedata file", + action: func() { + resetOsMocks(userdataSampleContentWriteFiles) + osStatErrorMessage = "no such file" + }, + input: nil, + expectedStr: "", + nrExpectedItems: 0, + expectedError: fs.ErrNotExist, + }, + + {name: "case 6: error while reading from usedata file", + action: func() { + resetOsMocks(userdataSampleContentWriteFiles) + osReadFileMock = func(name string) ([]byte, error) { return []byte{}, expectedErrorReadingFromFile } + osReadFile = osReadFileMock + }, + input: nil, + expectedStr: "", + nrExpectedItems: 0, + expectedError: expectedErrorReadingFromFile, + }, + + {name: "case 7: error while writing to usedata file", + action: func() { + resetOsMocks(userdataSampleContentWriteFiles) + osWriteFileMock = func(name string, data []byte, perm os.FileMode) error { + mockOsWriteFileContent = nil + return expectedErrorWritingToFile + } + osWriteFile = osWriteFileMock + }, + input: inputOneItemWriteFiles, + expectedStr: "", + nrExpectedItems: 0, + expectedError: expectedErrorWritingToFile, + }, + } + + var expected, observed map[string][]any + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + if tc.action != nil { + tc.action() + } + err := sc.ExtendUserdataWriteFiles(tc.input) + + if tc.expectedError != nil { + if !errors.Is(err, tc.expectedError) { + t.Fatalf("expected: %v, but got: %v", tc.expectedError, err) + } + } else { + + /* convert to YAML objects; + Since YAML maps do not preserve ordering, comparing YAML as raw text will always fail. Thus compare YAML semantically and not textually. + */ + if err := yaml.Unmarshal([]byte(tc.expectedStr), &expected); err != nil { + t.Fatalf("failed to unmarshal expected: %v", err) + } + + if err := yaml.Unmarshal(mockOsWriteFileContent, &observed); err != nil { + t.Fatalf("failed to unmarshal observed: %v", err) + } + + assert.Equal(t, expected, observed) + assert.Equal(t, len(observed["write_files"]), tc.nrExpectedItems) + } + }) + } + +} diff --git a/cfgutils/cloud_config.go b/cfgutils/cloud_config.go index c94d594..868f0d4 100644 --- a/cfgutils/cloud_config.go +++ b/cfgutils/cloud_config.go @@ -44,7 +44,7 @@ func NewCloudConfigItemWriteFiles(path, content string) cloudConfigItemWriteFile return cloudConfigItemWriteFiles{ encoding: "gzip+b64", content: content, - permissions: "0664", + permissions: "0644", path: path, } } diff --git a/cfgutils/resources_for_tests.go b/cfgutils/resources_for_tests.go index 4194ad6..f18a89b 100644 --- a/cfgutils/resources_for_tests.go +++ b/cfgutils/resources_for_tests.go @@ -8,14 +8,14 @@ runcmd: - timedatectl set-timezone Europe/Warsaw ` - userdataSampleContentNoSectionRunCmd = `#cloud-config` - userdataSampleInvalidYamlContent = `.32??#(&&)58ffo:bar` + userdataSampleContentNoSections = `#cloud-config` + userdataSampleInvalidYamlContent = `.32??#(&&)58ffo:bar` inputOneItemRunCmd = []string{ `echo "Boot completed at $(date)" >> /tmp/cloud-config-test-runcmd.log`, } - expectedStr2Cmd1Write = ` + expectedStr2Cmd = ` #cloud-config runcmd: - timedatectl set-timezone Europe/Warsaw @@ -27,7 +27,7 @@ runcmd: `echo "Cloud config test succeeded" >> /tmp/cloud-config-test-runcmd.log`, } - expectedStr3Cmd1Write = ` + expectedStr3Cmd = ` #cloud-config runcmd: - timedatectl set-timezone Europe/Warsaw @@ -36,11 +36,59 @@ runcmd: ` expectedErrorReadingFromFile = errors.New("error while reading file") - expectedStr1Cmd1Write = ` + expectedStr1Cmd = ` #cloud-config runcmd: - echo "Boot completed at $(date)" >> /tmp/cloud-config-test-runcmd.log ` expectedErrorWritingToFile = errors.New("error while writing file") + + userdataSampleContentWriteFiles = `#cloud-config +write_files: + - path: /tmp/foo + content: Foo was here + encoding: "gzip+b64" + permissions: "0644"` + + inputOneItemWriteFiles = []CloudConfigItem{ + NewCloudConfigItemWriteFiles("/tmp/cloud-config-test-write-files.log", "Cloud config succeeded for write_files")} + + inputTwoItemsWriteFiles = []CloudConfigItem{ + NewCloudConfigItemWriteFiles("/tmp/cloud-config-test-write-files.log", "Cloud config succeeded for write_files"), + NewCloudConfigItemWriteFiles("/tmp/cloud-config-test-write-files-2.log", "Cloud config succeeded for write_files part 2"), + } + + expectedStr1Write = `#cloud-config +write_files: + - path: /tmp/cloud-config-test-write-files.log + content: H4sIAAAAAAAA/wAAAP//cs7JL01RSM7PS8tMVyguTU5OTU1JTVFIyy9SKC/KLEmNT8vMSS0GBAAA//84FqCbJgAAAA== + encoding: "gzip+b64" + permissions: "0644"` + + expectedStr2Write = `#cloud-config +write_files: + - path: /tmp/foo + content: Foo was here + encoding: "gzip+b64" + permissions: "0644" + - path: /tmp/cloud-config-test-write-files.log + content: H4sIAAAAAAAA/wAAAP//cs7JL01RSM7PS8tMVyguTU5OTU1JTVFIyy9SKC/KLEmNT8vMSS0GBAAA//84FqCbJgAAAA== + encoding: "gzip+b64" + permissions: "0644"` + + expectedStr3Write = `#cloud-config +write_files: + - path: /tmp/foo + content: Foo was here + encoding: "gzip+b64" + permissions: "0644" + - path: /tmp/cloud-config-test-write-files.log + content: H4sIAAAAAAAA/wAAAP//cs7JL01RSM7PS8tMVyguTU5OTU1JTVFIyy9SKC/KLEmNT8vMSS0GBAAA//84FqCbJgAAAA== + encoding: "gzip+b64" + permissions: "0644" + - path: /tmp/cloud-config-test-write-files-2.log + content: H4sIAAAAAAAA/wAAAP//cs7JL01RSM7PS8tMVyguTU5OTU1JTVFIyy9SKC/KLEmNT8vMSS1WKEgsKlEwAgQAAP//55tZZi0AAAA= + encoding: "gzip+b64" + permissions: "0644"` ) From 6007ffd540a9ffa502dc4e74b980512bded238ba Mon Sep 17 00:00:00 2001 From: Lukasz Piotrowski Date: Tue, 25 Nov 2025 14:15:18 +0100 Subject: [PATCH 08/11] new unit tests for private method extendUserdata; --- cfgutils/cfgutils_test.go | 152 ++++++++++++++++++++++++++++++++ cfgutils/resources_for_tests.go | 111 +++++++++++++++++++++++ 2 files changed, 263 insertions(+) diff --git a/cfgutils/cfgutils_test.go b/cfgutils/cfgutils_test.go index 58fab2b..668751a 100644 --- a/cfgutils/cfgutils_test.go +++ b/cfgutils/cfgutils_test.go @@ -510,3 +510,155 @@ func TestExtendUserdataWriteFiles(t *testing.T) { } } + +func TestExtendUserdata(t *testing.T) { + sc := NewStandardCfgManager("", "/tmp/userdata.yaml") + + testCases := []struct { + action func() + name string + input []CloudConfigItem + expectedStr string + nrExpectedItemsWF int + nrExpectedItemsRC int + expectedError error + }{ + {name: "case 1: input as empty list", + action: func() { resetOsMocks(userdataSampleContentBothSections) }, + input: []CloudConfigItem{}, + expectedStr: userdataSampleContentBothSections, + nrExpectedItemsWF: 1, + nrExpectedItemsRC: 1, + expectedError: nil, + }, + + {name: "case 2: add 1 item to section 'runcmd'", + action: func() { resetOsMocks(userdataSampleContentBothSections) }, + input: input1ItemRunCmdCast, + expectedStr: expectedStr2Cmd1Write, + nrExpectedItemsRC: 2, + nrExpectedItemsWF: 1, + expectedError: nil, + }, + + {name: "case 3: add 1 item to section 'runcmd' and 1 item to 'write_files'", + action: func() { resetOsMocks(userdataSampleContentBothSections) }, + input: input1ItemRunCmdCast1ItemWriteFiles, + expectedStr: expectedStr2Cmd2Write, + nrExpectedItemsRC: 2, + nrExpectedItemsWF: 2, + expectedError: nil, + }, + + {name: "case 4: add 2 items to section 'runcmd' and 2 items to 'write_files'", + action: func() { resetOsMocks(userdataSampleContentBothSections) }, + input: input2ItemsRunCmdCast2ItemsWriteFiles, + expectedStr: expectedStr3Cmd3Write, + nrExpectedItemsRC: 3, + nrExpectedItemsWF: 3, + expectedError: nil, + }, + + {name: "case 5: no section 'runcmd' available section 'write_files' 1 item cmd, 1 item write", + action: func() { resetOsMocks(userdataSampleContentCmdNoWriteYes) }, + input: input1ItemRunCmdCast1ItemWriteFiles, + expectedStr: expectedStr1Cmd2Write, + nrExpectedItemsRC: 1, + nrExpectedItemsWF: 2, + expectedError: nil, + }, + + {name: "case 6: no section 'write_files' available section 'runcmd' 1 item cmd, 1 item write", + action: func() { resetOsMocks(userdataSampleContentCmdYesWriteNo) }, + input: input1ItemRunCmdCast1ItemWriteFiles, + expectedStr: expectedStr2Cmd1WriteBis, + nrExpectedItemsRC: 2, + nrExpectedItemsWF: 1, + expectedError: nil, + }, + + {name: "case 7: no section 'write_files' neither 'runcmd' 1 item cmd, 1 item write", + action: func() { resetOsMocks(userdataSampleContentNoSections) }, + input: input1ItemRunCmdCast1ItemWriteFiles, + expectedStr: expectedStr1Cmd1Write, + nrExpectedItemsRC: 1, + nrExpectedItemsWF: 1, + expectedError: nil, + }, + + {name: "case 8: no usedata file", + action: func() { + resetOsMocks(userdataSampleContentNoSections) + osStatErrorMessage = "no such file" + }, + input: nil, + expectedStr: "", + nrExpectedItemsRC: 0, + nrExpectedItemsWF: 0, + expectedError: fs.ErrNotExist, + }, + + {name: "case 9: error while reading from usedata file", + action: func() { + resetOsMocks(userdataSampleContentNoSections) + osReadFileMock = func(name string) ([]byte, error) { return []byte{}, expectedErrorReadingFromFile } + osReadFile = osReadFileMock + }, + input: nil, + expectedStr: "", + nrExpectedItemsRC: 0, + nrExpectedItemsWF: 0, + expectedError: expectedErrorReadingFromFile, + }, + + {name: "case 10: error while writing to usedata file", + action: func() { + resetOsMocks(userdataSampleContentNoSections) + osWriteFileMock = func(name string, data []byte, perm os.FileMode) error { + mockOsWriteFileContent = nil + return expectedErrorWritingToFile + } + osWriteFile = osWriteFileMock + }, + input: input1ItemRunCmdCast1ItemWriteFiles, + expectedStr: "", + nrExpectedItemsRC: 0, + nrExpectedItemsWF: 0, + expectedError: expectedErrorWritingToFile, + }, + } + + var expected, observed map[string][]any + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + if tc.action != nil { + tc.action() + } + err := sc.extendUserdata(tc.input) + + if tc.expectedError != nil { + if !errors.Is(err, tc.expectedError) { + t.Fatalf("expected: %v, but got: %v", tc.expectedError, err) + } + } else { + + /* convert to YAML objects; + Since YAML maps do not preserve ordering, comparing YAML as raw text will always fail. Thus compare YAML semantically and not textually. + */ + if err := yaml.Unmarshal([]byte(tc.expectedStr), &expected); err != nil { + t.Fatalf("failed to unmarshal expected: %v", err) + } + + if err := yaml.Unmarshal(mockOsWriteFileContent, &observed); err != nil { + t.Fatalf("failed to unmarshal observed: %v", err) + } + + assert.Equal(t, expected, observed) + assert.Equal(t, len(observed["runcmd"]), tc.nrExpectedItemsRC) + assert.Equal(t, len(observed["write_files"]), tc.nrExpectedItemsWF) + } + }) + } + +} diff --git a/cfgutils/resources_for_tests.go b/cfgutils/resources_for_tests.go index f18a89b..64f5544 100644 --- a/cfgutils/resources_for_tests.go +++ b/cfgutils/resources_for_tests.go @@ -90,5 +90,116 @@ write_files: - path: /tmp/cloud-config-test-write-files-2.log content: H4sIAAAAAAAA/wAAAP//cs7JL01RSM7PS8tMVyguTU5OTU1JTVFIyy9SKC/KLEmNT8vMSS1WKEgsKlEwAgQAAP//55tZZi0AAAA= encoding: "gzip+b64" + permissions: "0644"` + + userdataSampleContentBothSections = `#cloud-config +runcmd: + - timedatectl set-timezone Europe/Warsaw +write_files: + - path: /tmp/foo + content: Foo was here + encoding: "gzip+b64" + permissions: "0644"` + + input1ItemRunCmdCast = []CloudConfigItem{ + NewCloudConfigItemRunCmd([]string{`echo "Boot completed at $(date)" >> /tmp/cloud-config-test-runcmd.log`})} + + input1ItemRunCmdCast1ItemWriteFiles = []CloudConfigItem{ + NewCloudConfigItemRunCmd([]string{`echo "Boot completed at $(date)" >> /tmp/cloud-config-test-runcmd.log`}), + NewCloudConfigItemWriteFiles("/tmp/cloud-config-test-write-files.log", "Cloud config succeeded for write_files"), + } + + expectedStr2Cmd1Write = `#cloud-config +runcmd: + - timedatectl set-timezone Europe/Warsaw + - echo "Boot completed at $(date)" >> /tmp/cloud-config-test-runcmd.log +write_files: + - path: /tmp/foo + content: Foo was here + encoding: "gzip+b64" + permissions: "0644"` + + expectedStr2Cmd2Write = `#cloud-config +runcmd: + - timedatectl set-timezone Europe/Warsaw + - echo "Boot completed at $(date)" >> /tmp/cloud-config-test-runcmd.log +write_files: + - path: /tmp/foo + content: Foo was here + encoding: "gzip+b64" + permissions: "0644" + - path: /tmp/cloud-config-test-write-files.log + content: H4sIAAAAAAAA/wAAAP//cs7JL01RSM7PS8tMVyguTU5OTU1JTVFIyy9SKC/KLEmNT8vMSS0GBAAA//84FqCbJgAAAA== + encoding: "gzip+b64" + permissions: "0644"` + + input2ItemsRunCmdCast2ItemsWriteFiles = []CloudConfigItem{ + NewCloudConfigItemRunCmd([]string{ + `echo "Boot completed at $(date)" >> /tmp/cloud-config-test-runcmd.log`, + `echo "Cloud config test succeeded" >> /tmp/cloud-config-test-runcmd.log`}), + NewCloudConfigItemWriteFiles("/tmp/cloud-config-test-write-files.log", "Cloud config succeeded for write_files"), + NewCloudConfigItemWriteFiles("/tmp/cloud-config-test-write-files-2.log", "Cloud config succeeded for write_files part 2"), + } + + expectedStr3Cmd3Write = `#cloud-config +runcmd: + - timedatectl set-timezone Europe/Warsaw + - echo "Boot completed at $(date)" >> /tmp/cloud-config-test-runcmd.log + - echo "Cloud config test succeeded" >> /tmp/cloud-config-test-runcmd.log +write_files: + - path: /tmp/foo + content: Foo was here + encoding: "gzip+b64" + permissions: "0644" + - path: /tmp/cloud-config-test-write-files.log + content: H4sIAAAAAAAA/wAAAP//cs7JL01RSM7PS8tMVyguTU5OTU1JTVFIyy9SKC/KLEmNT8vMSS0GBAAA//84FqCbJgAAAA== + encoding: "gzip+b64" + permissions: "0644" + - path: /tmp/cloud-config-test-write-files-2.log + content: H4sIAAAAAAAA/wAAAP//cs7JL01RSM7PS8tMVyguTU5OTU1JTVFIyy9SKC/KLEmNT8vMSS1WKEgsKlEwAgQAAP//55tZZi0AAAA= + encoding: "gzip+b64" + permissions: "0644"` + + userdataSampleContentCmdNoWriteYes = `#cloud-config +write_files: + - path: /tmp/foo + content: Foo was here + encoding: "gzip+b64" + permissions: "0644"` + + expectedStr1Cmd2Write = `#cloud-config +runcmd: + - echo "Boot completed at $(date)" >> /tmp/cloud-config-test-runcmd.log +write_files: + - path: /tmp/foo + content: Foo was here + encoding: "gzip+b64" + permissions: "0644" + - path: /tmp/cloud-config-test-write-files.log + content: H4sIAAAAAAAA/wAAAP//cs7JL01RSM7PS8tMVyguTU5OTU1JTVFIyy9SKC/KLEmNT8vMSS0GBAAA//84FqCbJgAAAA== + encoding: "gzip+b64" + permissions: "0644"` + + userdataSampleContentCmdYesWriteNo = `#cloud-config +runcmd: + - timedatectl set-timezone Europe/Warsaw` + + expectedStr2Cmd1WriteBis = `#cloud-config +runcmd: + - timedatectl set-timezone Europe/Warsaw + - echo "Boot completed at $(date)" >> /tmp/cloud-config-test-runcmd.log +write_files: + - path: /tmp/cloud-config-test-write-files.log + content: H4sIAAAAAAAA/wAAAP//cs7JL01RSM7PS8tMVyguTU5OTU1JTVFIyy9SKC/KLEmNT8vMSS0GBAAA//84FqCbJgAAAA== + encoding: "gzip+b64" + permissions: "0644"` + + expectedStr1Cmd1Write = `#cloud-config +runcmd: + - echo "Boot completed at $(date)" >> /tmp/cloud-config-test-runcmd.log +write_files: + - path: /tmp/cloud-config-test-write-files.log + content: H4sIAAAAAAAA/wAAAP//cs7JL01RSM7PS8tMVyguTU5OTU1JTVFIyy9SKC/KLEmNT8vMSS0GBAAA//84FqCbJgAAAA== + encoding: "gzip+b64" permissions: "0644"` ) From 81ea57cc3ffdbd58a183bc5b2bee185038fb51d7 Mon Sep 17 00:00:00 2001 From: Lukasz Piotrowski Date: Tue, 25 Nov 2025 14:52:30 +0100 Subject: [PATCH 09/11] check unmarshaling error for all methods; --- cfgutils/cfgutils_test.go | 23 +++++++++++++++-------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/cfgutils/cfgutils_test.go b/cfgutils/cfgutils_test.go index 668751a..f267cf5 100644 --- a/cfgutils/cfgutils_test.go +++ b/cfgutils/cfgutils_test.go @@ -375,14 +375,14 @@ func TestExtendUserdataRunCmd(t *testing.T) { } -func TestExtendUserdataRunCmd_YamlUnmarshallinError(t *testing.T) { +func TestExtendUserdataRunCmd_YamlUnmarshalingError(t *testing.T) { sc := NewStandardCfgManager("", "/tmp/userdata.yaml") - resetOsMocks(userdataSampleContent) - mockOsReadFileContent = []byte(userdataSampleInvalidYamlContent) - err := sc.ExtendUserdataRunCmd(inputOneItemRunCmd) - if err == nil { - t.Fatal("expected error, but got nil") + type userdataFn func() error + functions := []userdataFn{ + func() error { return sc.ExtendUserdataRunCmd(inputOneItemRunCmd) }, + func() error { return sc.ExtendUserdataWriteFiles(inputOneItemWriteFiles) }, + func() error { return sc.extendUserdata(input1ItemRunCmdCast1ItemWriteFiles) }, } expectedErrMsg := []string{ @@ -390,8 +390,15 @@ func TestExtendUserdataRunCmd_YamlUnmarshallinError(t *testing.T) { "line 1: cannot unmarshal !!str", } - for _, errMsg := range expectedErrMsg { - assert.Contains(t, err.Error(), errMsg) + for _, fn := range functions { + resetOsMocks(userdataSampleContent) + mockOsReadFileContent = []byte(userdataSampleInvalidYamlContent) + + if err := fn(); err != nil { + for _, errMsg := range expectedErrMsg { + assert.Contains(t, err.Error(), errMsg) + } + } } } From 0fd408e84f1126e1f5d0f61d5af05ba46cac588e Mon Sep 17 00:00:00 2001 From: lukasz-piotrowski-fujitsu Date: Tue, 25 Nov 2025 19:36:54 +0000 Subject: [PATCH 10/11] ci: changelog file automatic update --- CHANGELOG.md | 35 +++++++++-------------------------- 1 file changed, 9 insertions(+), 26 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c26c07e..99f2c4c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,16 +1,18 @@ Changelog file generated automatically. Do not edit. -Changelog generated at: 2025-10-28T12:36:32+00:00 +Changelog generated at: 2025-11-25T19:36:54+00:00 # Changelog -## [Unreleased](https://github.com/fujitsu/docker-machine-driver-fsas/tree/HEAD) +## [v0.1.9](https://github.com/fujitsu/docker-machine-driver-fsas/tree/v0.1.9) (2025-11-24) -[Full Changelog](https://github.com/fujitsu/docker-machine-driver-fsas/compare/v0.1.7...HEAD) +[Full Changelog](https://github.com/fujitsu/docker-machine-driver-fsas/compare/v0.1.8...v0.1.9) **Merged pull requests:** -- Merged PR 1776: feature/handle\_sles\_registration [\#17](https://github.com/fujitsu/docker-machine-driver-fsas/pull/17) ([fujitsu-domzalskis](https://github.com/fujitsu-domzalskis)) -- Feature/set explicitly permisions in workflow files [\#16](https://github.com/fujitsu/docker-machine-driver-fsas/pull/16) ([lukasz-piotrowski-fujitsu](https://github.com/lukasz-piotrowski-fujitsu)) -- add log extension to gitignore; [\#15](https://github.com/fujitsu/docker-machine-driver-fsas/pull/15) ([lukasz-piotrowski-fujitsu](https://github.com/lukasz-piotrowski-fujitsu)) +- Deregister only registered SLES product [\#19](https://github.com/fujitsu/docker-machine-driver-fsas/pull/19) ([AnjriI](https://github.com/AnjriI)) + +## [v0.1.8](https://github.com/fujitsu/docker-machine-driver-fsas/tree/v0.1.8) (2025-10-29) + +[Full Changelog](https://github.com/fujitsu/docker-machine-driver-fsas/compare/v0.1.7...v0.1.8) ## [v0.1.7](https://github.com/fujitsu/docker-machine-driver-fsas/tree/v0.1.7) (2025-10-07) @@ -20,37 +22,18 @@ Changelog generated at: 2025-10-28T12:36:32+00:00 [Full Changelog](https://github.com/fujitsu/docker-machine-driver-fsas/compare/v0.1.5...v0.1.6-hotfix-cdi-746) -**Merged pull requests:** - -- Merged PR 1612: Implementation of GPU node labels [\#7](https://github.com/fujitsu/docker-machine-driver-fsas/pull/7) ([AnjriI](https://github.com/AnjriI)) - ## [v0.1.5](https://github.com/fujitsu/docker-machine-driver-fsas/tree/v0.1.5) (2025-10-03) [Full Changelog](https://github.com/fujitsu/docker-machine-driver-fsas/compare/v0.1.4...v0.1.5) -**Merged pull requests:** - -- new workflows for testing, creating release and generating changelog … [\#6](https://github.com/fujitsu/docker-machine-driver-fsas/pull/6) ([lukasz-piotrowski-fujitsu](https://github.com/lukasz-piotrowski-fujitsu)) - ## [v0.1.4](https://github.com/fujitsu/docker-machine-driver-fsas/tree/v0.1.4) (2025-09-23) [Full Changelog](https://github.com/fujitsu/docker-machine-driver-fsas/compare/v0.1.3-hotfix-cdi-802...v0.1.4) -**Merged pull requests:** - -- handle situation when CDI status cannot be found in statuses assignment map; [\#5](https://github.com/fujitsu/docker-machine-driver-fsas/pull/5) ([lukasz-piotrowski-fujitsu](https://github.com/lukasz-piotrowski-fujitsu)) - ## [v0.1.3-hotfix-cdi-802](https://github.com/fujitsu/docker-machine-driver-fsas/tree/v0.1.3-hotfix-cdi-802) (2025-09-19) [Full Changelog](https://github.com/fujitsu/docker-machine-driver-fsas/compare/v0.1.2-hotfix-cdi-817...v0.1.3-hotfix-cdi-802) -**Merged pull requests:** - -- Merged PR 1451: Implement graceful shutdown using FM API [\#4](https://github.com/fujitsu/docker-machine-driver-fsas/pull/4) ([AnjriI](https://github.com/AnjriI)) -- Fix/cdi-527 [\#3](https://github.com/fujitsu/docker-machine-driver-fsas/pull/3) ([lukasz-piotrowski-fujitsu](https://github.com/lukasz-piotrowski-fujitsu)) -- Merged PR 1477: Applying singular of CLIENT\_SECRETS and relevant across codebase [\#2](https://github.com/fujitsu/docker-machine-driver-fsas/pull/2) ([AnjriI](https://github.com/AnjriI)) -- Add pipeline into Github repository [\#1](https://github.com/fujitsu/docker-machine-driver-fsas/pull/1) ([fujitsu-domzalskis](https://github.com/fujitsu-domzalskis)) - ## [v0.1.2-hotfix-cdi-817](https://github.com/fujitsu/docker-machine-driver-fsas/tree/v0.1.2-hotfix-cdi-817) (2025-09-04) [Full Changelog](https://github.com/fujitsu/docker-machine-driver-fsas/compare/v0.1.1-hotfix-cdi-805...v0.1.2-hotfix-cdi-817) @@ -61,7 +44,7 @@ Changelog generated at: 2025-10-28T12:36:32+00:00 ## [v0.1.0](https://github.com/fujitsu/docker-machine-driver-fsas/tree/v0.1.0) (2025-09-01) -[Full Changelog](https://github.com/fujitsu/docker-machine-driver-fsas/compare/5f755625641ed1da2a127e80c54c38286efb4a7d...v0.1.0) +[Full Changelog](https://github.com/fujitsu/docker-machine-driver-fsas/compare/2be96e8ace1161aed15eb202341d78786306e639...v0.1.0) From 045d59c27d0509aa522e7b7f67ee1942ea0f516f Mon Sep 17 00:00:00 2001 From: Lukasz Piotrowski Date: Mon, 1 Dec 2025 21:20:26 +0900 Subject: [PATCH 11/11] rename test resources file to follow naming convention; add unit tests for unmarshaling error; change interface method names according to review suggestions; add condition for checking if unmarshaled data has right structure - list of interfaces (panic prevention); --- cfgutils/cfgutils.go | 77 ++++++++++--------- cfgutils/cfgutils_test.go | 72 +++++++++++++---- cfgutils/cloud_config.go | 42 +++++----- ...sources_for_tests.go => test_resources.go} | 15 +++- 4 files changed, 134 insertions(+), 72 deletions(-) rename cfgutils/{resources_for_tests.go => test_resources.go} (92%) diff --git a/cfgutils/cfgutils.go b/cfgutils/cfgutils.go index 19edc5b..00b6836 100644 --- a/cfgutils/cfgutils.go +++ b/cfgutils/cfgutils.go @@ -1,6 +1,7 @@ package cfgutils import ( + "bytes" "encoding/json" "fmt" "os" @@ -170,51 +171,57 @@ func (sc *StandardCfgManager) ExtendUserdataWriteFiles(fileObjects []CloudConfig // extendUserdata Extends cloud config userdata file func (sc *StandardCfgManager) extendUserdata(cci []CloudConfigItem) error { + if sc.userDataFile == "" { + return nil + } - userDataFile := sc.userDataFile - if userDataFile != "" { - if _, err := osStat(userDataFile); os.IsNotExist(err) { - slog.Error("User data file does not exist: ", "path", userDataFile, "err", err) - return err - } - - var userdata []byte - userdata, err := osReadFile(userDataFile) - if err != nil { - return err - } + if _, err := osStat(sc.userDataFile); os.IsNotExist(err) { + slog.Error("User data file does not exist:", "path", sc.userDataFile, "err", err) + return err + } - cloudConfig := make(map[any]any) - if err = yaml.Unmarshal(userdata, &cloudConfig); err != nil { - return err - } + userdata, err := osReadFile(sc.userDataFile) + if err != nil { + return err + } - for _, i := range cci { - newContent, err := i.addToCloudConfigFile() - if err != nil { - return fmt.Errorf("error while appending userdata file; section= %s; %w", i.section(), err) - } + cloudConfig := make(map[string]interface{}) + if err := yaml.Unmarshal(userdata, &cloudConfig); err != nil { + return err + } - if _, ok := cloudConfig[i.section()]; !ok { - // key does not exist then create fresh list - cloudConfig[i.section()] = []any{} - } + for _, ccItem := range cci { + moduleName := ccItem.getModuleName() - origSectionContent := cloudConfig[i.section()].([]any) - cloudConfig[i.section()] = append(origSectionContent, newContent...) + newContent, err := ccItem.getNewCloudConfigContent() + if err != nil { + return fmt.Errorf("error while appending userdata file; module= %s: %w", moduleName, err) } - userdataContent, err := yaml.Marshal(cloudConfig) - if err != nil { - return err + existing, ok := cloudConfig[moduleName] + if !ok { + cloudConfig[moduleName] = newContent + continue } - finalContent := append([]byte("#cloud-config\n"), userdataContent...) - err = osWriteFile(userDataFile, finalContent, 0644) - if err != nil { - return err + slice, ok := existing.([]interface{}) + if !ok { + return fmt.Errorf("module %s exists but is not a list", moduleName) } + cloudConfig[moduleName] = append(slice, newContent...) } - return nil + + yamlBytes, err := yaml.Marshal(cloudConfig) + if err != nil { + return err + } + + trimmed := bytes.TrimSpace(yamlBytes) + + if !bytes.HasPrefix(trimmed, []byte("#cloud-config")) { + trimmed = append([]byte("#cloud-config\n"), trimmed...) + } + + return osWriteFile(sc.userDataFile, trimmed, writeFilePermissions) } diff --git a/cfgutils/cfgutils_test.go b/cfgutils/cfgutils_test.go index f267cf5..c81f173 100644 --- a/cfgutils/cfgutils_test.go +++ b/cfgutils/cfgutils_test.go @@ -378,27 +378,65 @@ func TestExtendUserdataRunCmd(t *testing.T) { func TestExtendUserdataRunCmd_YamlUnmarshalingError(t *testing.T) { sc := NewStandardCfgManager("", "/tmp/userdata.yaml") - type userdataFn func() error - functions := []userdataFn{ - func() error { return sc.ExtendUserdataRunCmd(inputOneItemRunCmd) }, - func() error { return sc.ExtendUserdataWriteFiles(inputOneItemWriteFiles) }, - func() error { return sc.extendUserdata(input1ItemRunCmdCast1ItemWriteFiles) }, - } - - expectedErrMsg := []string{ - "yaml: unmarshal errors", - "line 1: cannot unmarshal !!str", + testCases := []struct { + action func() + name string + expectedErrorStr []string + }{ + {name: "case 1: invalid yaml file - random ascii chars", + action: func() { resetOsMocks(userdataSampleInvalidYamlContentRandomAscii) }, + expectedErrorStr: []string{ + "yaml: unmarshal errors", + "line 1: cannot unmarshal !!str", + }, + }, + {name: "case 2: invalid yaml file - runcmd is not list but integer", + action: func() { resetOsMocks(userdataSampleInvalidYamlContentRunCmdIsInteger) }, + expectedErrorStr: []string{ + "module runcmd exists but is not a list", + }, + }, + {name: "case 3: invalid yaml file - runcmd is not list but string", + action: func() { resetOsMocks(userdataSampleInvalidYamlContentRunCmdIsString) }, + expectedErrorStr: []string{ + "module runcmd exists but is not a list", + }, + }, + {name: "case 4: invalid yaml file - runcmd is not list but bool", + action: func() { resetOsMocks(userdataSampleInvalidYamlContentRunCmdIsBool) }, + expectedErrorStr: []string{ + "module runcmd exists but is not a list", + }, + }, + {name: "case 5: invalid yaml file - runcmd is not list but map", + action: func() { resetOsMocks(userdataSampleInvalidYamlContentRunCmdIsMap) }, + expectedErrorStr: []string{ + "module runcmd exists but is not a list", + }, + }, + {name: "case 6: invalid yaml file - runcmd is not list but nil", + action: func() { resetOsMocks(userdataSampleInvalidYamlContentRunCmdIsNil) }, + expectedErrorStr: []string{ + "module runcmd exists but is not a list", + }, + }, } - for _, fn := range functions { - resetOsMocks(userdataSampleContent) - mockOsReadFileContent = []byte(userdataSampleInvalidYamlContent) + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + if tc.action != nil { + tc.action() + } + err := sc.extendUserdata(input1ItemRunCmdCast1ItemWriteFiles) - if err := fn(); err != nil { - for _, errMsg := range expectedErrMsg { - assert.Contains(t, err.Error(), errMsg) + if err == nil { + t.Fatal("expected error but got nil") + } else { + for _, errMsg := range tc.expectedErrorStr { + assert.Contains(t, err.Error(), errMsg) + } } - } + }) } } diff --git a/cfgutils/cloud_config.go b/cfgutils/cloud_config.go index 868f0d4..7fcdafc 100644 --- a/cfgutils/cloud_config.go +++ b/cfgutils/cloud_config.go @@ -4,14 +4,18 @@ import ( "bytes" "compress/gzip" "encoding/base64" + "fmt" + "os" ) +const writeFilePermissions = os.FileMode(0644) + type CloudConfigItem interface { - section() string - addToCloudConfigFile() ([]any, error) + getModuleName() string + getNewCloudConfigContent() ([]interface{}, error) } -// structure for storing items that correspond to cloud config userdata file items from section 'runcmd' +// structure for storing items that correspond to cloud config userdata file items from module 'runcmd' type cloudConfigItemRunCmd struct { commands []string } @@ -20,19 +24,19 @@ func NewCloudConfigItemRunCmd(cmds []string) cloudConfigItemRunCmd { return cloudConfigItemRunCmd{cmds} } -func (c cloudConfigItemRunCmd) addToCloudConfigFile() ([]any, error) { - list := []any{} - for _, cmd := range c.commands { - list = append(list, cmd) +func (c cloudConfigItemRunCmd) getNewCloudConfigContent() ([]interface{}, error) { + ccItems := make([]interface{}, len(c.commands)) + for i, cmd := range c.commands { + ccItems[i] = cmd } - return list, nil + return ccItems, nil } -func (c cloudConfigItemRunCmd) section() string { +func (c cloudConfigItemRunCmd) getModuleName() string { return "runcmd" } -// structure for storing items that corresponds to cloud config userdata file items from section 'write_files' +// structure for storing items that corresponds to cloud config userdata file items from module 'write_files' type cloudConfigItemWriteFiles struct { encoding string content string @@ -44,18 +48,18 @@ func NewCloudConfigItemWriteFiles(path, content string) cloudConfigItemWriteFile return cloudConfigItemWriteFiles{ encoding: "gzip+b64", content: content, - permissions: "0644", + permissions: fmt.Sprintf("%04o", writeFilePermissions), path: path, } } -func (c cloudConfigItemWriteFiles) addToCloudConfigFile() ([]any, error) { +func (c cloudConfigItemWriteFiles) getNewCloudConfigContent() ([]interface{}, error) { zippedContent, err := gzipEncode([]byte(c.content)) - b64Encoded := base64.StdEncoding.EncodeToString(zippedContent) if err != nil { - return []any{}, err + return nil, err } - return []any{ + b64Encoded := base64.StdEncoding.EncodeToString(zippedContent) + return []interface{}{ map[string]string{ "encoding": c.encoding, "content": b64Encoded, @@ -64,7 +68,7 @@ func (c cloudConfigItemWriteFiles) addToCloudConfigFile() ([]any, error) { }}, nil } -func (c cloudConfigItemWriteFiles) section() string { +func (c cloudConfigItemWriteFiles) getModuleName() string { return "write_files" } @@ -73,11 +77,13 @@ func gzipEncode(data []byte) ([]byte, error) { var b bytes.Buffer gz := gzip.NewWriter(&b) gz.Flush() + if _, err := gz.Write(data); err != nil { - return []byte{}, err + return nil, err } + if err := gz.Close(); err != nil { - return []byte{}, err + return nil, err } return b.Bytes(), nil diff --git a/cfgutils/resources_for_tests.go b/cfgutils/test_resources.go similarity index 92% rename from cfgutils/resources_for_tests.go rename to cfgutils/test_resources.go index 64f5544..71ace29 100644 --- a/cfgutils/resources_for_tests.go +++ b/cfgutils/test_resources.go @@ -8,8 +8,19 @@ runcmd: - timedatectl set-timezone Europe/Warsaw ` - userdataSampleContentNoSections = `#cloud-config` - userdataSampleInvalidYamlContent = `.32??#(&&)58ffo:bar` + userdataSampleContentNoSections = `#cloud-config` + userdataSampleInvalidYamlContentRandomAscii = `.32??#(&&)58ffo:bar` + userdataSampleInvalidYamlContentRunCmdIsInteger = `#cloud-config + runcmd: 123` + userdataSampleInvalidYamlContentRunCmdIsString = `#cloud-config + runcmd: foobar` + userdataSampleInvalidYamlContentRunCmdIsBool = `#cloud-config + runcmd: true` + userdataSampleInvalidYamlContentRunCmdIsMap = `#cloud-config + runcmd: + foo: bar` + userdataSampleInvalidYamlContentRunCmdIsNil = `#cloud-config + runcmd:` inputOneItemRunCmd = []string{ `echo "Boot completed at $(date)" >> /tmp/cloud-config-test-runcmd.log`,