From d898ce522c1e984816c21fb064f2c4093d548afe Mon Sep 17 00:00:00 2001 From: cloudguruab Date: Wed, 27 May 2026 18:51:11 +0200 Subject: [PATCH 1/5] fix: write heat-stack-info.txt from Go module at the configured dir --- plugins/modules/create_heat_stack.py | 11 +++++ .../create_heat_stack/create_heat_stack.go | 22 ++++++++-- roles/import_workloads/tasks/heat_deploy.yml | 41 +------------------ 3 files changed, 31 insertions(+), 43 deletions(-) diff --git a/plugins/modules/create_heat_stack.py b/plugins/modules/create_heat_stack.py index 94501ef..3e4d76c 100644 --- a/plugins/modules/create_heat_stack.py +++ b/plugins/modules/create_heat_stack.py @@ -45,6 +45,12 @@ required: false type: int default: 600 + output_dir: + description: + - Directory where heat-stack-info.txt will be written after successful stack creation. + - When set, the file is written on the same host that runs the module, at the exact path specified. + required: false + type: str """ EXAMPLES = r""" @@ -72,4 +78,9 @@ returned: success type: dict sample: {"id": "stack-uuid", "name": "os-migrate-20240120", "status": "CREATE_COMPLETE"} +info_path: + description: Path to the written heat-stack-info.txt file + returned: when output_dir is provided + type: str + sample: "/opt/os-migrate/heat-stack-info.txt" """ diff --git a/plugins/modules/src/create_heat_stack/create_heat_stack.go b/plugins/modules/src/create_heat_stack/create_heat_stack.go index d279327..82fb256 100644 --- a/plugins/modules/src/create_heat_stack/create_heat_stack.go +++ b/plugins/modules/src/create_heat_stack/create_heat_stack.go @@ -21,6 +21,7 @@ import ( "encoding/json" "fmt" "os" + "path/filepath" "time" "vmware-migration-kit/plugins/module_utils/ansible" @@ -40,6 +41,7 @@ type ModuleArgs struct { Parameters map[string]interface{} `json:"parameters"` Wait bool `json:"wait"` Timeout int `json:"timeout"` + OutputDir string `json:"output_dir"` } type StackInfo struct { @@ -49,10 +51,11 @@ type StackInfo struct { } type Response struct { - Msg string `json:"msg"` - Changed bool `json:"changed"` - Failed bool `json:"failed"` - Stack StackInfo `json:"stack,omitempty"` + Msg string `json:"msg"` + Changed bool `json:"changed"` + Failed bool `json:"failed"` + Stack StackInfo `json:"stack,omitempty"` + InfoPath string `json:"info_path,omitempty"` } func exitJson(responseBody Response) { @@ -216,5 +219,16 @@ func Run() { Status: finalStack.Status, }, } + + if moduleArgs.OutputDir != "" { + infoPath := filepath.Join(moduleArgs.OutputDir, "heat_stack_info.txt") + content := fmt.Sprintf("Stack Name: %s\nStack ID: %s\nStatus: %s\nTemplate: %s\n", + finalStack.Name, finalStack.ID, finalStack.Status, moduleArgs.TemplatePath) + if err := os.WriteFile(infoPath, []byte(content), 0644); err != nil { + ansible.FailJson(ansible.Response{Msg: "Failed to write stack info file: " + err.Error()}) + } + response.InfoPath = infoPath + } + exitJson(response) } diff --git a/roles/import_workloads/tasks/heat_deploy.yml b/roles/import_workloads/tasks/heat_deploy.yml index d8ad055..526cbdd 100644 --- a/roles/import_workloads/tasks/heat_deploy.yml +++ b/roles/import_workloads/tasks/heat_deploy.yml @@ -86,6 +86,7 @@ parameters: "{{ heat_template_generated.parameters }}" wait: true timeout: 600 + output_dir: "{{ os_migrate_vmw_data_dir }}" register: heat_stack_created when: import_workloads_heat_auto_deploy | default(true) | bool @@ -101,45 +102,7 @@ View events: openstack stack event list {{ heat_stack_created.stack.id }} when: heat_stack_created is defined and not heat_stack_created.failed -- name: Save Heat stack information to file - ansible.builtin.copy: - content: | - # Heat Stack Information - Stack Name: {{ heat_stack_created.stack.name }} - Stack ID: {{ heat_stack_created.stack.id }} - Status: {{ heat_stack_created.stack.status }} - Template: {{ heat_template_generated.template_path }} - - ## Migrated VMs - {% for vm in heat_vms_data %} - - {{ vm.name }}: - Boot Volume: {{ vm.boot_volume_id }} - Flavor: {{ vm.flavor }} - Network: {{ vm.network }} - {% endfor %} - - ## Heat Management Commands - # View stack details - openstack stack show {{ heat_stack_created.stack.id }} - - # List stack resources - openstack stack resource list {{ heat_stack_created.stack.id }} - - # View stack events - openstack stack event list {{ heat_stack_created.stack.id }} - - # Update stack (with template changes) - openstack stack update {{ heat_stack_created.stack.id }} -t {{ heat_template_generated.template_path }} - - # Delete stack (WARNING: This will delete managed resources - instances and ports) - # Note: Cinder volumes are external and will NOT be deleted - openstack stack delete {{ heat_stack_created.stack.id }} - dest: "{{ os_migrate_vmw_data_dir }}/heat-stack-info.txt" - mode: '0644' - when: heat_stack_created is defined and not heat_stack_created.failed - delegate_to: localhost - - name: Display saved stack info location ansible.builtin.debug: - msg: "Stack information saved to {{ os_migrate_vmw_data_dir }}/heat-stack-info.txt" + msg: "Stack information saved to {{ heat_stack_created.info_path }}" when: heat_stack_created is defined and not heat_stack_created.failed From 4a99adafc668d45da7e77c00ba1308d34b19ae9c Mon Sep 17 00:00:00 2001 From: cloudguruab Date: Sun, 31 May 2026 21:33:40 +0200 Subject: [PATCH 2/5] pin pyvmomi to stable version --- aee/requirements.txt | 2 +- playbooks/setup_requirements.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/aee/requirements.txt b/aee/requirements.txt index aef5a4c..72dcc8e 100644 --- a/aee/requirements.txt +++ b/aee/requirements.txt @@ -1,3 +1,3 @@ requests pyVim -pyVmomi +pyVmomi<9.1.0.0 diff --git a/playbooks/setup_requirements.yml b/playbooks/setup_requirements.yml index 394a979..e9f5bd4 100644 --- a/playbooks/setup_requirements.yml +++ b/playbooks/setup_requirements.yml @@ -28,6 +28,6 @@ - openstacksdk>1.0.0 - requests - pyVim - - pyVmomi + - pyVmomi<9.1.0.0 - aiohttp when: not runner_from_aee | default(false) From 241be2e0fdbca96262330a321b282e4d302d7a4b Mon Sep 17 00:00:00 2001 From: cloudguruab Date: Sun, 31 May 2026 22:43:00 +0200 Subject: [PATCH 3/5] add tests for heat info text file placement --- .../create_heat_stack/create_heat_stack.go | 22 +++- tests/unit/create_heat_stack_test.go | 120 ++++++++++++++++++ 2 files changed, 138 insertions(+), 4 deletions(-) create mode 100644 tests/unit/create_heat_stack_test.go diff --git a/plugins/modules/src/create_heat_stack/create_heat_stack.go b/plugins/modules/src/create_heat_stack/create_heat_stack.go index 82fb256..4a960c8 100644 --- a/plugins/modules/src/create_heat_stack/create_heat_stack.go +++ b/plugins/modules/src/create_heat_stack/create_heat_stack.go @@ -58,6 +58,18 @@ type Response struct { InfoPath string `json:"info_path,omitempty"` } +const stackInfoFileName = "heat_stack_info.txt" + +func WriteStackInfoFile(outputDir string, stack StackInfo, templatePath string) (string, error) { + infoPath := filepath.Join(outputDir, stackInfoFileName) + content := fmt.Sprintf("Stack Name: %s\nStack ID: %s\nStatus: %s\nTemplate: %s\n", + stack.Name, stack.ID, stack.Status, templatePath) + if err := os.WriteFile(infoPath, []byte(content), 0644); err != nil { + return "", err + } + return infoPath, nil +} + func exitJson(responseBody Response) { ansible.ReturnResponseWithDeps(ansible.Response{ Msg: responseBody.Msg, @@ -221,10 +233,12 @@ func Run() { } if moduleArgs.OutputDir != "" { - infoPath := filepath.Join(moduleArgs.OutputDir, "heat_stack_info.txt") - content := fmt.Sprintf("Stack Name: %s\nStack ID: %s\nStatus: %s\nTemplate: %s\n", - finalStack.Name, finalStack.ID, finalStack.Status, moduleArgs.TemplatePath) - if err := os.WriteFile(infoPath, []byte(content), 0644); err != nil { + infoPath, err := WriteStackInfoFile(moduleArgs.OutputDir, StackInfo{ + Name: finalStack.Name, + ID: finalStack.ID, + Status: finalStack.Status, + }, moduleArgs.TemplatePath) + if err != nil { ansible.FailJson(ansible.Response{Msg: "Failed to write stack info file: " + err.Error()}) } response.InfoPath = infoPath diff --git a/tests/unit/create_heat_stack_test.go b/tests/unit/create_heat_stack_test.go new file mode 100644 index 0000000..a7e6deb --- /dev/null +++ b/tests/unit/create_heat_stack_test.go @@ -0,0 +1,120 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * Copyright 2025 Red Hat, Inc. + * + */ + +package moduleutils + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + createheatstack "vmware-migration-kit/plugins/modules/src/create_heat_stack" +) + +func TestWriteStackInfoFile(t *testing.T) { + dir := t.TempDir() + stack := createheatstack.StackInfo{ + Name: "os-migrate-test", + ID: "abc-123", + Status: "CREATE_COMPLETE", + } + templatePath := "/opt/os-migrate/template.yaml" + + infoPath, err := createheatstack.WriteStackInfoFile(dir, stack, templatePath) + if err != nil { + t.Fatalf("WriteStackInfoFile failed: %v", err) + } + + expectedPath := filepath.Join(dir, "heat_stack_info.txt") + if infoPath != expectedPath { + t.Errorf("expected info path %q, got %q", expectedPath, infoPath) + } + + content, err := os.ReadFile(infoPath) + if err != nil { + t.Fatalf("failed to read info file: %v", err) + } + + want := strings.Join([]string{ + "Stack Name: os-migrate-test", + "Stack ID: abc-123", + "Status: CREATE_COMPLETE", + "Template: /opt/os-migrate/template.yaml", + }, "\n") + "\n" + if string(content) != want { + t.Errorf("unexpected content:\n%s", string(content)) + } +} + +func TestWriteStackInfoFile_InvalidDir(t *testing.T) { + stack := createheatstack.StackInfo{ + Name: "os-migrate-test", + ID: "abc-123", + Status: "CREATE_COMPLETE", + } + + _, err := createheatstack.WriteStackInfoFile("/nonexistent/path/that/does/not/exist", stack, "/t.yaml") + if err == nil { + t.Fatal("expected error writing to invalid directory") + } +} + +func TestModuleArgsUnmarshalOutputDir(t *testing.T) { + raw := `{"output_dir": "/opt/os-migrate", "stack_name": "test-stack"}` + var args createheatstack.ModuleArgs + if err := json.Unmarshal([]byte(raw), &args); err != nil { + t.Fatalf("unmarshal failed: %v", err) + } + if args.OutputDir != "/opt/os-migrate" { + t.Errorf("expected output_dir '/opt/os-migrate', got %q", args.OutputDir) + } +} + +func TestResponseInfoPathJSON(t *testing.T) { + withPath := createheatstack.Response{ + Msg: "Heat stack created successfully", + Changed: true, + InfoPath: "/opt/os-migrate/heat_stack_info.txt", + Stack: createheatstack.StackInfo{ + ID: "stack-id", + Name: "os-migrate-test", + Status: "CREATE_COMPLETE", + }, + } + + data, err := json.Marshal(withPath) + if err != nil { + t.Fatalf("marshal failed: %v", err) + } + if !strings.Contains(string(data), `"info_path":"/opt/os-migrate/heat_stack_info.txt"`) { + t.Errorf("expected info_path in JSON, got %s", string(data)) + } + + withoutPath := createheatstack.Response{ + Msg: "Heat stack created successfully", + Changed: true, + } + data, err = json.Marshal(withoutPath) + if err != nil { + t.Fatalf("marshal failed: %v", err) + } + if strings.Contains(string(data), "info_path") { + t.Errorf("expected info_path omitted when empty, got %s", string(data)) + } +} From 5f0c710726dc965713deae43322381eba41c2b07 Mon Sep 17 00:00:00 2001 From: cloudguruab Date: Sun, 31 May 2026 22:49:34 +0200 Subject: [PATCH 4/5] unittests for volume lookup bug --- tests/unit/utils_test.go | 42 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/tests/unit/utils_test.go b/tests/unit/utils_test.go index d1df488..1994ee2 100644 --- a/tests/unit/utils_test.go +++ b/tests/unit/utils_test.go @@ -281,6 +281,48 @@ func TestSafeVmName(t *testing.T) { } } +// TestSafeVmName_HyphenHandling verifies hyphens are preserved so Cinder volume +// names built from SafeVmName stay consistent across create and lookup paths. +func TestSafeVmName_HyphenHandling(t *testing.T) { + tests := []struct { + input string + expected string + }{ + {"vm-01", "vm-01"}, + {"prod-web-server", "prod-web-server"}, + {"MyCompany-Web-01", "MyCompany-Web-01"}, + {"vm--01", "vm-01"}, + {"web---app", "web-app"}, + {"vm_01", "vm_01"}, + {"vm-01_prod", "vm-01_prod"}, + {"host-", "host"}, + {"host---", "host"}, + {strings.Repeat("a", 63) + "-", strings.Repeat("a", 63)}, + } + + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + result := moduleutils.SafeVmName(tt.input) + if result != tt.expected { + t.Errorf("SafeVmName(%q) = %q; want %q", tt.input, result, tt.expected) + } + }) + } +} + +func TestSafeVmName_VolumeNameConsistency(t *testing.T) { + vmName := "prod-web-01" + safeName := moduleutils.SafeVmName(vmName) + volumeName := safeName + "-0" + + if safeName != "prod-web-01" { + t.Errorf("SafeVmName(%q) = %q; want %q", vmName, safeName, "prod-web-01") + } + if volumeName != "prod-web-01-0" { + t.Errorf("volume name = %q; want %q", volumeName, "prod-web-01-0") + } +} + // Test 12: GenRandom produces different outputs on multiple calls (randomness) func TestGenRandom_Randomness(t *testing.T) { length := 20 From 1e221681972113ece7943eaeb642c2b2c97666a1 Mon Sep 17 00:00:00 2001 From: cloudguruab Date: Mon, 15 Jun 2026 11:29:29 +0200 Subject: [PATCH 5/5] remove pyvmomi pin --- aee/requirements.txt | 2 +- playbooks/setup_requirements.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/aee/requirements.txt b/aee/requirements.txt index 72dcc8e..aef5a4c 100644 --- a/aee/requirements.txt +++ b/aee/requirements.txt @@ -1,3 +1,3 @@ requests pyVim -pyVmomi<9.1.0.0 +pyVmomi diff --git a/playbooks/setup_requirements.yml b/playbooks/setup_requirements.yml index e9f5bd4..394a979 100644 --- a/playbooks/setup_requirements.yml +++ b/playbooks/setup_requirements.yml @@ -28,6 +28,6 @@ - openstacksdk>1.0.0 - requests - pyVim - - pyVmomi<9.1.0.0 + - pyVmomi - aiohttp when: not runner_from_aee | default(false)