diff --git a/.web-docs/README.md b/.web-docs/README.md index 2057cd07..20127e6f 100644 --- a/.web-docs/README.md +++ b/.web-docs/README.md @@ -49,6 +49,11 @@ packer plugins install github.com/hashicorp/vsphere This builder deploys and publishes new virtual machine to a vSphere Supervisor cluster using VM Service. +#### Data Sources + +- [vsphere-virtualmachine](/packer/integrations/hashicorp/vsphere/latest/components/data-source/vsphere-virtualmachine) - + This data source returns the name of a virtual machine that matches all defined filters. + #### Post-Processors - [vsphere](/packer/integrations/hashicorp/vsphere/latest/components/post-processor/vsphere) - diff --git a/.web-docs/components/data-source/virtualmachine/README.md b/.web-docs/components/data-source/virtualmachine/README.md new file mode 100644 index 00000000..d877bd1d --- /dev/null +++ b/.web-docs/components/data-source/virtualmachine/README.md @@ -0,0 +1,153 @@ +Type: `vsphere-virtualmachine` +Artifact BuilderId: `vsphere.virtualmachine` + +This data source retrieves information about existing virtual machines from vSphere +and return name of one virtual machine that matches all specified filters. This virtual +machine can be used in the vSphere Clone builder to select a template. + +## Configuration Reference + +### Filters Configuration + +**Optional:** + + + +- `name` (string) - Basic filter with glob support (e.g. `ubuntu_basic*`). Defaults to `*`. + Using strict globs will not reduce execution time because vSphere API + returns the full inventory. But can be used for better readability over + regular expressions. + +- `name_regex` (string) - Extended name filter with regular expressions support + (e.g. `ubuntu[-_]basic[0-9]*`). Default is empty. The match of the + regular expression is checked by substring. Use `^` and `$` to define a + full string. For example, the `^[^_]+$` filter will search names + without any underscores. The expression must use + [Go Regex Syntax](https://pkg.go.dev/regexp/syntax). + +- `template` (bool) - Filter to return only objects that are virtual machine templates. + Defaults to `false` and returns all virtual machines. + +- `host` (string) - Filter to search virtual machines only on the specified ESX host. + +- `tag` ([]Tag) - Filter to return only that virtual machines that have attached all + specifies tags. Specify one or more `tag` blocks to define list of tags + for the filter. + + HCL Example: + + ```hcl + tag { + category = "team" + name = "operations" + } + tag { + category = "sla" + name = "gold" + } + ``` + +- `latest` (bool) - This filter determines how to handle multiple machines that were + matched with all previous filters. Machine creation time is being used + to find latest. By default, multiple matching machines results in an + error. + + + + +### Tags Filter Configuration + +**Required:** + + + +- `name` (string) - Name of the tag added to virtual machine which must pass the `tag` + filter. + +- `category` (string) - Name of the tag category that contains the tag. + + -> **Note:** Both `name` and `category` must be specified in the `tag` + filter. + + + + +### Connection Configuration + +**Optional:** + + + +- `vcenter_server` (string) - The fully qualified domain name or IP address of the vCenter Server + instance. + +- `username` (string) - The username to authenticate with the vCenter Server instance. + +- `password` (string) - The password to authenticate with the vCenter Server instance. + +- `insecure_connection` (bool) - Do not validate the certificate of the vCenter Server instance. + Defaults to `false`. + + -> **Note:** This option is beneficial in scenarios where the certificate + is self-signed or does not meet standard validation criteria. + +- `datacenter` (string) - The name of the datacenter object in the vSphere inventory. + + -> **Note:** Required if more than one datacenter object exists in the + vSphere inventory. + + + + +## Output + + + +- `vm_name` (string) - Name of the found virtual machine. + + + + +## Example Usage + +This example demonstrates how to connect to vSphere cluster and search for the latest virtual machine +that matches the filters. The name of the machine is then output to the console as an output variable. +```hcl +data "vsphere-virtualmachine" "default" { + vcenter_server = "vcenter.example.com" + insecure_connection = true + username = "administrator@vsphere.local" + password = "VMware1!" + datacenter = "dc-01" + latest = true + tags { + category = "team" + name = "operations" + } + tags { + category = "sla" + name = "gold" + } + +} + +locals { + vm_name = data.vsphere-virtualmachine.default.vm_name +} + +source "null" "example" { + communicator = "none" +} + +build { + sources = [ + "source.null.example" + ] + + provisioner "shell-local" { + inline = [ + "echo vm_name: ${local.vm_name}", + ] + } +} +``` diff --git a/.web-docs/metadata.hcl b/.web-docs/metadata.hcl index 017cc2ad..1a7e5589 100644 --- a/.web-docs/metadata.hcl +++ b/.web-docs/metadata.hcl @@ -33,4 +33,9 @@ integration { name = "vSphere Template" slug = "vsphere-template" } + component { + type = "data-source" + name = "vSphere Virtual Machine" + slug = "vsphere-virtualmachine" + } } diff --git a/builder/vsphere/driver/vm.go b/builder/vsphere/driver/vm.go index e3a6c413..20b69751 100644 --- a/builder/vsphere/driver/vm.go +++ b/builder/vsphere/driver/vm.go @@ -449,7 +449,6 @@ func (vm *VirtualMachineDriver) Clone(ctx context.Context, config *CloneConfig) Device: adapter.(types.BaseVirtualDevice), Operation: types.VirtualDeviceConfigSpecOperationEdit, } - configSpec.DeviceChange = append(configSpec.DeviceChange, config) } diff --git a/datasource/common/driver/driver.go b/datasource/common/driver/driver.go new file mode 100644 index 00000000..f4e342df --- /dev/null +++ b/datasource/common/driver/driver.go @@ -0,0 +1,60 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package driver + +import ( + "context" + "fmt" + "net/url" + + "github.com/hashicorp/packer-plugin-vsphere/builder/vsphere/common" + "github.com/vmware/govmomi" + "github.com/vmware/govmomi/find" + "github.com/vmware/govmomi/object" + "github.com/vmware/govmomi/vapi/rest" +) + +type VCenterDriver struct { + Ctx context.Context + Client *govmomi.Client + RestClient *rest.Client + Finder *find.Finder + Datacenter *object.Datacenter +} + +func NewDriver(config common.ConnectConfig) (*VCenterDriver, error) { + ctx := context.Background() + + vcenterUrl, err := url.Parse(fmt.Sprintf("https://%v/sdk", config.VCenterServer)) + if err != nil { + return nil, fmt.Errorf("failed to parse URL: %w", err) + } + vcenterUrl.User = url.UserPassword(config.Username, config.Password) + + client, err := govmomi.NewClient(ctx, vcenterUrl, true) + if err != nil { + return nil, fmt.Errorf("failed to create govmomi Client: %w", err) + } + + restClient := rest.NewClient(client.Client) + err = restClient.Login(ctx, vcenterUrl.User) + if err != nil { + return nil, fmt.Errorf("failed to login to REST API endpoint: %w", err) + } + + finder := find.NewFinder(client.Client, true) + datacenter, err := finder.DatacenterOrDefault(ctx, config.Datacenter) + if err != nil { + return nil, fmt.Errorf("failed to find datacenter: %w", err) + } + finder.SetDatacenter(datacenter) + + return &VCenterDriver{ + Ctx: ctx, + Client: client, + RestClient: restClient, + Finder: finder, + Datacenter: datacenter, + }, nil +} diff --git a/datasource/common/testing/call_restapi.go b/datasource/common/testing/call_restapi.go new file mode 100644 index 00000000..57d84011 --- /dev/null +++ b/datasource/common/testing/call_restapi.go @@ -0,0 +1,67 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package testing + +import ( + "context" + "fmt" + + "github.com/vmware/govmomi/object" + "github.com/vmware/govmomi/vapi/tags" +) + +// MarkSimulatedVmAsTemplate powers off the virtual machine before converting it to a template (because the simulator +// creates all virtual machines in an online state). +func MarkSimulatedVmAsTemplate(ctx context.Context, vm *object.VirtualMachine) error { + task, err := vm.PowerOff(ctx) + if err != nil { + return fmt.Errorf("failed to issue powering off command to the machine: %w", err) + } + err = task.Wait(ctx) + if err != nil { + return fmt.Errorf("failed to power off the machine: %w", err) + } + err = vm.MarkAsTemplate(ctx) + if err != nil { + return fmt.Errorf("failed to mark virtual machine as a template: %w", err) + } + return nil +} + +// FindOrCreateCategory tries to find category passed by name, creates category if not found and returns category ID. +// Category will be created with "MULTIPLE" constraint. +func FindOrCreateCategory(ctx context.Context, man *tags.Manager, catName string) (string, error) { + categoryList, err := man.GetCategories(ctx) + if err != nil { + return "", fmt.Errorf("cannot return categories from cluster: %w", err) + } + for _, category := range categoryList { + if category.Name == catName { + return category.ID, nil + } + } + newCategoryID, err := man.CreateCategory(ctx, &tags.Category{Name: catName, Cardinality: "MULTIPLE"}) + if err != nil { + return "", fmt.Errorf("cannot create category: %w", err) + } + return newCategoryID, nil +} + +// FindOrCreateTag tries to find the tagName in category with catID, creates if not found and returns tag ID. +func FindOrCreateTag(ctx context.Context, man *tags.Manager, catID string, tagName string) (string, error) { + tagsInCategory, err := man.GetTagsForCategory(ctx, catID) + if err != nil { + return "", fmt.Errorf("cannot return tags for category: %w", err) + } + for _, tag := range tagsInCategory { + if tag.Name == tagName { + return tag.ID, nil + } + } + newTagID, err := man.CreateTag(ctx, &tags.Tag{Name: tagName, CategoryID: catID}) + if err != nil { + return "", fmt.Errorf("cannot create tag: %w", err) + } + return newTagID, nil +} diff --git a/datasource/common/testing/simulator.go b/datasource/common/testing/simulator.go new file mode 100644 index 00000000..ba1b5a90 --- /dev/null +++ b/datasource/common/testing/simulator.go @@ -0,0 +1,164 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package testing + +import ( + "context" + "crypto/tls" + "fmt" + "net/url" + "time" + + "github.com/vmware/govmomi" + "github.com/vmware/govmomi/find" + "github.com/vmware/govmomi/object" + "github.com/vmware/govmomi/simulator" + "github.com/vmware/govmomi/vapi/rest" + _ "github.com/vmware/govmomi/vapi/simulator" + "github.com/vmware/govmomi/vapi/tags" + "github.com/vmware/govmomi/vim25/types" +) + +type Tag struct { + Category string + Name string +} + +type SimulatedVMConfig struct { + Name string + Tags []Tag + Template bool + CreationTime time.Time +} + +type VCenterSimulator struct { + Model *simulator.Model + Server *simulator.Server + Ctx context.Context + Client *govmomi.Client + RestClient *rest.Client + Finder *find.Finder + Datacenter *object.Datacenter +} + +// NewVCenterSimulator creates simulator object with model passed as argument. +func NewVCenterSimulator(model *simulator.Model) (*VCenterSimulator, error) { + ctx := context.Background() + if model == nil { + return nil, fmt.Errorf("model has not been initialized") + } + + err := model.Create() + if err != nil { + return nil, fmt.Errorf("failed to create simulator model: %w", err) + } + model.Service.RegisterEndpoints = true + model.Service.TLS = new(tls.Config) + + server := model.Service.NewServer() + + u, err := url.Parse(server.URL.String()) + if err != nil { + return nil, fmt.Errorf("failed to parse simulator URL: %w", err) + } + password, _ := simulator.DefaultLogin.Password() + u.User = url.UserPassword(simulator.DefaultLogin.Username(), password) + + client, err := govmomi.NewClient(ctx, u, true) + if err != nil { + return nil, fmt.Errorf("failed to connect to SOAP simulator: %w", err) + } + + restClient := rest.NewClient(client.Client) + err = restClient.Login(ctx, simulator.DefaultLogin) + if err != nil { + return nil, fmt.Errorf("failed to login to REST simulator: %w", err) + } + + finder := find.NewFinder(client.Client, false) + dcs, err := finder.DatacenterList(ctx, "*") + if err != nil { + return nil, fmt.Errorf("failed to list datacenters: %w", err) + } + if len(dcs) == 0 { + return nil, fmt.Errorf("datacenters were not found in the simulator: %w", err) + } + finder.SetDatacenter(dcs[0]) + + return &VCenterSimulator{ + Ctx: ctx, + Server: server, + Model: model, + Client: client, + Finder: finder, + RestClient: restClient, + Datacenter: dcs[0], + }, nil +} + +func (sim *VCenterSimulator) Stop() { + if sim.Model != nil { + sim.Model.Remove() + } + if sim.Server != nil { + sim.Server.Close() + } +} + +// CustomizeSimulator configures virtual machines in order that was retrieved from simulator according to +// list of machine configs in `vmsConfig`. Available options can be found in SimulatedVMConfig type. +func (sim *VCenterSimulator) CustomizeSimulator(vmsConfig []SimulatedVMConfig) error { + tagMan := tags.NewManager(sim.RestClient) + + vms, err := sim.Finder.VirtualMachineList(sim.Ctx, "*") + if err != nil { + return fmt.Errorf("failed to list virtual machines in cluster: %w", err) + } + + for i := 0; i < len(vmsConfig); i++ { + vmConfig := types.VirtualMachineConfigSpec{ + Name: vmsConfig[i].Name, + } + + if !vmsConfig[i].CreationTime.IsZero() { + vmConfig.CreateDate = &vmsConfig[i].CreationTime + } + + if vmsConfig[i].Name != "" { + task, err := vms[i].Reconfigure(sim.Ctx, vmConfig) + if err != nil { + return fmt.Errorf("failed to issue rename of virtual machine command: %w", err) + } + if err = task.Wait(sim.Ctx); err != nil { + return fmt.Errorf("failed to rename virtual machine: %w", err) + } + } + + if vmsConfig[i].Template { + err = MarkSimulatedVmAsTemplate(sim.Ctx, vms[i]) + if err != nil { + return fmt.Errorf("failed to convert to templates: %w", err) + } + } + + if vmsConfig[i].Tags != nil { + for _, tag := range vmsConfig[i].Tags { + catID, err := FindOrCreateCategory(sim.Ctx, tagMan, tag.Category) + if err != nil { + return fmt.Errorf("failed to find/create category: %w", err) + } + tagID, err := FindOrCreateTag(sim.Ctx, tagMan, catID, tag.Name) + if err != nil { + return fmt.Errorf("failed to find/create tag: %w", err) + } + err = tagMan.AttachTag(sim.Ctx, tagID, vms[i].Reference()) + if err != nil { + return fmt.Errorf("failed to attach tag to virtual machine: %w", err) + } + } + } + } + + return nil +} diff --git a/datasource/virtualmachine/data.go b/datasource/virtualmachine/data.go new file mode 100644 index 00000000..adb9839b --- /dev/null +++ b/datasource/virtualmachine/data.go @@ -0,0 +1,192 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +//go:generate packer-sdc struct-markdown +//go:generate packer-sdc mapstructure-to-hcl2 -type Config,Tag,DatasourceOutput +package virtualmachine + +import ( + "errors" + "fmt" + + "github.com/hashicorp/hcl/v2/hcldec" + "github.com/hashicorp/packer-plugin-sdk/common" + "github.com/hashicorp/packer-plugin-sdk/hcl2helper" + packersdk "github.com/hashicorp/packer-plugin-sdk/packer" + "github.com/hashicorp/packer-plugin-sdk/template/config" + vsphere "github.com/hashicorp/packer-plugin-vsphere/builder/vsphere/common" + "github.com/hashicorp/packer-plugin-vsphere/datasource/common/driver" + "github.com/zclconf/go-cty/cty" +) + +type Tag struct { + // Name of the tag added to virtual machine which must pass the `tag` + // filter. + Name string `mapstructure:"name" required:"true"` + // Name of the tag category that contains the tag. + // + // -> **Note:** Both `name` and `category` must be specified in the `tag` + // filter. + Category string `mapstructure:"category" required:"true"` +} + +type Config struct { + common.PackerConfig `mapstructure:",squash"` + vsphere.ConnectConfig `mapstructure:",squash"` + + // Basic filter with glob support (e.g. `ubuntu_basic*`). Defaults to `*`. + // Using strict globs will not reduce execution time because vSphere API + // returns the full inventory. But can be used for better readability over + // regular expressions. + Name string `mapstructure:"name"` + // Extended name filter with regular expressions support + // (e.g. `ubuntu[-_]basic[0-9]*`). Default is empty. The match of the + // regular expression is checked by substring. Use `^` and `$` to define a + // full string. For example, the `^[^_]+$` filter will search names + // without any underscores. The expression must use + // [Go Regex Syntax](https://pkg.go.dev/regexp/syntax). + NameRegex string `mapstructure:"name_regex"` + // Filter to return only objects that are virtual machine templates. + // Defaults to `false` and returns all virtual machines. + Template bool `mapstructure:"template"` + // Filter to search virtual machines only on the specified ESX host. + Host string `mapstructure:"host"` + // Filter to return only that virtual machines that have attached all + // specifies tags. Specify one or more `tag` blocks to define list of tags + // for the filter. + // + // HCL Example: + // + // ```hcl + // tag { + // category = "team" + // name = "operations" + // } + // tag { + // category = "sla" + // name = "gold" + // } + // ``` + Tags []Tag `mapstructure:"tag"` + // This filter determines how to handle multiple machines that were + // matched with all previous filters. Machine creation time is being used + // to find latest. By default, multiple matching machines results in an + // error. + Latest bool `mapstructure:"latest"` +} + +type Datasource struct { + config Config +} + +type DatasourceOutput struct { + // Name of the found virtual machine. + VmName string `mapstructure:"vm_name"` +} + +func (d *Datasource) ConfigSpec() hcldec.ObjectSpec { + return d.config.FlatMapstructure().HCL2Spec() +} + +func (d *Datasource) Configure(raws ...interface{}) error { + err := config.Decode(&d.config, nil, raws...) + if err != nil { + return err + } + + if d.config.Name == "" { + d.config.Name = "*" + } + + var errs *packersdk.MultiError + if d.config.VCenterServer == "" { + errs = packersdk.MultiErrorAppend(errs, errors.New("'vcenter_server' is required")) + } + if d.config.Username == "" { + errs = packersdk.MultiErrorAppend(errs, errors.New("'username' is required")) + } + if d.config.Password == "" { + errs = packersdk.MultiErrorAppend(errs, errors.New("'password' is required")) + } + if len(d.config.Tags) > 0 { + for _, tag := range d.config.Tags { + if tag.Name == "" || tag.Category == "" { + errs = packersdk.MultiErrorAppend(errs, errors.New("both name and category are required for tag")) + } + } + } + + if errs != nil && len(errs.Errors) > 0 { + return errs + } + + return nil +} + +func (d *Datasource) OutputSpec() hcldec.ObjectSpec { + return (&DatasourceOutput{}).FlatMapstructure().HCL2Spec() +} + +func (d *Datasource) Execute() (cty.Value, error) { + dr, err := driver.NewDriver(d.config.ConnectConfig) + if err != nil { + return cty.NullVal(cty.EmptyObject), fmt.Errorf("failed to initialize driver: %w", err) + } + + // This is the first level of filters + // (the finder with glob will return filtered list or drop an error if found nothing). + filteredVms, err := dr.Finder.VirtualMachineList(dr.Ctx, d.config.Name) + if err != nil { + return cty.NullVal(cty.EmptyObject), fmt.Errorf("failed to retrieve virtual machines list: %w", err) + } + + // Chain of other filters that will be executed only when defined + // and previous filter in chain left some virtual machines in the list. + if d.config.NameRegex != "" { + filteredVms = filterByNameRegex(filteredVms, d.config.NameRegex) + } + + if len(filteredVms) > 0 && d.config.Template { + filteredVms, err = filterByTemplate(dr, filteredVms) + if err != nil { + return cty.NullVal(cty.EmptyObject), fmt.Errorf("failed to filter by template attribute: %w", err) + } + } + + if len(filteredVms) > 0 && d.config.Host != "" { + filteredVms, err = filterByHost(dr, d.config, filteredVms) + if err != nil { + return cty.NullVal(cty.EmptyObject), fmt.Errorf("failed to filter by host attribute: %w", err) + } + } + + if len(filteredVms) > 0 && d.config.Tags != nil { + filteredVms, err = filterByTags(dr, d.config.Tags, filteredVms) + if err != nil { + return cty.NullVal(cty.EmptyObject), fmt.Errorf("failed to filter by tags: %w", err) + } + } + + // No VMs passed the filter chain. Nothing to return. + if len(filteredVms) == 0 { + return cty.NullVal(cty.EmptyObject), errors.New("no virtual machine matches the filters") + } + + if len(filteredVms) > 1 { + if d.config.Latest { + filteredVms, err = filterByLatest(dr, filteredVms) + if err != nil { + return cty.NullVal(cty.EmptyObject), fmt.Errorf("failed to find the latest virtual machine: %w", err) + } + } else { + // Too many machines passed the filter chain. Cannot decide which machine to return. + return cty.NullVal(cty.EmptyObject), errors.New("more than one virtual machine matched the filters") + } + } + + output := DatasourceOutput{ + VmName: filteredVms[0].Name(), + } + + return hcl2helper.HCL2ValueFromConfig(output, d.OutputSpec()), nil +} diff --git a/datasource/virtualmachine/data.hcl2spec.go b/datasource/virtualmachine/data.hcl2spec.go new file mode 100644 index 00000000..31aa2da3 --- /dev/null +++ b/datasource/virtualmachine/data.hcl2spec.go @@ -0,0 +1,115 @@ +// Code generated by "packer-sdc mapstructure-to-hcl2"; DO NOT EDIT. + +package virtualmachine + +import ( + "github.com/hashicorp/hcl/v2/hcldec" + "github.com/zclconf/go-cty/cty" +) + +// FlatConfig is an auto-generated flat version of Config. +// Where the contents of a field with a `mapstructure:,squash` tag are bubbled up. +type FlatConfig struct { + PackerBuildName *string `mapstructure:"packer_build_name" cty:"packer_build_name" hcl:"packer_build_name"` + PackerBuilderType *string `mapstructure:"packer_builder_type" cty:"packer_builder_type" hcl:"packer_builder_type"` + PackerCoreVersion *string `mapstructure:"packer_core_version" cty:"packer_core_version" hcl:"packer_core_version"` + PackerDebug *bool `mapstructure:"packer_debug" cty:"packer_debug" hcl:"packer_debug"` + PackerForce *bool `mapstructure:"packer_force" cty:"packer_force" hcl:"packer_force"` + PackerOnError *string `mapstructure:"packer_on_error" cty:"packer_on_error" hcl:"packer_on_error"` + PackerUserVars map[string]string `mapstructure:"packer_user_variables" cty:"packer_user_variables" hcl:"packer_user_variables"` + PackerSensitiveVars []string `mapstructure:"packer_sensitive_variables" cty:"packer_sensitive_variables" hcl:"packer_sensitive_variables"` + VCenterServer *string `mapstructure:"vcenter_server" cty:"vcenter_server" hcl:"vcenter_server"` + Username *string `mapstructure:"username" cty:"username" hcl:"username"` + Password *string `mapstructure:"password" cty:"password" hcl:"password"` + InsecureConnection *bool `mapstructure:"insecure_connection" cty:"insecure_connection" hcl:"insecure_connection"` + Datacenter *string `mapstructure:"datacenter" cty:"datacenter" hcl:"datacenter"` + Name *string `mapstructure:"name" cty:"name" hcl:"name"` + NameRegex *string `mapstructure:"name_regex" cty:"name_regex" hcl:"name_regex"` + Template *bool `mapstructure:"template" cty:"template" hcl:"template"` + Host *string `mapstructure:"host" cty:"host" hcl:"host"` + Tags []FlatTag `mapstructure:"tag" cty:"tag" hcl:"tag"` + Latest *bool `mapstructure:"latest" cty:"latest" hcl:"latest"` +} + +// FlatMapstructure returns a new FlatConfig. +// FlatConfig is an auto-generated flat version of Config. +// Where the contents a fields with a `mapstructure:,squash` tag are bubbled up. +func (*Config) FlatMapstructure() interface{ HCL2Spec() map[string]hcldec.Spec } { + return new(FlatConfig) +} + +// HCL2Spec returns the hcl spec of a Config. +// This spec is used by HCL to read the fields of Config. +// The decoded values from this spec will then be applied to a FlatConfig. +func (*FlatConfig) HCL2Spec() map[string]hcldec.Spec { + s := map[string]hcldec.Spec{ + "packer_build_name": &hcldec.AttrSpec{Name: "packer_build_name", Type: cty.String, Required: false}, + "packer_builder_type": &hcldec.AttrSpec{Name: "packer_builder_type", Type: cty.String, Required: false}, + "packer_core_version": &hcldec.AttrSpec{Name: "packer_core_version", Type: cty.String, Required: false}, + "packer_debug": &hcldec.AttrSpec{Name: "packer_debug", Type: cty.Bool, Required: false}, + "packer_force": &hcldec.AttrSpec{Name: "packer_force", Type: cty.Bool, Required: false}, + "packer_on_error": &hcldec.AttrSpec{Name: "packer_on_error", Type: cty.String, Required: false}, + "packer_user_variables": &hcldec.AttrSpec{Name: "packer_user_variables", Type: cty.Map(cty.String), Required: false}, + "packer_sensitive_variables": &hcldec.AttrSpec{Name: "packer_sensitive_variables", Type: cty.List(cty.String), Required: false}, + "vcenter_server": &hcldec.AttrSpec{Name: "vcenter_server", Type: cty.String, Required: false}, + "username": &hcldec.AttrSpec{Name: "username", Type: cty.String, Required: false}, + "password": &hcldec.AttrSpec{Name: "password", Type: cty.String, Required: false}, + "insecure_connection": &hcldec.AttrSpec{Name: "insecure_connection", Type: cty.Bool, Required: false}, + "datacenter": &hcldec.AttrSpec{Name: "datacenter", Type: cty.String, Required: false}, + "name": &hcldec.AttrSpec{Name: "name", Type: cty.String, Required: false}, + "name_regex": &hcldec.AttrSpec{Name: "name_regex", Type: cty.String, Required: false}, + "template": &hcldec.AttrSpec{Name: "template", Type: cty.Bool, Required: false}, + "host": &hcldec.AttrSpec{Name: "host", Type: cty.String, Required: false}, + "tag": &hcldec.BlockListSpec{TypeName: "tag", Nested: hcldec.ObjectSpec((*FlatTag)(nil).HCL2Spec())}, + "latest": &hcldec.AttrSpec{Name: "latest", Type: cty.Bool, Required: false}, + } + return s +} + +// FlatDatasourceOutput is an auto-generated flat version of DatasourceOutput. +// Where the contents of a field with a `mapstructure:,squash` tag are bubbled up. +type FlatDatasourceOutput struct { + VmName *string `mapstructure:"vm_name" cty:"vm_name" hcl:"vm_name"` +} + +// FlatMapstructure returns a new FlatDatasourceOutput. +// FlatDatasourceOutput is an auto-generated flat version of DatasourceOutput. +// Where the contents a fields with a `mapstructure:,squash` tag are bubbled up. +func (*DatasourceOutput) FlatMapstructure() interface{ HCL2Spec() map[string]hcldec.Spec } { + return new(FlatDatasourceOutput) +} + +// HCL2Spec returns the hcl spec of a DatasourceOutput. +// This spec is used by HCL to read the fields of DatasourceOutput. +// The decoded values from this spec will then be applied to a FlatDatasourceOutput. +func (*FlatDatasourceOutput) HCL2Spec() map[string]hcldec.Spec { + s := map[string]hcldec.Spec{ + "vm_name": &hcldec.AttrSpec{Name: "vm_name", Type: cty.String, Required: false}, + } + return s +} + +// FlatTag is an auto-generated flat version of Tag. +// Where the contents of a field with a `mapstructure:,squash` tag are bubbled up. +type FlatTag struct { + Name *string `mapstructure:"name" required:"true" cty:"name" hcl:"name"` + Category *string `mapstructure:"category" required:"true" cty:"category" hcl:"category"` +} + +// FlatMapstructure returns a new FlatTag. +// FlatTag is an auto-generated flat version of Tag. +// Where the contents a fields with a `mapstructure:,squash` tag are bubbled up. +func (*Tag) FlatMapstructure() interface{ HCL2Spec() map[string]hcldec.Spec } { + return new(FlatTag) +} + +// HCL2Spec returns the hcl spec of a Tag. +// This spec is used by HCL to read the fields of Tag. +// The decoded values from this spec will then be applied to a FlatTag. +func (*FlatTag) HCL2Spec() map[string]hcldec.Spec { + s := map[string]hcldec.Spec{ + "name": &hcldec.AttrSpec{Name: "name", Type: cty.String, Required: false}, + "category": &hcldec.AttrSpec{Name: "category", Type: cty.String, Required: false}, + } + return s +} diff --git a/datasource/virtualmachine/data_test.go b/datasource/virtualmachine/data_test.go new file mode 100644 index 00000000..8a5ccad7 --- /dev/null +++ b/datasource/virtualmachine/data_test.go @@ -0,0 +1,213 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package virtualmachine + +import ( + "testing" + "time" + + "github.com/hashicorp/packer-plugin-vsphere/builder/vsphere/common" + "github.com/vmware/govmomi/simulator" + + commonT "github.com/hashicorp/packer-plugin-vsphere/datasource/common/testing" +) + +func TestExecute(t *testing.T) { + machinesToPrepare := []commonT.SimulatedVMConfig{ + { + Name: "first-vm", + Tags: []commonT.Tag{ + { + Category: "operating-system-class", + Name: "Linux", + }, + }, + }, { + Name: "second-vm", + Tags: []commonT.Tag{ + { + Category: "operating-system-class", + Name: "Linux", + }, + { + Category: "security-team", + Name: "red", + }, + { + Category: "security-team", + Name: "blue", + }, + }, + Template: true, + }, { + Name: "machine-three", + Tags: []commonT.Tag{ + { + Category: "operating-system-class", + Name: "Linux", + }, + { + Category: "security-team", + Name: "blue", + }, + }, + CreationTime: time.Now().AddDate(0, 0, 1), + }, + } + + model := simulator.VPX() + model.Datacenter = 2 + model.Machine = 8 + + vcSim, err := commonT.NewVCenterSimulator(model) + if err != nil { + t.Fatalf("error creating vCenter simulator: %s", err) + } + defer vcSim.Stop() + + err = vcSim.CustomizeSimulator(machinesToPrepare) + if err != nil { + t.Fatalf("error customizing simulator: %s", err) + } + + simulatorPassword, _ := vcSim.Server.URL.User.Password() + connectConfig := common.ConnectConfig{ + VCenterServer: vcSim.Server.URL.Host, + Username: vcSim.Server.URL.User.Username(), + Password: simulatorPassword, + InsecureConnection: true, + Datacenter: vcSim.Datacenter.Name(), + } + + dsTestConfigs := []struct { + name string + expectFailure bool + expectVmName string + config Config + }{ + { + name: "first-vm was found by name, no error", + expectFailure: false, + expectVmName: "first-vm", + config: Config{ + Name: "first-vm", + }, + }, + { + name: "no machines match the filter, error", + expectFailure: true, + expectVmName: "", + config: Config{ + Name: "firstest-vm", + }, + }, + { + name: "second-vm was found by the regex, no error", + expectFailure: false, + expectVmName: "second-vm", + config: Config{ + NameRegex: "^seco.*m$", + }, + }, + { + name: "multiple machines match the regex, but latest not used, error", + expectFailure: true, + expectVmName: "", + config: Config{ + NameRegex: ".*-vm", + }, + }, + { + name: "multiple guests match the regex and latest used, no error", + expectFailure: false, + expectVmName: "machine-three", + config: Config{ + NameRegex: "^[^_]+$", + Latest: true, + }, + }, + { + name: "found machine that is a template, no error", + expectFailure: false, + expectVmName: "second-vm", + config: Config{ + Template: true, + }, + }, + { + name: "found multiple machines at the host, error", + expectFailure: true, + expectVmName: "", + config: Config{ + Host: "DC0_H0", + }, + }, + { + name: "cluster host not found, error", + expectFailure: true, + expectVmName: "", + config: Config{ + Host: "unexpected_host", + }, + }, + { + name: "found machine with defined set of tags, no error", + expectFailure: false, + expectVmName: "second-vm", + config: Config{ + Tags: []Tag{ + { + Category: "security-team", + Name: "blue", + }, + { + Category: "security-team", + Name: "red", + }, + }, + }, + }, + { + name: "found multiple machines with defined set of tags, error", + expectFailure: true, + expectVmName: "", + config: Config{ + Tags: []Tag{ + { + Category: "operating-system-class", + Name: "Linux", + }, + }, + }, + }, + } + + for _, testConfig := range dsTestConfigs { + t.Run(testConfig.name, func(t *testing.T) { + testConfig.config.ConnectConfig = connectConfig + + ds := Datasource{ + config: testConfig.config, + } + err := ds.Configure() + if err != nil { + t.Fatalf("Failed to configure datasource: %s", err) + } + + result, err := ds.Execute() + if err != nil && !testConfig.expectFailure { + t.Fatalf("unexpected failure: %s", err) + } + if err == nil && testConfig.expectFailure { + t.Errorf("expected failure, but execution succeeded") + } + if err == nil { + vmName := result.GetAttr("vm_name").AsString() + if vmName != testConfig.expectVmName { + t.Errorf("expected vm name `%s`, but got `%s`", testConfig.expectVmName, vmName) + } + } + }) + } +} diff --git a/datasource/virtualmachine/filters.go b/datasource/virtualmachine/filters.go new file mode 100644 index 00000000..ec9c1c65 --- /dev/null +++ b/datasource/virtualmachine/filters.go @@ -0,0 +1,137 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package virtualmachine + +import ( + "fmt" + "regexp" + "time" + + "github.com/hashicorp/packer-plugin-vsphere/datasource/common/driver" + "github.com/vmware/govmomi/object" + "github.com/vmware/govmomi/property" + "github.com/vmware/govmomi/vapi/tags" + "github.com/vmware/govmomi/vim25/mo" +) + +// filterByNameRegex filters machines by matching their names against defined regular expression. +func filterByNameRegex(vmList []*object.VirtualMachine, nameRegex string) []*object.VirtualMachine { + re, _ := regexp.Compile(nameRegex) + result := make([]*object.VirtualMachine, 0) + for _, i := range vmList { + if re.MatchString(i.Name()) { + result = append(result, i) + } + } + return result +} + +// filterByTemplate filters machines by template attribute. Only templates will pass the filter. +func filterByTemplate(driver *driver.VCenterDriver, vmList []*object.VirtualMachine) ([]*object.VirtualMachine, error) { + result := make([]*object.VirtualMachine, 0) + for _, i := range vmList { + isTemplate, err := i.IsTemplate(driver.Ctx) + if err != nil { + return nil, fmt.Errorf("error checking if virtual machine is a template: %w", err) + } + + if isTemplate { + result = append(result, i) + } + } + return result, nil +} + +// filterByHost filters machines by ESX host placement. +// Only machines that are stored on the defined host will pass the filter. +func filterByHost(driver *driver.VCenterDriver, config Config, vmList []*object.VirtualMachine) ([]*object.VirtualMachine, error) { + pc := property.DefaultCollector(driver.Client.Client) + obj, err := driver.Finder.HostSystem(driver.Ctx, config.Host) + if err != nil { + return nil, fmt.Errorf("error finding defined host system: %w", err) + } + + var host mo.HostSystem + err = pc.RetrieveOne(driver.Ctx, obj.Reference(), []string{"vm"}, &host) + if err != nil { + return nil, fmt.Errorf("error retrieving properties of host system: %w", err) + } + + var hostVms []mo.VirtualMachine + err = pc.Retrieve(driver.Ctx, host.Vm, []string{"name"}, &hostVms) + if err != nil { + return nil, fmt.Errorf("failed to get properties for the virtual machine: %w", err) + } + + result := make([]*object.VirtualMachine, 0) + for _, filteredVm := range vmList { + vmName := filteredVm.Name() + for _, hostVm := range hostVms { + if vmName == hostVm.Name { + result = append(result, filteredVm) + } + } + } + + return result, nil +} + +// filterByTags filters machines by tags. Only machines that has all the tags from list will pass the filter. +func filterByTags(driver *driver.VCenterDriver, vmTags []Tag, vmList []*object.VirtualMachine) ([]*object.VirtualMachine, error) { + result := make([]*object.VirtualMachine, 0) + tagMan := tags.NewManager(driver.RestClient) + for _, filteredVm := range vmList { + realTagsList, err := tagMan.GetAttachedTags(driver.Ctx, filteredVm.Reference()) + if err != nil { + return nil, fmt.Errorf("failed return tags for the virtual machine: %w", err) + } + matchedTagsCount := 0 + for _, configTag := range vmTags { + configTagMatched := false + for _, realTag := range realTagsList { + if configTag.Name == realTag.Name { + category, err := tagMan.GetCategory(driver.Ctx, realTag.CategoryID) + if err != nil { + return nil, fmt.Errorf("failed to return tag category for tag: %w", err) + } + if configTag.Category == category.Name { + configTagMatched = true + break + } + } + } + if configTagMatched { + matchedTagsCount++ + } else { + // If a single requested tag from config not matched then no need to proceed. + // Fail early. + break + } + } + if matchedTagsCount == len(vmTags) { + result = append(result, filteredVm) + } + } + + return result, nil +} + +// filterByLatest filters machines by creation date. This filter returns list with one element. +func filterByLatest(driver *driver.VCenterDriver, vmList []*object.VirtualMachine) ([]*object.VirtualMachine, error) { + var latestVM *object.VirtualMachine + var latestTimestamp time.Time + for _, elementVM := range vmList { + var vmConfig mo.VirtualMachine + err := elementVM.Properties(driver.Ctx, elementVM.Reference(), []string{"config"}, &vmConfig) + if err != nil { + return nil, fmt.Errorf("error retrieving config properties for the virtual machine: %w", err) + } + if vmConfig.Config.CreateDate.After(latestTimestamp) { + latestVM = elementVM + latestTimestamp = *vmConfig.Config.CreateDate + } + } + result := []*object.VirtualMachine{latestVM} + return result, nil +} diff --git a/docs-partials/datasource/virtualmachine/Config-not-required.mdx b/docs-partials/datasource/virtualmachine/Config-not-required.mdx new file mode 100644 index 00000000..03779212 --- /dev/null +++ b/docs-partials/datasource/virtualmachine/Config-not-required.mdx @@ -0,0 +1,42 @@ + + +- `name` (string) - Basic filter with glob support (e.g. `ubuntu_basic*`). Defaults to `*`. + Using strict globs will not reduce execution time because vSphere API + returns the full inventory. But can be used for better readability over + regular expressions. + +- `name_regex` (string) - Extended name filter with regular expressions support + (e.g. `ubuntu[-_]basic[0-9]*`). Default is empty. The match of the + regular expression is checked by substring. Use `^` and `$` to define a + full string. For example, the `^[^_]+$` filter will search names + without any underscores. The expression must use + [Go Regex Syntax](https://pkg.go.dev/regexp/syntax). + +- `template` (bool) - Filter to return only objects that are virtual machine templates. + Defaults to `false` and returns all virtual machines. + +- `host` (string) - Filter to search virtual machines only on the specified ESX host. + +- `tag` ([]Tag) - Filter to return only that virtual machines that have attached all + specifies tags. Specify one or more `tag` blocks to define list of tags + for the filter. + + HCL Example: + + ```hcl + tag { + category = "team" + name = "operations" + } + tag { + category = "sla" + name = "gold" + } + ``` + +- `latest` (bool) - This filter determines how to handle multiple machines that were + matched with all previous filters. Machine creation time is being used + to find latest. By default, multiple matching machines results in an + error. + + diff --git a/docs-partials/datasource/virtualmachine/DatasourceOutput.mdx b/docs-partials/datasource/virtualmachine/DatasourceOutput.mdx new file mode 100644 index 00000000..ff300244 --- /dev/null +++ b/docs-partials/datasource/virtualmachine/DatasourceOutput.mdx @@ -0,0 +1,5 @@ + + +- `vm_name` (string) - Name of the found virtual machine. + + diff --git a/docs-partials/datasource/virtualmachine/Tag-required.mdx b/docs-partials/datasource/virtualmachine/Tag-required.mdx new file mode 100644 index 00000000..0b9d544d --- /dev/null +++ b/docs-partials/datasource/virtualmachine/Tag-required.mdx @@ -0,0 +1,11 @@ + + +- `name` (string) - Name of the tag added to virtual machine which must pass the `tag` + filter. + +- `category` (string) - Name of the tag category that contains the tag. + + -> **Note:** Both `name` and `category` must be specified in the `tag` + filter. + + diff --git a/docs/README.md b/docs/README.md index 2057cd07..20127e6f 100644 --- a/docs/README.md +++ b/docs/README.md @@ -49,6 +49,11 @@ packer plugins install github.com/hashicorp/vsphere This builder deploys and publishes new virtual machine to a vSphere Supervisor cluster using VM Service. +#### Data Sources + +- [vsphere-virtualmachine](/packer/integrations/hashicorp/vsphere/latest/components/data-source/vsphere-virtualmachine) - + This data source returns the name of a virtual machine that matches all defined filters. + #### Post-Processors - [vsphere](/packer/integrations/hashicorp/vsphere/latest/components/post-processor/vsphere) - diff --git a/docs/datasources/virtualmachine.mdx b/docs/datasources/virtualmachine.mdx new file mode 100644 index 00000000..22e9ff4e --- /dev/null +++ b/docs/datasources/virtualmachine.mdx @@ -0,0 +1,87 @@ +--- +modeline: | + vim: set ft=pandoc: +description: | + This data source retrieves information about existing virtual machines from vSphere + and return name of one virtual machine that matches all specified filters. This virtual + machine can be used in the vSphere Clone builder to select a template. +page_title: vSphere Virtual Machine - Data Source +sidebar_title: vSphere Virtual Machine +--- + +# Virtual Machine Data Source + +Type: `vsphere-virtualmachine` +Artifact BuilderId: `vsphere.virtualmachine` + +This data source retrieves information about existing virtual machines from vSphere +and return name of one virtual machine that matches all specified filters. This virtual +machine can be used in the vSphere Clone builder to select a template. + +## Configuration Reference + +### Filters Configuration + +**Optional:** + +@include 'datasource/virtualmachine/Config-not-required.mdx' + +### Tags Filter Configuration + +**Required:** + +@include 'datasource/virtualmachine/Tag-required.mdx' + +### Connection Configuration + +**Optional:** + +@include 'builder/vsphere/common/ConnectConfig-not-required.mdx' + +## Output + +@include 'datasource/virtualmachine/DatasourceOutput.mdx' + +## Example Usage + +This example demonstrates how to connect to vSphere cluster and search for the latest virtual machine +that matches the filters. The name of the machine is then output to the console as an output variable. +```hcl +data "vsphere-virtualmachine" "default" { + vcenter_server = "vcenter.example.com" + insecure_connection = true + username = "administrator@vsphere.local" + password = "VMware1!" + datacenter = "dc-01" + latest = true + tags { + category = "team" + name = "operations" + } + tags { + category = "sla" + name = "gold" + } + +} + +locals { + vm_name = data.vsphere-virtualmachine.default.vm_name +} + +source "null" "example" { + communicator = "none" +} + +build { + sources = [ + "source.null.example" + ] + + provisioner "shell-local" { + inline = [ + "echo vm_name: ${local.vm_name}", + ] + } +} +``` diff --git a/main.go b/main.go index 790b807b..ddfbe2d6 100644 --- a/main.go +++ b/main.go @@ -12,6 +12,7 @@ import ( "github.com/hashicorp/packer-plugin-vsphere/builder/vsphere/clone" "github.com/hashicorp/packer-plugin-vsphere/builder/vsphere/iso" "github.com/hashicorp/packer-plugin-vsphere/builder/vsphere/supervisor" + "github.com/hashicorp/packer-plugin-vsphere/datasource/virtualmachine" "github.com/hashicorp/packer-plugin-vsphere/post-processor/vsphere" vsphereTemplate "github.com/hashicorp/packer-plugin-vsphere/post-processor/vsphere-template" "github.com/hashicorp/packer-plugin-vsphere/version" @@ -22,6 +23,7 @@ func main() { pps.RegisterBuilder("iso", new(iso.Builder)) pps.RegisterBuilder("clone", new(clone.Builder)) pps.RegisterBuilder("supervisor", new(supervisor.Builder)) + pps.RegisterDatasource("virtualmachine", new(virtualmachine.Datasource)) pps.RegisterPostProcessor(plugin.DEFAULT_NAME, new(vsphere.PostProcessor)) pps.RegisterPostProcessor("template", new(vsphereTemplate.PostProcessor)) pps.SetVersion(version.PluginVersion)