Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions plugins/modules/create_heat_stack.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"""
Expand Down Expand Up @@ -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"
"""
36 changes: 32 additions & 4 deletions plugins/modules/src/create_heat_stack/create_heat_stack.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"time"

"vmware-migration-kit/plugins/module_utils/ansible"
Expand All @@ -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 {
Expand All @@ -49,10 +51,23 @@ 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"`
}

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) {
Expand Down Expand Up @@ -216,5 +231,18 @@ func Run() {
Status: finalStack.Status,
},
}

if moduleArgs.OutputDir != "" {
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
}

exitJson(response)
}
41 changes: 2 additions & 39 deletions roles/import_workloads/tasks/heat_deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
120 changes: 120 additions & 0 deletions tests/unit/create_heat_stack_test.go
Original file line number Diff line number Diff line change
@@ -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))
}
}
42 changes: 42 additions & 0 deletions tests/unit/utils_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading