Skip to content

Commit 69b1e46

Browse files
committed
feat: clone from .ovf/.ova
Add support for cloning virtual machines from .ovf and .ova files using ovftool, in addition to .vmx files. Signed-off-by: Ryan Johnson <ryan@tenthirtyam.org>
1 parent d80f546 commit 69b1e46

31 files changed

Lines changed: 598 additions & 144 deletions

.web-docs/components/builder/vmx/README.md

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ JSON Example:
5151

5252
<!-- Code generated from the comments of the Config struct in builder/vmware/vmx/config.go; DO NOT EDIT MANUALLY -->
5353

54-
- `source_path` (string) - Path to the source `.vmx` file to clone.
54+
- `source_path` (string) - Path to the source `.vmx`, '.ovf', or '.ova' file to clone.
5555

5656
<!-- End of code generated from the comments of the Config struct in builder/vmware/vmx/config.go; -->
5757

@@ -90,6 +90,18 @@ JSON Example:
9090
- `snapshot_name` (string) - This is the name of the initial snapshot created after provisioning and
9191
cleanup. If blank, no snapshot is created.
9292

93+
- `guest_os_type` (string) - The guest operating system identifier for the virtual machine.
94+
95+
~> **Note:** This is required when cloning from an OVF/OVA file
96+
and overrides the guest operating system identifier set by ovftool.
97+
98+
- `version` (int) - The virtual machine hardware version. Refer to [KB 315655](https://knowledge.broadcom.com/external/article?articleNumber=315655)
99+
for more information on supported virtual hardware versions.
100+
Default is 21. Minimum is 19.
101+
102+
~> **Note:** This is only used when cloning from an OVF/OVA file
103+
and overrides the hardware version set by ovftool.
104+
93105
<!-- End of code generated from the comments of the Config struct in builder/vmware/vmx/config.go; -->
94106

95107

builder/vmware/vmx/builder.go

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -100,11 +100,13 @@ func (b *Builder) Run(ctx context.Context, ui packersdk.Ui, hook packersdk.Hook)
100100
DiskTypeId: b.config.DiskTypeId,
101101
},
102102
&StepCloneVMX{
103-
Path: b.config.SourcePath,
104-
OutputDir: &b.config.OutputDir,
105-
VMName: b.config.VMName,
106-
Linked: b.config.Linked,
107-
Snapshot: b.config.AttachSnapshot,
103+
Path: b.config.SourcePath,
104+
OutputDir: &b.config.OutputDir,
105+
VMName: b.config.VMName,
106+
Linked: b.config.Linked,
107+
Snapshot: b.config.AttachSnapshot,
108+
Version: b.config.Version,
109+
GuestOSType: b.config.GuestOSType,
108110
},
109111
&vmwcommon.StepConfigureVMX{
110112
CustomData: b.config.VMXData,

builder/vmware/vmx/config.go

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ type Config struct {
6161
// virtual machine is started from its current state. Default to
6262
// `null/empty`.
6363
AttachSnapshot string `mapstructure:"attach_snapshot" required:"false"`
64-
// Path to the source `.vmx` file to clone.
64+
// Path to the source `.vmx`, '.ovf', or '.ova' file to clone.
6565
SourcePath string `mapstructure:"source_path" required:"true"`
6666
// This is the name of the `.vmx` file for the virtual machine, without
6767
// the file extension. By default, this is `packer-BUILDNAME`, where
@@ -70,6 +70,18 @@ type Config struct {
7070
// This is the name of the initial snapshot created after provisioning and
7171
// cleanup. If blank, no snapshot is created.
7272
SnapshotName string `mapstructure:"snapshot_name" required:"false"`
73+
// The guest operating system identifier for the virtual machine.
74+
//
75+
// ~> **Note:** This is required when cloning from an OVF/OVA file
76+
// and overrides the guest operating system identifier set by ovftool.
77+
GuestOSType string `mapstructure:"guest_os_type" required:"false"`
78+
// The virtual machine hardware version. Refer to [KB 315655](https://knowledge.broadcom.com/external/article?articleNumber=315655)
79+
// for more information on supported virtual hardware versions.
80+
// Default is 21. Minimum is 19.
81+
//
82+
// ~> **Note:** This is only used when cloning from an OVF/OVA file
83+
// and overrides the hardware version set by ovftool.
84+
Version int `mapstructure:"version" required:"false"`
7385

7486
ctx interpolate.Context
7587
}
@@ -117,7 +129,6 @@ func (c *Config) Prepare(raws ...interface{}) ([]string, error) {
117129
errs = packersdk.MultiErrorAppend(errs, c.FloppyConfig.Prepare(&c.ctx)...)
118130
errs = packersdk.MultiErrorAppend(errs, c.CDConfig.Prepare(&c.ctx)...)
119131
errs = packersdk.MultiErrorAppend(errs, c.VNCConfig.Prepare(&c.ctx)...)
120-
errs = packersdk.MultiErrorAppend(errs, c.VNCConfig.Prepare(&c.ctx)...)
121132
errs = packersdk.MultiErrorAppend(errs, c.ExportConfig.Prepare(&c.ctx)...)
122133
errs = packersdk.MultiErrorAppend(errs, c.DiskConfig.Prepare(&c.ctx)...)
123134

@@ -135,6 +146,19 @@ func (c *Config) Prepare(raws ...interface{}) ([]string, error) {
135146
errs = packersdk.MultiErrorAppend(errs,
136147
fmt.Errorf("source_path is invalid: %s", err))
137148
}
149+
150+
// Check if source is an OVF/OVA file and validate requirements.
151+
lowerPath := strings.ToLower(c.SourcePath)
152+
if strings.HasSuffix(lowerPath, ".ova") || strings.HasSuffix(lowerPath, ".ovf") {
153+
if vmwcommon.GetOvfTool() == "" {
154+
errs = packersdk.MultiErrorAppend(errs,
155+
errors.New("ovftool is required to clone from OVF/OVA files but was not found in PATH"))
156+
}
157+
if c.GuestOSType == "" {
158+
errs = packersdk.MultiErrorAppend(errs,
159+
errors.New("'guest_os_type' is required when cloning from OVF/OVA files"))
160+
}
161+
}
138162
}
139163

140164
if c.Headless && c.DisableVNC {
@@ -155,6 +179,13 @@ func (c *Config) Prepare(raws ...interface{}) ([]string, error) {
155179
c.SkipExport = true
156180
}
157181

182+
// Set a default hardware version for OVF/OVA sources, if not specified.
183+
if c.Version == 0 {
184+
c.Version = vmwcommon.DefaultHardwareVersion
185+
} else if c.Version < vmwcommon.MinimumHardwareVersion {
186+
errs = packersdk.MultiErrorAppend(errs, fmt.Errorf("invalid 'version' %d, minimum hardware version: %d", c.Version, vmwcommon.MinimumHardwareVersion))
187+
}
188+
158189
err = c.Validate(c.SkipExport)
159190
if err != nil {
160191
errs = packersdk.MultiErrorAppend(errs, err)

builder/vmware/vmx/config.hcl2spec.go

Lines changed: 4 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

builder/vmware/vmx/step_clone_vmx.go

Lines changed: 146 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -8,23 +8,26 @@ import (
88
"fmt"
99
"log"
1010
"os"
11+
"os/exec"
1112
"path/filepath"
1213
"regexp"
14+
"strings"
1315

1416
"github.com/hashicorp/packer-plugin-sdk/multistep"
1517
packersdk "github.com/hashicorp/packer-plugin-sdk/packer"
1618
vmwcommon "github.com/hashicorp/packer-plugin-vmware/builder/vmware/common"
1719
)
1820

19-
// StepCloneVMX takes a .vmx file and clones the virtual machine into the
20-
// output directory.
21+
// StepCloneVMX clones the source virtual machine a supplied path.
2122
type StepCloneVMX struct {
22-
OutputDir *string
23-
Path string
24-
VMName string
25-
Linked bool
26-
Snapshot string
27-
tempDir string
23+
OutputDir *string
24+
Path string
25+
VMName string
26+
Linked bool
27+
Snapshot string
28+
Version int
29+
GuestOSType string
30+
tempDir string
2831
}
2932

3033
// Run executes the VMX cloning step, creating a copy of the source virtual machine.
@@ -37,14 +40,139 @@ func (s *StepCloneVMX) Run(ctx context.Context, state multistep.StateBag) multis
3740
driver := state.Get("driver").(vmwcommon.Driver)
3841
ui := state.Get("ui").(packersdk.Ui)
3942

40-
// Set the path we want for the new .vmx file and clone.
41-
vmxPath := filepath.Join(*s.OutputDir, s.VMName+".vmx")
42-
ui.Say("Cloning source virtual machine...")
43-
log.Printf("[INFO] Cloning from: %s", s.Path)
44-
log.Printf("[INFO] Cloning to: %s", vmxPath)
43+
lowerSrc := strings.ToLower(s.Path)
44+
var vmxPath string
4545

46-
if err := driver.Clone(vmxPath, s.Path, s.Linked, s.Snapshot); err != nil {
47-
return halt(err)
46+
// If the source is a .ovf/.ova file, use ovftool.
47+
if strings.HasSuffix(lowerSrc, ".ovf") || strings.HasSuffix(lowerSrc, ".ova") {
48+
// Clone the source virtual machine from the .ovf/.ova file.
49+
ui.Sayf("Cloning from source .ovf/.ova...")
50+
log.Printf("[INFO] Cloning from: %s", s.Path)
51+
log.Printf("[INFO] Cloning to: %s", *s.OutputDir)
52+
53+
// ovftool always creates a subdirectory with the virtual machine name.
54+
// Pass the output directory to ovftool, then move the contents up one level.
55+
ovftoolTargetDir := *s.OutputDir
56+
57+
// Ensure that the output directory exists.
58+
if err := os.MkdirAll(ovftoolTargetDir, 0o755); err != nil {
59+
return halt(fmt.Errorf("failed to create output directory: %w", err))
60+
}
61+
62+
// Set up the ovftool command.
63+
ovftool := vmwcommon.GetOvfTool()
64+
65+
// Pass the virtual machine name, virtual hardware version, and output directory to ovftool.
66+
args := []string{
67+
"--lax",
68+
fmt.Sprintf("--maxVirtualHardwareVersion=%d", s.Version),
69+
fmt.Sprintf("--name=%s", s.VMName),
70+
s.Path,
71+
ovftoolTargetDir,
72+
}
73+
74+
cmd := exec.CommandContext(ctx, ovftool, args...)
75+
cmd.Stdout = os.Stdout
76+
cmd.Stderr = os.Stderr
77+
78+
if err := cmd.Run(); err != nil {
79+
return halt(fmt.Errorf("failed to clone from .ovf/.ova: %w", err))
80+
}
81+
82+
ui.Say("Successfully cloned from .ovf/.ova.")
83+
84+
// Determine where ovftool actually created the output within the target directory.
85+
// ovftool creates either <target>/<vmname> or <target>/<vmname>.vmwarevm depending on the platform.
86+
ovftoolCreatedPath := filepath.Join(ovftoolTargetDir, s.VMName)
87+
if _, err := os.Stat(ovftoolCreatedPath); os.IsNotExist(err) {
88+
// Check if ovftool created a .vmwarevm bundle instead (VMware Fusion on macOS).
89+
vmwarevmPath := ovftoolCreatedPath + ".vmwarevm"
90+
if _, err := os.Stat(vmwarevmPath); err == nil {
91+
ovftoolCreatedPath = vmwarevmPath
92+
} else {
93+
return halt(fmt.Errorf("ovftool output not found at %s or %s", ovftoolCreatedPath, vmwarevmPath))
94+
}
95+
}
96+
97+
// Move the ovftool output contents to the root of the output directory.
98+
// Use a temporary directory outside the output directory to avoid conflicts.
99+
log.Printf("[INFO] Moving output from %s to %s", ovftoolCreatedPath, *s.OutputDir)
100+
tempDir := strings.TrimRight(*s.OutputDir, string(filepath.Separator)) + ".tmp"
101+
s.tempDir = tempDir
102+
if err := os.Rename(ovftoolCreatedPath, tempDir); err != nil {
103+
return halt(fmt.Errorf("failed to rename ovftool output: %w", err))
104+
}
105+
106+
// Remove the output directory.
107+
if err := os.RemoveAll(*s.OutputDir); err != nil && !os.IsNotExist(err) {
108+
os.Rename(tempDir, ovftoolCreatedPath)
109+
return halt(fmt.Errorf("failed to remove output directory: %w", err))
110+
}
111+
112+
// Ensure parent directories exist before the final move.
113+
// Use the cleaned output directory path to get the correct parent.
114+
cleanedOutputDir := strings.TrimRight(*s.OutputDir, string(filepath.Separator))
115+
if err := os.MkdirAll(filepath.Dir(cleanedOutputDir), 0o755); err != nil {
116+
return halt(fmt.Errorf("failed to create parent directories: %w", err))
117+
}
118+
119+
// Move the temporary directory to the final output location.
120+
if err := os.Rename(tempDir, *s.OutputDir); err != nil {
121+
return halt(fmt.Errorf("failed to move ovftool results to output directory: %w", err))
122+
}
123+
s.tempDir = ""
124+
125+
// Find the .vmx file in the output directory.
126+
vmxPath = filepath.Join(*s.OutputDir, s.VMName+".vmx")
127+
if _, err := os.Stat(vmxPath); os.IsNotExist(err) {
128+
// VMware Fusion: Check for .vmwarevm bundle from ovftool.
129+
vmxPath = filepath.Join(*s.OutputDir, s.VMName+".vmwarevm", s.VMName+".vmx")
130+
if _, err := os.Stat(vmxPath); os.IsNotExist(err) {
131+
// Search for any .vmx file in the output directory.
132+
var found bool
133+
err := filepath.Walk(*s.OutputDir, func(path string, info os.FileInfo, err error) error {
134+
if err != nil {
135+
return err
136+
}
137+
if !info.IsDir() && strings.HasSuffix(strings.ToLower(path), ".vmx") {
138+
vmxPath = path
139+
found = true
140+
return filepath.SkipAll
141+
}
142+
return nil
143+
})
144+
if err != nil || !found {
145+
return halt(fmt.Errorf("unable to find .vmx file after ovftool conversion"))
146+
}
147+
}
148+
}
149+
150+
// Override guest operating system identifier, if specified.
151+
if s.GuestOSType != "" {
152+
log.Printf("[INFO] Overriding guest operating system identifier set by ovftool: %s", s.GuestOSType)
153+
vmxData, err := vmwcommon.ReadVMX(vmxPath)
154+
if err != nil {
155+
return halt(fmt.Errorf("failed to read vmx: %w", err))
156+
}
157+
158+
vmxData["guestos"] = s.GuestOSType
159+
160+
if err := vmwcommon.WriteVMX(vmxPath, vmxData); err != nil {
161+
return halt(fmt.Errorf("failed to write vmx: %w", err))
162+
}
163+
}
164+
} else {
165+
// Clone the source virtual machine from the .vmx configuration file.
166+
ui.Say("Cloning from source .vmx...")
167+
vmxPath = filepath.Join(*s.OutputDir, s.VMName+".vmx")
168+
log.Printf("[INFO] Cloning from: %s", s.Path)
169+
log.Printf("[INFO] Cloning to: %s", vmxPath)
170+
171+
if err := driver.Clone(vmxPath, s.Path, s.Linked, s.Snapshot); err != nil {
172+
return halt(fmt.Errorf("failed to clone from .vmx: %s", err))
173+
}
174+
175+
ui.Say("Successfully cloned from .vmx.")
48176
}
49177

50178
// Read in the virtual machine configuration from the cloned .vmx file.
@@ -54,20 +182,6 @@ func (s *StepCloneVMX) Run(ctx context.Context, state multistep.StateBag) multis
54182
}
55183

56184
var diskFilenames []string
57-
// The VMX file stores the path to a configured disk, and information
58-
// about that disks attachment to a virtual adapter/controller, as a
59-
// key/value pair.
60-
//
61-
// For a virtual disk attached to bus ID 3 of the virtual machines
62-
// first SCSI adapter the key/value pair would look something like:
63-
// scsi0:3.fileName = "relative/path/to/scsiDisk.vmdk"
64-
// The supported adapter types and configuration maximums for each type
65-
// vary according to the hypervisor and version, and the virtua
66-
// machine hardware version used.
67-
//
68-
// The following regexp is used to match all possible disk attachment
69-
// points that may be found in the VMX file across all VMware
70-
// platforms/versions and Virtual Machine Hardware versions
71185
diskPathKeyRe := regexp.MustCompile(`(?i)^(scsi|sata|ide|nvme)[[:digit:]]:[[:digit:]]{1,2}\.fileName`)
72186
for k, v := range vmxData {
73187
match := diskPathKeyRe.FindString(k)
@@ -76,18 +190,18 @@ func (s *StepCloneVMX) Run(ctx context.Context, state multistep.StateBag) multis
76190
}
77191
}
78192

79-
// Build the full path to each disk.
80193
var diskFullPaths []string
194+
vmxDir := filepath.Dir(vmxPath)
81195
for _, diskFilename := range diskFilenames {
82196
log.Printf("[INFO] Found attached disk with filename: %s", diskFilename)
83-
diskFullPaths = append(diskFullPaths, filepath.Join(*s.OutputDir, diskFilename))
197+
// Disk paths are relative to the .vmx file location, not OutputDir.
198+
diskFullPaths = append(diskFullPaths, filepath.Join(vmxDir, diskFilename))
84199
}
85200

86201
if len(diskFullPaths) == 0 {
87202
return halt(fmt.Errorf("unable to enumerate disk info from the vmx file"))
88203
}
89204

90-
// Determine the network type by reading out of the .vmx.
91205
var networkType string
92206
if _, ok := vmxData["ethernet0.connectiontype"]; ok {
93207
networkType = vmxData["ethernet0.connectiontype"]
@@ -98,7 +212,6 @@ func (s *StepCloneVMX) Run(ctx context.Context, state multistep.StateBag) multis
98212
log.Printf("[INFO] Defaulting to network type: %s", networkType)
99213
}
100214

101-
// Stash all required information in state.
102215
state.Put("vmx_path", vmxPath)
103216
state.Put("disk_full_paths", diskFullPaths)
104217
state.Put("vmnetwork", networkType)

docs-partials/builder/vmware/vmx/Config-not-required.mdx

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,4 +30,16 @@
3030
- `snapshot_name` (string) - This is the name of the initial snapshot created after provisioning and
3131
cleanup. If blank, no snapshot is created.
3232

33+
- `guest_os_type` (string) - The guest operating system identifier for the virtual machine.
34+
35+
~> **Note:** This is required when cloning from an OVF/OVA file
36+
and overrides the guest operating system identifier set by ovftool.
37+
38+
- `version` (int) - The virtual machine hardware version. Refer to [KB 315655](https://knowledge.broadcom.com/external/article?articleNumber=315655)
39+
for more information on supported virtual hardware versions.
40+
Default is 21. Minimum is 19.
41+
42+
~> **Note:** This is only used when cloning from an OVF/OVA file
43+
and overrides the hardware version set by ovftool.
44+
3345
<!-- End of code generated from the comments of the Config struct in builder/vmware/vmx/config.go; -->
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
<!-- Code generated from the comments of the Config struct in builder/vmware/vmx/config.go; DO NOT EDIT MANUALLY -->
22

3-
- `source_path` (string) - Path to the source `.vmx` file to clone.
3+
- `source_path` (string) - Path to the source `.vmx`, '.ovf', or '.ova' file to clone.
44

55
<!-- End of code generated from the comments of the Config struct in builder/vmware/vmx/config.go; -->

0 commit comments

Comments
 (0)