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) diff --git a/cfgutils/cfgutils.go b/cfgutils/cfgutils.go index d2522ae..00b6836 100644 --- a/cfgutils/cfgutils.go +++ b/cfgutils/cfgutils.go @@ -1,16 +1,22 @@ package cfgutils import ( + "bytes" "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 +24,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 +68,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 +102,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 +112,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 +127,101 @@ 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 { + if sc.userDataFile == "" { + return nil + } + + if _, err := osStat(sc.userDataFile); os.IsNotExist(err) { + slog.Error("User data file does not exist:", "path", sc.userDataFile, "err", err) + return err + } + + userdata, err := osReadFile(sc.userDataFile) + if err != nil { + return err + } + + cloudConfig := make(map[string]interface{}) + if err := yaml.Unmarshal(userdata, &cloudConfig); err != nil { + return err + } + + for _, ccItem := range cci { + moduleName := ccItem.getModuleName() + + newContent, err := ccItem.getNewCloudConfigContent() + if err != nil { + return fmt.Errorf("error while appending userdata file; module= %s: %w", moduleName, err) + } + + existing, ok := cloudConfig[moduleName] + if !ok { + cloudConfig[moduleName] = newContent + continue + } + + slice, ok := existing.([]interface{}) + if !ok { + return fmt.Errorf("module %s exists but is not a list", moduleName) + } + + cloudConfig[moduleName] = append(slice, newContent...) + } + + 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 9260fd7..c81f173 100644 --- a/cfgutils/cfgutils_test.go +++ b/cfgutils/cfgutils_test.go @@ -2,11 +2,15 @@ package cfgutils import ( "encoding/json" + "errors" "fmt" + "io/fs" + "os" "testing" "github.com/fujitsu/docker-machine-driver-fsas/models" "github.com/stretchr/testify/assert" + "gopkg.in/yaml.v3" ) func TestIsInit_Fail(t *testing.T) { @@ -16,7 +20,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 +48,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 +66,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 +84,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 +119,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 +137,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 +173,537 @@ 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) } + +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(userdataContent string) { + 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(userdataContent) + osReadFile = osReadFileMock + osWriteFile = osWriteFileMock + +} + +func TestExtendUserdataRunCmd(t *testing.T) { + sc := NewStandardCfgManager("", "/tmp/userdata.yaml") + + testCases := []struct { + action func() + name string + input []string + expectedStr string + nrExpectedItems int + expectedError error + }{ + {name: "case 1: input as empty list", + action: func() { resetOsMocks(userdataSampleContent) }, + input: []string{}, + expectedStr: userdataSampleContent, + nrExpectedItems: 1, + expectedError: nil, + }, + + {name: "case 2: add one item to section 'runcmd'", + action: func() { resetOsMocks(userdataSampleContent) }, + input: inputOneItemRunCmd, + expectedStr: expectedStr2Cmd, + nrExpectedItems: 2, + expectedError: nil, + }, + + {name: "case 3: add two items to section 'runcmd'", + action: func() { resetOsMocks(userdataSampleContent) }, + input: inputTwoItemsRunCmd, + expectedStr: expectedStr3Cmd, + nrExpectedItems: 3, + expectedError: nil, + }, + + {name: "case 4: section 'runcmd' does not exist", + action: func() { + resetOsMocks(userdataSampleContentNoSections) + }, + input: inputOneItemRunCmd, + expectedStr: expectedStr1Cmd, + nrExpectedItems: 1, + expectedError: nil, + }, + + {name: "case 5: no usedata file", + action: func() { + resetOsMocks(userdataSampleContent) + osStatErrorMessage = "no such file" + }, + input: nil, + expectedStr: "", + nrExpectedItems: 0, + expectedError: fs.ErrNotExist, + }, + + {name: "case 6: error while reading from usedata file", + action: func() { + resetOsMocks(userdataSampleContent) + 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(userdataSampleContent) + osWriteFileMock = func(name string, data []byte, perm os.FileMode) error { + mockOsWriteFileContent = nil + return expectedErrorWritingToFile + } + osWriteFile = osWriteFileMock + }, + input: inputOneItemRunCmd, + 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.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) + } + + assert.Equal(t, expected, observed) + assert.Equal(t, len(observed["runcmd"]), tc.nrExpectedItems) + } + }) + } + +} + +func TestExtendUserdataRunCmd_YamlUnmarshalingError(t *testing.T) { + sc := NewStandardCfgManager("", "/tmp/userdata.yaml") + + 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 _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + if tc.action != nil { + tc.action() + } + err := sc.extendUserdata(input1ItemRunCmdCast1ItemWriteFiles) + + if err == nil { + t.Fatal("expected error but got nil") + } else { + for _, errMsg := range tc.expectedErrorStr { + 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) + } + }) + } + +} + +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/cloud_config.go b/cfgutils/cloud_config.go new file mode 100644 index 0000000..7fcdafc --- /dev/null +++ b/cfgutils/cloud_config.go @@ -0,0 +1,90 @@ +package cfgutils + +import ( + "bytes" + "compress/gzip" + "encoding/base64" + "fmt" + "os" +) + +const writeFilePermissions = os.FileMode(0644) + +type CloudConfigItem interface { + getModuleName() string + getNewCloudConfigContent() ([]interface{}, error) +} + +// structure for storing items that correspond to cloud config userdata file items from module 'runcmd' +type cloudConfigItemRunCmd struct { + commands []string +} + +func NewCloudConfigItemRunCmd(cmds []string) cloudConfigItemRunCmd { + return cloudConfigItemRunCmd{cmds} +} + +func (c cloudConfigItemRunCmd) getNewCloudConfigContent() ([]interface{}, error) { + ccItems := make([]interface{}, len(c.commands)) + for i, cmd := range c.commands { + ccItems[i] = cmd + } + return ccItems, nil +} + +func (c cloudConfigItemRunCmd) getModuleName() string { + return "runcmd" +} + +// structure for storing items that corresponds to cloud config userdata file items from module '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: fmt.Sprintf("%04o", writeFilePermissions), + path: path, + } +} + +func (c cloudConfigItemWriteFiles) getNewCloudConfigContent() ([]interface{}, error) { + zippedContent, err := gzipEncode([]byte(c.content)) + if err != nil { + return nil, err + } + b64Encoded := base64.StdEncoding.EncodeToString(zippedContent) + return []interface{}{ + map[string]string{ + "encoding": c.encoding, + "content": b64Encoded, + "permissions": c.permissions, + "path": c.path, + }}, nil +} + +func (c cloudConfigItemWriteFiles) getModuleName() 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 nil, err + } + + if err := gz.Close(); err != nil { + return nil, 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/cfgutils/test_resources.go b/cfgutils/test_resources.go new file mode 100644 index 0000000..71ace29 --- /dev/null +++ b/cfgutils/test_resources.go @@ -0,0 +1,216 @@ +package cfgutils + +import "errors" + +var ( + userdataSampleContent = `#cloud-config +runcmd: + - timedatectl set-timezone Europe/Warsaw +` + + 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`, + } + + expectedStr2Cmd = ` +#cloud-config +runcmd: + - timedatectl set-timezone Europe/Warsaw + - echo "Boot completed at $(date)" >> /tmp/cloud-config-test-runcmd.log +` + + inputTwoItemsRunCmd = []string{ + `echo "Boot completed at $(date)" >> /tmp/cloud-config-test-runcmd.log`, + `echo "Cloud config test succeeded" >> /tmp/cloud-config-test-runcmd.log`, + } + + expectedStr3Cmd = ` +#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 +` + expectedErrorReadingFromFile = errors.New("error while reading file") + + 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"` + + 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"` +) diff --git a/pkg/drivers/fsas/fsas.go b/pkg/drivers/fsas/fsas.go index 0006fd2..ccc5524 100644 --- a/pkg/drivers/fsas/fsas.go +++ b/pkg/drivers/fsas/fsas.go @@ -610,7 +610,7 @@ func (d *Driver) innerCreate() error { } if !d.CfgManager.IsInit() { - cfgManager := cfgutils.NewStandardCfgManager(d.DevicesSpecJson) + cfgManager := cfgutils.NewStandardCfgManager(d.DevicesSpecJson, d.UserDataFile) d.CfgManager = cfgManager }