diff --git a/builder/vsphere/examples/supervisor/general-template.pkr.hcl b/builder/vsphere/examples/supervisor/general-template.pkr.hcl new file mode 100644 index 00000000..83083423 --- /dev/null +++ b/builder/vsphere/examples/supervisor/general-template.pkr.hcl @@ -0,0 +1,119 @@ +# A Packer template to deploy and publish a VM-Service VM using the vsphere-supervisor builder. + +# VM-Service source VM configs. +variable "image_name" { + type = string +} +variable "class_name" { + type = string +} +variable "storage_class" { + type = string +} +variable "source_name" { + type = string + default = null +} +variable "bootstrap_provider" { + type = string + default = "CloudInit" +} +variable "bootstrap_data_file" { + type = string + default = null +} + +# Supervisor cluster configs. +variable "kubeconfig_path" { + type = string + default = null +} +variable "supervisor_namespace" { + type = string + default = null +} + +# SSH connection configs. +variable "communicator" { + type = string + default = "ssh" +} +variable "ssh_username" { + type = string + default = "packer" +} +variable "ssh_password" { + type = string + default = "packer" + sensitive = true +} +variable "ssh_bastion_host" { + type = string + default = null +} +variable "ssh_bastion_username" { + type = string + default = null +} +variable "ssh_bastion_password" { + type = string + default = null + sensitive = true +} + +# Whether to keep the created source VM after the build. +variable "keep_input_artifact" { + type = bool + default = false +} + +# VM publishing configs. +variable "publish_location_name" { + type = string + default = null +} +variable "publish_image_name" { + type = string + default = null +} + +# Watch timeout related configs. +variable "watch_source_timeout_sec" { + type = number + default = 1800 +} +variable "watch_publish_timeout_sec" { + type = number + default = 600 +} + +source "vsphere-supervisor" "vm" { + kubeconfig_path = "${var.kubeconfig_path}" + supervisor_namespace = "${var.supervisor_namespace}" + class_name = "${var.class_name}" + image_name = "${var.image_name}" + source_name = "${var.source_name}" + storage_class = "${var.storage_class}" + bootstrap_provider = "${var.bootstrap_provider}" + bootstrap_data_file = "${var.bootstrap_data_file}" + communicator = "${var.communicator}" + ssh_username = "${var.ssh_username}" + ssh_password = "${var.ssh_password}" + ssh_bastion_host = "${var.ssh_bastion_host}" + ssh_bastion_username = "${var.ssh_bastion_username}" + ssh_bastion_password = "${var.ssh_bastion_password}" + keep_input_artifact = "${var.keep_input_artifact}" + publish_location_name = "${var.publish_location_name}" + publish_image_name = "${var.publish_image_name}" + watch_source_timeout_sec = "${var.watch_source_timeout_sec}" + watch_publish_timeout_sec = "${var.watch_publish_timeout_sec}" +} + +build { + sources = ["source.vsphere-supervisor.vm"] + provisioner "shell" { + inline = [ + "echo 'Hello from Packer!' > ./hello-packer.txt", + ] + } +} diff --git a/builder/vsphere/examples/supervisor/jenkins-template.pkr.hcl b/builder/vsphere/examples/supervisor/jenkins-template.pkr.hcl new file mode 100644 index 00000000..c29217ed --- /dev/null +++ b/builder/vsphere/examples/supervisor/jenkins-template.pkr.hcl @@ -0,0 +1,143 @@ +# A Packer template to deploy a VM-Service VM using the vsphere-supervisor builder. +# It installs Jenkins and runs a sample hello-world job in the deployed VM. + +# VM-Service source VM configs. +variable "image_name" { + type = string +} +variable "class_name" { + type = string +} +variable "storage_class" { + type = string +} +variable "source_name" { + type = string + default = null +} +variable "bootstrap_provider" { + type = string + default = "CloudInit" +} +variable "bootstrap_data_file" { + type = string + default = null +} + +# Supervisor cluster configs. +variable "kubeconfig_path" { + type = string + default = null +} +variable "supervisor_namespace" { + type = string + default = null +} + +# SSH connection configs. +variable "communicator" { + type = string + default = "ssh" +} +variable "ssh_username" { + type = string + default = "packer" +} +variable "ssh_password" { + type = string + default = "packer" + sensitive = true +} +variable "ssh_bastion_host" { + type = string + default = null +} +variable "ssh_bastion_username" { + type = string + default = null +} +variable "ssh_bastion_password" { + type = string + default = null + sensitive = true +} + +# Whether to keep the created source VM after the build. +variable "keep_input_artifact" { + type = bool + default = false +} + +# VM publishing configs. +variable "publish_location_name" { + type = string + default = null +} +variable "publish_image_name" { + type = string + default = null +} + +source "vsphere-supervisor" "vm" { + kubeconfig_path = "${var.kubeconfig_path}" + supervisor_namespace = "${var.supervisor_namespace}" + class_name = "${var.class_name}" + image_name = "${var.image_name}" + source_name = "${var.source_name}" + storage_class = "${var.storage_class}" + bootstrap_provider = "${var.bootstrap_provider}" + bootstrap_data_file = "${var.bootstrap_data_file}" + communicator = "${var.communicator}" + ssh_username = "${var.ssh_username}" + ssh_password = "${var.ssh_password}" + ssh_bastion_host = "${var.ssh_bastion_host}" + ssh_bastion_username = "${var.ssh_bastion_username}" + ssh_bastion_password = "${var.ssh_bastion_password}" + keep_input_artifact = "${var.keep_input_artifact}" + publish_location_name = "${var.publish_location_name}" + publish_image_name = "${var.publish_image_name}" +} + +build { + sources = ["source.vsphere-supervisor.vm"] + + # Jenkins job configuration file. + provisioner "file" { + destination = "/tmp/sample-job.xml" + content = < + + A sample job + + + echo "Hello VM-Service from Jenkins" + + + +EOF + } + + provisioner "shell" { + inline = [ + # Install Jenkins and its dependencies. + "curl -fsSL https://pkg.jenkins.io/debian/jenkins.io-2023.key | sudo tee /usr/share/keyrings/jenkins-keyring.asc > /dev/null", + "echo deb [signed-by=/usr/share/keyrings/jenkins-keyring.asc] https://pkg.jenkins.io/debian binary/ | sudo tee /etc/apt/sources.list.d/jenkins.list > /dev/null", + # Sometimes apt-get uses IPv6 and causes failure, force to use IPv4 address. + "sudo apt-get -qq -o Acquire::ForceIPv4=true update", + "sudo apt-get -qq -o Acquire::ForceIPv4=true install -f -y ca-certificates openjdk-11-jre-headless", + "sudo apt-get -qq -o Acquire::ForceIPv4=true install -f -y jenkins", + # Restart Jenkins service, in case it didn't initialize successfully. + "sudo systemctl restart jenkins", + + "export JENKINS_URL=http://localhost:8080/", + "export USER=admin", + "export PASSWORD=$(sudo cat /var/lib/jenkins/secrets/initialAdminPassword)", + # Download Jenkins CLI to create and check job status. + "wget -q -O /tmp/jenkins-cli.jar $JENKINS_URL/jnlpJars/jenkins-cli.jar", + # Create a new job from the above sample-job.xml file. + "java -jar /tmp/jenkins-cli.jar -s $JENKINS_URL -auth $USER:$PASSWORD create-job sample-job < /tmp/sample-job.xml", + # Build and wait for a successful completion of the job. + "java -jar /tmp/jenkins-cli.jar -s $JENKINS_URL -auth $USER:$PASSWORD build sample-job -s -v", + ] + } +} diff --git a/builder/vsphere/examples/supervisor/nginx-template.pkr.hcl b/builder/vsphere/examples/supervisor/nginx-template.pkr.hcl index a392716e..66171a20 100644 --- a/builder/vsphere/examples/supervisor/nginx-template.pkr.hcl +++ b/builder/vsphere/examples/supervisor/nginx-template.pkr.hcl @@ -1,36 +1,115 @@ # Copyright (c) HashiCorp, Inc. # SPDX-License-Identifier: MPL-2.0 -# Note: this is an example template file to show you how to use the vsphere-supervisor builder -# to deploy a VM with Nginx installed. You can use this file as a starting point for your own. - -source "vsphere-supervisor" "example-vm" { - image_name = "'" - class_name = "" - storage_class = "" - kubeconfig_path = "" - supervisor_namespace = "" - source_name = "" - network_type = "" - ssh_username = "" - ssh_password = "" - ssh_bastion_host = "" - watch_source_timeout_sec = "" - keep_input_artifact = "" +# A Packer template to deploy a VM-Service VM using the vsphere-supervisor builder. +# It runs Nginx and cleans up the VM using the Ansible provisioner. + +# VM-Service source VM configs. +variable "image_name" { + type = string +} +variable "class_name" { + type = string +} +variable "storage_class" { + type = string +} +variable "source_name" { + type = string + default = null +} +variable "bootstrap_provider" { + type = string + default = "CloudInit" +} +variable "bootstrap_data_file" { + type = string + default = null +} + +# Supervisor cluster configs. +variable "kubeconfig_path" { + type = string + default = null +} +variable "supervisor_namespace" { + type = string + default = null +} + +# SSH connection configs. +variable "communicator" { + type = string + default = "ssh" +} +variable "ssh_username" { + type = string + default = "packer" +} +variable "ssh_password" { + type = string + default = "packer" + sensitive = true +} +variable "ssh_bastion_host" { + type = string + default = null +} +variable "ssh_bastion_username" { + type = string + default = null +} +variable "ssh_bastion_password" { + type = string + default = null + sensitive = true +} + +# Whether to keep the created source VM after the build. +variable "keep_input_artifact" { + type = bool + default = false +} + +# VM publishing configs. +variable "publish_location_name" { + type = string + default = null +} +variable "publish_image_name" { + type = string + default = null +} + +source "vsphere-supervisor" "vm" { + kubeconfig_path = "${var.kubeconfig_path}" + supervisor_namespace = "${var.supervisor_namespace}" + class_name = "${var.class_name}" + image_name = "${var.image_name}" + source_name = "${var.source_name}" + storage_class = "${var.storage_class}" + bootstrap_provider = "${var.bootstrap_provider}" + bootstrap_data_file = "${var.bootstrap_data_file}" + communicator = "${var.communicator}" + ssh_username = "${var.ssh_username}" + ssh_password = "${var.ssh_password}" + ssh_bastion_host = "${var.ssh_bastion_host}" + ssh_bastion_username = "${var.ssh_bastion_username}" + ssh_bastion_password = "${var.ssh_bastion_password}" + keep_input_artifact = "${var.keep_input_artifact}" + publish_location_name = "${var.publish_location_name}" + publish_image_name = "${var.publish_image_name}" } build { - sources = ["source.vsphere-supervisor.example-vm"] + sources = ["source.vsphere-supervisor.vm"] provisioner "shell" { inline = [ - "sudo apt update && sudo apt install -y nginx", - "sudo systemctl restart nginx", - "sudo systemctl status nginx", + "yum install -qy nginx", + "systemctl restart nginx", + "systemctl status nginx", "echo 'Testing Nginx connectivity...'", "curl -sI http://localhost:80", ] } - provisioner "ansible" { - playbook_file = "" - } } diff --git a/builder/vsphere/supervisor/builder.go b/builder/vsphere/supervisor/builder.go index aa31e29e..24dcac38 100644 --- a/builder/vsphere/supervisor/builder.go +++ b/builder/vsphere/supervisor/builder.go @@ -53,6 +53,10 @@ func (b *Builder) Run(ctx context.Context, ui packersdk.Ui, hook packersdk.Hook) &StepConnectSupervisor{ Config: &b.config.ConnectSupervisorConfig, }, + // Validate if VM publish feature is enabled and the required config is valid. + &StepValidatePublish{ + Config: &b.config.ValidatePublishConfig, + }, // Create a source VM and other related resources in Supervisor cluster. &StepCreateSource{ Config: &b.config.CreateSourceConfig, @@ -71,6 +75,11 @@ func (b *Builder) Run(ctx context.Context, ui packersdk.Ui, hook packersdk.Hook) steps = append(steps, new(commonsteps.StepProvision)) } + // Publish the provisioned source VM to a vSphere content library (if specified). + steps = append(steps, &StepPublishSource{ + Config: &b.config.PublishSourceConfig, + }) + b.runner = commonsteps.NewRunnerWithPauseFn(steps, b.config.PackerConfig, ui, state) b.runner.Run(ctx, state) @@ -78,7 +87,7 @@ func (b *Builder) Run(ctx context.Context, ui packersdk.Ui, hook packersdk.Hook) return nil, rawErr.(error) } - logger.Info("Build 'vsphere-supervisor' finished without publishing the VM image (feature not available yet).") + logger.Info("Build 'vsphere-supervisor' finished successfully.") return nil, nil } diff --git a/builder/vsphere/supervisor/config.go b/builder/vsphere/supervisor/config.go index 5894c761..d3dbcf05 100644 --- a/builder/vsphere/supervisor/config.go +++ b/builder/vsphere/supervisor/config.go @@ -23,9 +23,11 @@ const ( type Config struct { packercommon.PackerConfig `mapstructure:",squash"` CommunicatorConfig communicator.Config `mapstructure:",squash"` + ValidatePublishConfig `mapstructure:",squash"` ConnectSupervisorConfig `mapstructure:",squash"` CreateSourceConfig `mapstructure:",squash"` WatchSourceConfig `mapstructure:",squash"` + PublishSourceConfig `mapstructure:",squash"` ctx interpolate.Context } @@ -59,8 +61,10 @@ func (c *Config) Prepare(raws ...interface{}) ([]string, error) { errs := new(packersdk.MultiError) errs = packersdk.MultiErrorAppend(errs, c.CommunicatorConfig.Prepare(&c.ctx)...) errs = packersdk.MultiErrorAppend(errs, c.ConnectSupervisorConfig.Prepare()...) + errs = packersdk.MultiErrorAppend(errs, c.ValidatePublishConfig.Prepare()...) errs = packersdk.MultiErrorAppend(errs, c.CreateSourceConfig.Prepare()...) errs = packersdk.MultiErrorAppend(errs, c.WatchSourceConfig.Prepare()...) + errs = packersdk.MultiErrorAppend(errs, c.PublishSourceConfig.Prepare()...) if len(errs.Errors) > 0 { return nil, errs diff --git a/builder/vsphere/supervisor/config.hcl2spec.go b/builder/vsphere/supervisor/config.hcl2spec.go index ce8eeaf5..2da85df6 100644 --- a/builder/vsphere/supervisor/config.hcl2spec.go +++ b/builder/vsphere/supervisor/config.hcl2spec.go @@ -67,6 +67,7 @@ type FlatConfig struct { WinRMUseSSL *bool `mapstructure:"winrm_use_ssl" cty:"winrm_use_ssl" hcl:"winrm_use_ssl"` WinRMInsecure *bool `mapstructure:"winrm_insecure" cty:"winrm_insecure" hcl:"winrm_insecure"` WinRMUseNTLM *bool `mapstructure:"winrm_use_ntlm" cty:"winrm_use_ntlm" hcl:"winrm_use_ntlm"` + PublishLocationName *string `mapstructure:"publish_location_name" cty:"publish_location_name" hcl:"publish_location_name"` KubeconfigPath *string `mapstructure:"kubeconfig_path" cty:"kubeconfig_path" hcl:"kubeconfig_path"` SupervisorNamespace *string `mapstructure:"supervisor_namespace" cty:"supervisor_namespace" hcl:"supervisor_namespace"` ImageName *string `mapstructure:"image_name" required:"true" cty:"image_name" hcl:"image_name"` @@ -79,6 +80,8 @@ type FlatConfig struct { BootstrapProvider *string `mapstructure:"bootstrap_provider" cty:"bootstrap_provider" hcl:"bootstrap_provider"` BootstrapDataFile *string `mapstructure:"bootstrap_data_file" cty:"bootstrap_data_file" hcl:"bootstrap_data_file"` WatchSourceTimeoutSec *int `mapstructure:"watch_source_timeout_sec" cty:"watch_source_timeout_sec" hcl:"watch_source_timeout_sec"` + PublishImageName *string `mapstructure:"publish_image_name" cty:"publish_image_name" hcl:"publish_image_name"` + WatchPublishTimeoutSec *int `mapstructure:"watch_publish_timeout_sec" cty:"watch_publish_timeout_sec" hcl:"watch_publish_timeout_sec"` } // FlatMapstructure returns a new FlatConfig. @@ -150,6 +153,7 @@ func (*FlatConfig) HCL2Spec() map[string]hcldec.Spec { "winrm_use_ssl": &hcldec.AttrSpec{Name: "winrm_use_ssl", Type: cty.Bool, Required: false}, "winrm_insecure": &hcldec.AttrSpec{Name: "winrm_insecure", Type: cty.Bool, Required: false}, "winrm_use_ntlm": &hcldec.AttrSpec{Name: "winrm_use_ntlm", Type: cty.Bool, Required: false}, + "publish_location_name": &hcldec.AttrSpec{Name: "publish_location_name", Type: cty.String, Required: false}, "kubeconfig_path": &hcldec.AttrSpec{Name: "kubeconfig_path", Type: cty.String, Required: false}, "supervisor_namespace": &hcldec.AttrSpec{Name: "supervisor_namespace", Type: cty.String, Required: false}, "image_name": &hcldec.AttrSpec{Name: "image_name", Type: cty.String, Required: false}, @@ -162,6 +166,8 @@ func (*FlatConfig) HCL2Spec() map[string]hcldec.Spec { "bootstrap_provider": &hcldec.AttrSpec{Name: "bootstrap_provider", Type: cty.String, Required: false}, "bootstrap_data_file": &hcldec.AttrSpec{Name: "bootstrap_data_file", Type: cty.String, Required: false}, "watch_source_timeout_sec": &hcldec.AttrSpec{Name: "watch_source_timeout_sec", Type: cty.Number, Required: false}, + "publish_image_name": &hcldec.AttrSpec{Name: "publish_image_name", Type: cty.String, Required: false}, + "watch_publish_timeout_sec": &hcldec.AttrSpec{Name: "watch_publish_timeout_sec", Type: cty.Number, Required: false}, } return s } diff --git a/builder/vsphere/supervisor/config_test.go b/builder/vsphere/supervisor/config_test.go index 5704d292..589bb699 100644 --- a/builder/vsphere/supervisor/config_test.go +++ b/builder/vsphere/supervisor/config_test.go @@ -73,6 +73,14 @@ func TestConfig_Values(t *testing.T) { t.Errorf("expected storage_class to be: %s, got: %s", providedConfigs["storage_class"], c.StorageClass) } + if c.PublishLocationName != providedConfigs["publish_location_name"] { + t.Errorf("expected publish_location_name to be: %s, got: %s", + providedConfigs["publish_location_name"], c.PublishLocationName) + } + if c.PublishImageName != providedConfigs["publish_image_name"] { + t.Errorf("expected publish_image_name to be: %s, got: %s", + providedConfigs["publish_image_name"], c.PublishImageName) + } if c.KubeconfigPath != providedConfigs["kubeconfig_path"] { t.Errorf("expected kubeconfig_path to be: %s, got: %s", providedConfigs["kubeconfig_path"], c.KubeconfigPath) @@ -97,6 +105,10 @@ func TestConfig_Values(t *testing.T) { t.Errorf("expected watch_source_timeout_sec to be: %d, got: %d", providedConfigs["watch_source_timeout_sec"], c.WatchSourceTimeoutSec) } + if c.WatchPublishTimeoutSec != providedConfigs["watch_publish_timeout_sec"] { + t.Errorf("expected watch_publish_timeout_sec to be: %d, got: %d", + providedConfigs["watch_publish_timeout_sec"], c.WatchPublishTimeoutSec) + } if c.KeepInputArtifact != providedConfigs["keep_input_artifact"] { t.Errorf("expected keep_input_artifact to be: true, got: false") } @@ -115,15 +127,18 @@ func getCompleteConfig(t *testing.T) map[string]interface{} { validPath := getTestKubeconfigFile(t, "").Name() return map[string]interface{}{ - "image_name": "test-image", - "class_name": "test-class", - "storage_class": "test-storage", - "supervisor_namespace": "test-namespace", - "source_name": "test-source", - "network_type": "test-networkType", - "network_name": "test-networkName", - "watch_source_timeout_sec": 60, - "keep_input_artifact": true, - "kubeconfig_path": validPath, + "image_name": "test-image", + "class_name": "test-class", + "storage_class": "test-storage", + "supervisor_namespace": "test-namespace", + "source_name": "test-source", + "network_type": "test-networkType", + "network_name": "test-networkName", + "publish_location_name": "test-publish-location", + "publish_image_name": "test-publish-image", + "watch_source_timeout_sec": 60, + "watch_publish_timeout_sec": 60, + "keep_input_artifact": true, + "kubeconfig_path": validPath, } } diff --git a/builder/vsphere/supervisor/step_connect_supervisor.go b/builder/vsphere/supervisor/step_connect_supervisor.go index a8d7cf0e..ea07f0e9 100644 --- a/builder/vsphere/supervisor/step_connect_supervisor.go +++ b/builder/vsphere/supervisor/step_connect_supervisor.go @@ -10,14 +10,14 @@ import ( "context" "os" + "github.com/hashicorp/packer-plugin-sdk/multistep" + "github.com/pkg/errors" + imgregv1alpha1 "github.com/vmware-tanzu/image-registry-operator-api/api/v1alpha1" + vmopv1alpha1 "github.com/vmware-tanzu/vm-operator/api/v1alpha1" corev1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/runtime" "k8s.io/client-go/tools/clientcmd" "sigs.k8s.io/controller-runtime/pkg/client" - - "github.com/hashicorp/packer-plugin-sdk/multistep" - "github.com/pkg/errors" - vmopv1alpha1 "github.com/vmware-tanzu/vm-operator/api/v1alpha1" ) const ( @@ -98,10 +98,11 @@ var InitKubeClientFunc = func(s *StepConnectSupervisor) (client.WithWatch, error return nil, err } - // The Supervisor builder will interact with both vmoperator and corev1 resources. + // The Supervisor builder will interact with both vmoperator, corev1, and image-registry-operator resources. scheme := runtime.NewScheme() _ = corev1.AddToScheme(scheme) _ = vmopv1alpha1.AddToScheme(scheme) + _ = imgregv1alpha1.AddToScheme(scheme) // Initialize a WithWatch client as we need to watch the status of the source VM. return client.NewWithWatch(config, client.Options{Scheme: scheme}) diff --git a/builder/vsphere/supervisor/step_connect_supervisor_test.go b/builder/vsphere/supervisor/step_connect_supervisor_test.go index 6c27902b..9e289261 100644 --- a/builder/vsphere/supervisor/step_connect_supervisor_test.go +++ b/builder/vsphere/supervisor/step_connect_supervisor_test.go @@ -9,11 +9,10 @@ import ( "os" "testing" + "github.com/hashicorp/packer-plugin-sdk/multistep" "k8s.io/client-go/tools/clientcmd" "sigs.k8s.io/controller-runtime/pkg/client" - "github.com/hashicorp/packer-plugin-sdk/multistep" - "github.com/hashicorp/packer-plugin-vsphere/builder/vsphere/supervisor" ) diff --git a/builder/vsphere/supervisor/step_create_source.go b/builder/vsphere/supervisor/step_create_source.go index 4dd4ec51..43ed6f9c 100644 --- a/builder/vsphere/supervisor/step_create_source.go +++ b/builder/vsphere/supervisor/step_create_source.go @@ -30,6 +30,7 @@ const ( StateKeyVMCreated = "vm_created" StateKeyVMServiceCreated = "vm_service_created" StateKeyVMMetadataSecretCreated = "vm_metadata_secret_created" + StateKeyKeepInputArtifact = "keep_input_artifact" ProviderCloudInit = string(vmopv1alpha1.VirtualMachineMetadataCloudInitTransport) ProviderSysprep = string(vmopv1alpha1.VirtualMachineMetadataSysprepTransport) @@ -50,7 +51,7 @@ type CreateSourceConfig struct { NetworkType string `mapstructure:"network_type"` // Name of the network to attach to the source VM's network interface. Defaults to empty. NetworkName string `mapstructure:"network_name"` - // Preserve the created objects even after importing them to the vSphere endpoint. Defaults to `false`. + // Preserve all the created objects in Supervisor cluster after the build finishes. Defaults to `false`. KeepInputArtifact bool `mapstructure:"keep_input_artifact"` // Name of the bootstrap provider to use for configuring the source VM. // Supported values are `CloudInit`, `Sysprep`, and `vAppConfig`. Defaults to `CloudInit`. @@ -132,8 +133,9 @@ func (s *StepCreateSource) Run(ctx context.Context, state multistep.StateBag) mu state.Put(StateKeyVMServiceCreated, true) } - // Make the source name retrievable in later step. + // Make the source_name and keep_input_artifact retrievable in later step. state.Put(StateKeySourceName, s.Config.SourceName) + state.Put(StateKeyKeepInputArtifact, s.Config.KeepInputArtifact) logger.Info("Finished creating all required source objects in Supervisor cluster") return multistep.ActionContinue diff --git a/builder/vsphere/supervisor/step_publish_source.go b/builder/vsphere/supervisor/step_publish_source.go new file mode 100644 index 00000000..abad2032 --- /dev/null +++ b/builder/vsphere/supervisor/step_publish_source.go @@ -0,0 +1,223 @@ +//go:generate packer-sdc struct-markdown +//go:generate packer-sdc mapstructure-to-hcl2 -type PublishSourceConfig + +package supervisor + +import ( + "context" + "fmt" + "time" + + "github.com/hashicorp/packer-plugin-sdk/multistep" + vmopv1alpha1 "github.com/vmware-tanzu/vm-operator/api/v1alpha1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/fields" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +const ( + DefaultWatchPublishTimeoutSec = 600 + + StateKeyVMPublishRequestCreated = "vm_pub_req_created" +) + +var IsWatchingVMPublish bool + +type PublishSourceConfig struct { + // The name of the published VM image. If not specified, the vm-operator API will set a default name. + PublishImageName string `mapstructure:"publish_image_name"` + // The timeout in seconds to wait for the VM to be published. Defaults to `600`. + WatchPublishTimeoutSec int `mapstructure:"watch_publish_timeout_sec"` +} + +func (c *PublishSourceConfig) Prepare() []error { + if c.WatchPublishTimeoutSec == 0 { + c.WatchPublishTimeoutSec = DefaultWatchPublishTimeoutSec + } + + return nil +} + +type StepPublishSource struct { + Config *PublishSourceConfig + + PublishLocationName, SourceName, Namespace string + KeepInputArtifact bool + KubeWatchClient client.WithWatch +} + +func (s *StepPublishSource) Run(ctx context.Context, state multistep.StateBag) multistep.StepAction { + logger := state.Get("logger").(*PackerLogger) + + var err error + defer func() { + if err != nil { + state.Put("error", err) + } + }() + + if err = s.initStep(state); err != nil { + return multistep.ActionHalt + } + + // Skip publishing if the publish location name is not specified. + if s.PublishLocationName == "" { + return multistep.ActionContinue + } + + logger.Info("Publishing the source VM to %q", s.PublishLocationName) + + if err = s.createVMPublishRequest(ctx, logger); err != nil { + return multistep.ActionHalt + } + state.Put(StateKeyVMPublishRequestCreated, true) + + if err = s.watchVMPublish(ctx, logger); err != nil { + return multistep.ActionHalt + } + + logger.Info("Finished publishing the source VM") + + return multistep.ActionContinue +} + +func (s *StepPublishSource) Cleanup(state multistep.StateBag) { + if state.Get(StateKeyVMPublishRequestCreated) == false { + // Either the publish step was skipped or the object was not created successfully. + // Skip deleting the VirtualMachinePublishRequest object. + return + } + + logger := state.Get("logger").(*PackerLogger) + if s.KeepInputArtifact { + logger.Info("Skip cleaning up the VirtualMachinePublishRequest object as specified in config") + return + } + + logger.Info("Deleting the VirtualMachinePublishRequest object from Supervisor cluster") + ctx := context.Background() + vmPubReqObj := &vmopv1alpha1.VirtualMachinePublishRequest{ + ObjectMeta: metav1.ObjectMeta{ + Name: s.SourceName, + Namespace: s.Namespace, + }, + } + if err := s.KubeWatchClient.Delete(ctx, vmPubReqObj); err != nil { + logger.Error("Failed to delete the VirtualMachinePublishRequest object") + } else { + logger.Info("Successfully deleted the VirtualMachinePublishRequest object") + } +} + +func (s *StepPublishSource) initStep(state multistep.StateBag) error { + if err := CheckRequiredStates(state, + StateKeyPublishLocationName, + StateKeySourceName, + StateKeySupervisorNamespace, + StateKeyKubeClient, + StateKeyKeepInputArtifact, + ); err != nil { + return err + } + + var ok bool + if s.PublishLocationName, ok = state.Get(StateKeyPublishLocationName).(string); !ok { + return fmt.Errorf("failed to cast %s to type string", StateKeyPublishLocationName) + } + if s.SourceName, ok = state.Get(StateKeySourceName).(string); !ok { + return fmt.Errorf("failed to cast %s to type string", StateKeySourceName) + } + if s.Namespace, ok = state.Get(StateKeySupervisorNamespace).(string); !ok { + return fmt.Errorf("failed to cast %s to type string", StateKeySupervisorNamespace) + } + if s.KubeWatchClient, ok = state.Get(StateKeyKubeClient).(client.WithWatch); !ok { + return fmt.Errorf("failed to cast %s to type client.WithWatch", StateKeyKubeClient) + } + if s.KeepInputArtifact, ok = state.Get(StateKeyKeepInputArtifact).(bool); !ok { + return fmt.Errorf("failed to cast %s to type bool", StateKeyKeepInputArtifact) + } + + return nil +} + +func (s *StepPublishSource) createVMPublishRequest(ctx context.Context, logger *PackerLogger) error { + logger.Info("Creating a VirtualMachinePublishRequest object") + + vmPublishReq := &vmopv1alpha1.VirtualMachinePublishRequest{ + ObjectMeta: metav1.ObjectMeta{ + Name: s.SourceName, + Namespace: s.Namespace, + }, + Spec: vmopv1alpha1.VirtualMachinePublishRequestSpec{ + Target: vmopv1alpha1.VirtualMachinePublishRequestTarget{ + Location: vmopv1alpha1.VirtualMachinePublishRequestTargetLocation{ + Name: s.PublishLocationName, + }, + }, + }, + } + + // Set the PublishImageName if provided in configs. + if s.Config.PublishImageName != "" { + vmPublishReq.Spec.Target.Item.Name = s.Config.PublishImageName + } + + if err := s.KubeWatchClient.Create(ctx, vmPublishReq); err != nil { + logger.Error("Failed to create the VirtualMachinePublishRequest object") + return err + } + + logger.Info("Successfully created the VirtualMachinePublishRequest object") + return nil +} + +func (s *StepPublishSource) watchVMPublish(ctx context.Context, logger *PackerLogger) error { + vmPublishReqWatch, err := s.KubeWatchClient.Watch(ctx, &vmopv1alpha1.VirtualMachinePublishRequestList{}, &client.ListOptions{ + FieldSelector: fields.OneTermEqualSelector("metadata.name", s.SourceName), + Namespace: s.Namespace, + }) + + if err != nil { + logger.Error("Failed to watch the VirtualMachinePublishRequest object in Supervisor cluster") + return err + } + + timedCtx, cancel := context.WithTimeout(ctx, time.Duration(s.Config.WatchPublishTimeoutSec)*time.Second) + + defer func() { + vmPublishReqWatch.Stop() + cancel() + + Mu.Lock() + IsWatchingVMPublish = false + Mu.Unlock() + }() + + Mu.Lock() + IsWatchingVMPublish = true + Mu.Unlock() + + for { + select { + case event := <-vmPublishReqWatch.ResultChan(): + if event.Object == nil { + return fmt.Errorf("watch VirtualMachinePublishRequest event object is nil") + } + + vmPublishReqObj, ok := event.Object.(*vmopv1alpha1.VirtualMachinePublishRequest) + if !ok { + return fmt.Errorf("failed to convert the watch VirtualMachinePublishRequest event object") + } + + if !vmPublishReqObj.Status.Ready { + logger.Info("Waiting for the VM publish request to complete...") + } else { + logger.Info("Successfully published the VM to image %q", vmPublishReqObj.Status.ImageName) + return nil + } + + case <-timedCtx.Done(): + return fmt.Errorf("timed out watching for VirtualMachinePublishRequest object to complete") + } + } +} diff --git a/builder/vsphere/supervisor/step_publish_source.hcl2spec.go b/builder/vsphere/supervisor/step_publish_source.hcl2spec.go new file mode 100644 index 00000000..1fa4106e --- /dev/null +++ b/builder/vsphere/supervisor/step_publish_source.hcl2spec.go @@ -0,0 +1,33 @@ +// Code generated by "packer-sdc mapstructure-to-hcl2"; DO NOT EDIT. + +package supervisor + +import ( + "github.com/hashicorp/hcl/v2/hcldec" + "github.com/zclconf/go-cty/cty" +) + +// FlatPublishSourceConfig is an auto-generated flat version of PublishSourceConfig. +// Where the contents of a field with a `mapstructure:,squash` tag are bubbled up. +type FlatPublishSourceConfig struct { + PublishImageName *string `mapstructure:"publish_image_name" cty:"publish_image_name" hcl:"publish_image_name"` + WatchPublishTimeoutSec *int `mapstructure:"watch_publish_timeout_sec" cty:"watch_publish_timeout_sec" hcl:"watch_publish_timeout_sec"` +} + +// FlatMapstructure returns a new FlatPublishSourceConfig. +// FlatPublishSourceConfig is an auto-generated flat version of PublishSourceConfig. +// Where the contents a fields with a `mapstructure:,squash` tag are bubbled up. +func (*PublishSourceConfig) FlatMapstructure() interface{ HCL2Spec() map[string]hcldec.Spec } { + return new(FlatPublishSourceConfig) +} + +// HCL2Spec returns the hcl spec of a PublishSourceConfig. +// This spec is used by HCL to read the fields of PublishSourceConfig. +// The decoded values from this spec will then be applied to a FlatPublishSourceConfig. +func (*FlatPublishSourceConfig) HCL2Spec() map[string]hcldec.Spec { + s := map[string]hcldec.Spec{ + "publish_image_name": &hcldec.AttrSpec{Name: "publish_image_name", Type: cty.String, Required: false}, + "watch_publish_timeout_sec": &hcldec.AttrSpec{Name: "watch_publish_timeout_sec", Type: cty.Number, Required: false}, + } + return s +} diff --git a/builder/vsphere/supervisor/step_publish_source_test.go b/builder/vsphere/supervisor/step_publish_source_test.go new file mode 100644 index 00000000..e13b6f15 --- /dev/null +++ b/builder/vsphere/supervisor/step_publish_source_test.go @@ -0,0 +1,214 @@ +package supervisor_test + +import ( + "bytes" + "context" + "sync" + "testing" + "time" + + "github.com/hashicorp/packer-plugin-sdk/multistep" + vmopv1alpha1 "github.com/vmware-tanzu/vm-operator/api/v1alpha1" + "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/client" + + "github.com/hashicorp/packer-plugin-vsphere/builder/vsphere/supervisor" +) + +func TestPublishSource_Prepare(t *testing.T) { + config := &supervisor.PublishSourceConfig{} + if actualErrs := config.Prepare(); len(actualErrs) != 0 { + t.Fatalf("Prepare should NOT fail: %v", actualErrs) + } + + if config.WatchPublishTimeoutSec != supervisor.DefaultWatchPublishTimeoutSec { + t.Fatalf("Default timeout should be %d, but got %d", + supervisor.DefaultWatchPublishTimeoutSec, config.WatchPublishTimeoutSec) + } +} + +func TestStepPublishSource_Run_Skip(t *testing.T) { + // Initialize the step without `publish_location_name` set. + config := &supervisor.PublishSourceConfig{ + WatchPublishTimeoutSec: 5, + } + step := &supervisor.StepPublishSource{ + Config: config, + } + + // Set up required state for running this step. + state := newBasicTestState(new(bytes.Buffer)) + state.Put(supervisor.StateKeyPublishLocationName, "") + state.Put(supervisor.StateKeySourceName, "test-source") + state.Put(supervisor.StateKeySupervisorNamespace, "test-ns") + state.Put(supervisor.StateKeyKubeClient, newFakeKubeClient()) + state.Put(supervisor.StateKeyKeepInputArtifact, true) + + action := step.Run(context.TODO(), state) + if action != multistep.ActionContinue { + if rawErr, ok := state.GetOk("error"); ok { + t.Errorf("Error from running the step: %s", rawErr.(error)) + } + t.Fatal("Step should continue") + } +} + +func TestStepPublishSource_Run(t *testing.T) { + // Initialize the step with `publish_location_name` set. + config := &supervisor.PublishSourceConfig{ + WatchPublishTimeoutSec: 5, + } + step := &supervisor.StepPublishSource{ + Config: config, + } + + testSourceName := "test-source-name" + testImageName := "test-image-name" + testPublishLocationName := "test-publish-location-name" + testNamespace := "test-namespace" + testPublishRequestName := "test-publish-request-name" + VMPublishReqObj := newFakeVMPubReqObj(testNamespace, testPublishRequestName, testPublishLocationName) + testKubeClient := newFakeKubeClient(VMPublishReqObj) + + // Set up required state for running this step. + testWriter := new(bytes.Buffer) + state := newBasicTestState(testWriter) + state.Put(supervisor.StateKeyPublishLocationName, testPublishLocationName) + state.Put(supervisor.StateKeySourceName, testSourceName) + state.Put(supervisor.StateKeySupervisorNamespace, testNamespace) + state.Put(supervisor.StateKeyKubeClient, testKubeClient) + state.Put(supervisor.StateKeyKeepInputArtifact, true) + + ctx := context.TODO() + var wg sync.WaitGroup + wg.Add(1) + + go func() { + defer wg.Done() + action := step.Run(ctx, state) + if action == multistep.ActionHalt { + if rawErr, ok := state.GetOk("error"); ok { + t.Errorf("Error from running the step: %s", rawErr.(error)) + } + t.Errorf("Step should NOT halt") + } + + // check if the VirtualMachinePublishRequest object is created with the expected spec. + objKey := client.ObjectKey{ + Name: testPublishRequestName, + Namespace: testNamespace, + } + if err := testKubeClient.Get(ctx, objKey, VMPublishReqObj); err != nil { + t.Errorf("Failed to get the expected VirtualMachinePublishRequest object, err: %s", err.Error()) + } + if VMPublishReqObj.Name != testPublishRequestName { + t.Errorf("Expected VirtualMachinePublishRequest name to be '%s', got '%s'", + testPublishRequestName, VMPublishReqObj.Name) + } + if VMPublishReqObj.Namespace != testNamespace { + t.Errorf("Expected VirtualMachinePublishRequest namespace to be '%s', got '%s'", + testNamespace, VMPublishReqObj.Namespace) + } + if VMPublishReqObj.Spec.Target.Location.Name != testPublishLocationName { + t.Errorf("Expected VirtualMachinePublishRequest target location to be '%s', got '%s'", + testPublishLocationName, VMPublishReqObj.Spec.Target.Location.Name) + } + + expectedOutput := []string{ + "Publishing the source VM to \"test-publish-location-name\"", + "Creating a VirtualMachinePublishRequest object", + "Successfully created the VirtualMachinePublishRequest object", + "Waiting for the VM publish request to complete...", + "Successfully published the VM to image \"test-image-name\"", + "Finished publishing the source VM", + } + checkOutputLines(t, testWriter, expectedOutput) + }() + + // Wait for the watch to be established from Builder before updating the fake VirtualMachinePublishRequest resource below. + for i := 0; i < step.Config.WatchPublishTimeoutSec; i++ { + supervisor.Mu.Lock() + if supervisor.IsWatchingVMPublish { + supervisor.Mu.Unlock() + break + } + supervisor.Mu.Unlock() + time.Sleep(time.Second) + } + + VMPublishReqObj.Status.Ready = false + if err := testKubeClient.Update(ctx, VMPublishReqObj); err != nil { + t.Errorf("Failed to update the VirtualMachinePublishRequest object status ready, err: %s", err.Error()) + } + + VMPublishReqObj.Status.Ready = true + VMPublishReqObj.Status.ImageName = testImageName + if err := testKubeClient.Update(ctx, VMPublishReqObj); err != nil { + t.Errorf("Failed to update the VirtualMachinePublishRequest object status image name, err: %s", err.Error()) + } + + wg.Wait() +} + +func TestStepPublishSource_Cleanup(t *testing.T) { + // Test when 'keep_input_artifact' config is set to true (should skip cleanup). + step := &supervisor.StepPublishSource{} + step.KeepInputArtifact = true + testWriter := new(bytes.Buffer) + state := newBasicTestState(testWriter) + state.Put(supervisor.StateKeyVMPublishRequestCreated, true) + step.Cleanup(state) + + expectedOutput := []string{"Skip cleaning up the VirtualMachinePublishRequest object as specified in config"} + checkOutputLines(t, testWriter, expectedOutput) + + // Test when 'keep_input_artifact' config is false (should delete the VirtualMachinePublishRequest object). + step.KeepInputArtifact = false + step.SourceName = "test-source" + step.Namespace = "test-namespace" + vmPubReq := &vmopv1alpha1.VirtualMachinePublishRequest{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-source", + Namespace: "test-namespace", + }, + } + fakeClient := newFakeKubeClient(vmPubReq) + step.KubeWatchClient = fakeClient + state.Put(supervisor.StateKeyKeepInputArtifact, true) + state.Put(supervisor.StateKeyVMPublishRequestCreated, true) + step.Cleanup(state) + + // Check if the source objects are deleted from the cluster. + ctx := context.TODO() + objKey := client.ObjectKey{ + Name: "test-source", + Namespace: "test-namespace", + } + if err := fakeClient.Get(ctx, objKey, &vmopv1alpha1.VirtualMachinePublishRequest{}); !errors.IsNotFound(err) { + t.Fatal("Expected the VirtualMachinePublishRequest object to be deleted") + } + + // Check the output lines from the step runs. + expectedOutput = []string{ + "Deleting the VirtualMachinePublishRequest object from Supervisor cluster", + "Successfully deleted the VirtualMachinePublishRequest object", + } + checkOutputLines(t, testWriter, expectedOutput) +} + +func newFakeVMPubReqObj(ns, name, publishLocation string) *vmopv1alpha1.VirtualMachinePublishRequest { + return &vmopv1alpha1.VirtualMachinePublishRequest{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: ns, + Name: name, + }, + Spec: vmopv1alpha1.VirtualMachinePublishRequestSpec{ + Target: vmopv1alpha1.VirtualMachinePublishRequestTarget{ + Location: vmopv1alpha1.VirtualMachinePublishRequestTargetLocation{ + Name: publishLocation, + }, + }, + }, + } +} diff --git a/builder/vsphere/supervisor/step_validate_publish.go b/builder/vsphere/supervisor/step_validate_publish.go new file mode 100644 index 00000000..3e557071 --- /dev/null +++ b/builder/vsphere/supervisor/step_validate_publish.go @@ -0,0 +1,134 @@ +//go:generate packer-sdc struct-markdown +//go:generate packer-sdc mapstructure-to-hcl2 -type ValidatePublishConfig + +package supervisor + +import ( + "context" + "fmt" + "strings" + + "github.com/hashicorp/packer-plugin-sdk/multistep" + imgregv1a1 "github.com/vmware-tanzu/image-registry-operator-api/api/v1alpha1" + vmopv1alpha1 "github.com/vmware-tanzu/vm-operator/api/v1alpha1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +const ( + StateKeyPublishLocationName = "publish_location_name" + + vmPubFeatureNotEnabledMsg = "WCP_VM_Image_Registry feature not enabled" +) + +type ValidatePublishConfig struct { + // Name of a writable ContentLibrary resource associated with namespace where the source VM will be published. + PublishLocationName string `mapstructure:"publish_location_name"` +} + +func (c *ValidatePublishConfig) Prepare() []error { + return nil +} + +type StepValidatePublish struct { + Config *ValidatePublishConfig + + Namespace string + KubeClient client.Client +} + +func (s *StepValidatePublish) Run(ctx context.Context, state multistep.StateBag) multistep.StepAction { + logger := state.Get("logger").(*PackerLogger) + logger.Info("Validating VM publish location...") + + var err error + defer func() { + if err != nil { + state.Put("error", err) + } + }() + + state.Put(StateKeyPublishLocationName, s.Config.PublishLocationName) + + if s.Config.PublishLocationName == "" { + logger.Info("VM publish step will be skipped as the `publish_location_name` config is not set") + return multistep.ActionContinue + } + + if err = s.initStep(state); err != nil { + return multistep.ActionHalt + } + + if err = s.isPublishFeatureEnabled(ctx, logger); err != nil { + return multistep.ActionHalt + } + + if err = s.isPublishLocationValid(ctx, logger); err != nil { + return multistep.ActionHalt + } + + logger.Info("VM publish location is valid") + + return multistep.ActionContinue +} + +func (s *StepValidatePublish) Cleanup(state multistep.StateBag) {} + +func (s *StepValidatePublish) initStep(state multistep.StateBag) error { + if err := CheckRequiredStates(state, + StateKeySupervisorNamespace, + StateKeyKubeClient, + ); err != nil { + return err + } + + var ( + ok bool + namespace string + kubeClient client.Client + ) + + if namespace, ok = state.Get(StateKeySupervisorNamespace).(string); !ok { + return fmt.Errorf("failed to cast %q from state bag as type string", StateKeySupervisorNamespace) + } + if kubeClient, ok = state.Get(StateKeyKubeClient).(client.Client); !ok { + return fmt.Errorf("failed to cast %q from state bag as type 'client.Client'", StateKeyKubeClient) + } + + s.Namespace = namespace + s.KubeClient = kubeClient + + return nil +} + +func (s *StepValidatePublish) isPublishFeatureEnabled(ctx context.Context, logger *PackerLogger) error { + vmPublishReq := &vmopv1alpha1.VirtualMachinePublishRequest{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: s.Namespace, + GenerateName: "vmpub-", + }, + } + + // Use dry run mode to send a VM publish creation request to API-Server without applying the resource. + err := client.NewDryRunClient(s.KubeClient).Create(ctx, vmPublishReq) + if err != nil && strings.Contains(err.Error(), vmPubFeatureNotEnabledMsg) { + logger.Error("The VM publish feature is NOT enabled in the current version of vSphere Supervisor cluster") + return err + } + + return nil +} + +func (s *StepValidatePublish) isPublishLocationValid(ctx context.Context, logger *PackerLogger) error { + cl := &imgregv1a1.ContentLibrary{} + objKey := client.ObjectKey{Name: s.Config.PublishLocationName, Namespace: s.Namespace} + if err := s.KubeClient.Get(ctx, objKey, cl); err != nil { + return err + } + + if !cl.Spec.Writable { + return fmt.Errorf("The specified publish location %q is not writable", s.Config.PublishLocationName) + } + + return nil +} diff --git a/builder/vsphere/supervisor/step_validate_publish.hcl2spec.go b/builder/vsphere/supervisor/step_validate_publish.hcl2spec.go new file mode 100644 index 00000000..c152a6e8 --- /dev/null +++ b/builder/vsphere/supervisor/step_validate_publish.hcl2spec.go @@ -0,0 +1,31 @@ +// Code generated by "packer-sdc mapstructure-to-hcl2"; DO NOT EDIT. + +package supervisor + +import ( + "github.com/hashicorp/hcl/v2/hcldec" + "github.com/zclconf/go-cty/cty" +) + +// FlatValidatePublishConfig is an auto-generated flat version of ValidatePublishConfig. +// Where the contents of a field with a `mapstructure:,squash` tag are bubbled up. +type FlatValidatePublishConfig struct { + PublishLocationName *string `mapstructure:"publish_location_name" cty:"publish_location_name" hcl:"publish_location_name"` +} + +// FlatMapstructure returns a new FlatValidatePublishConfig. +// FlatValidatePublishConfig is an auto-generated flat version of ValidatePublishConfig. +// Where the contents a fields with a `mapstructure:,squash` tag are bubbled up. +func (*ValidatePublishConfig) FlatMapstructure() interface{ HCL2Spec() map[string]hcldec.Spec } { + return new(FlatValidatePublishConfig) +} + +// HCL2Spec returns the hcl spec of a ValidatePublishConfig. +// This spec is used by HCL to read the fields of ValidatePublishConfig. +// The decoded values from this spec will then be applied to a FlatValidatePublishConfig. +func (*FlatValidatePublishConfig) HCL2Spec() map[string]hcldec.Spec { + s := map[string]hcldec.Spec{ + "publish_location_name": &hcldec.AttrSpec{Name: "publish_location_name", Type: cty.String, Required: false}, + } + return s +} diff --git a/builder/vsphere/supervisor/step_validate_publish_test.go b/builder/vsphere/supervisor/step_validate_publish_test.go new file mode 100644 index 00000000..59fafcf1 --- /dev/null +++ b/builder/vsphere/supervisor/step_validate_publish_test.go @@ -0,0 +1,120 @@ +package supervisor_test + +import ( + "bytes" + "context" + "fmt" + "strings" + "testing" + + "github.com/hashicorp/packer-plugin-sdk/multistep" + imgregv1a1 "github.com/vmware-tanzu/image-registry-operator-api/api/v1alpha1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + "github.com/hashicorp/packer-plugin-vsphere/builder/vsphere/supervisor" +) + +func TestValidatePublish_Prepare(t *testing.T) { + config := &supervisor.ValidatePublishConfig{} + if actualErr := config.Prepare(); len(actualErr) != 0 { + t.Fatalf("Prepare should not fail: %v", actualErr) + } +} + +func TestValidatePublish_Run(t *testing.T) { + // Test with `publish_location_name` not set. + step := &supervisor.StepValidatePublish{ + Config: &supervisor.ValidatePublishConfig{ + PublishLocationName: "", + }, + } + + ctx := context.TODO() + testWriter := new(bytes.Buffer) + state := newBasicTestState(testWriter) + + action := step.Run(ctx, state) + if action == multistep.ActionHalt { + if rawErr, ok := state.GetOk("error"); ok { + t.Errorf("Error from running the step: %s", rawErr.(error)) + } + t.Fatal("Step should NOT halt") + } + + expectedOutput := []string{ + "Validating VM publish location...", + "VM publish step will be skipped as the `publish_location_name` config is not set", + } + checkOutputLines(t, testWriter, expectedOutput) + + // Test with non-existing "publish_location_name". + testPublishLocationName := "test-publish-location" + testNamespace := "test-namespace" + kubeClient := newFakeKubeClient() + step.Config.PublishLocationName = testPublishLocationName + state.Put(supervisor.StateKeySupervisorNamespace, testNamespace) + state.Put(supervisor.StateKeyKubeClient, kubeClient) + + action = step.Run(ctx, state) + if action != multistep.ActionHalt { + t.Fatal("Step should halt") + } + + expectedError := fmt.Sprintf("%q not found", testPublishLocationName) + if rawErr, ok := state.GetOk("error"); ok { + if !strings.Contains(rawErr.(error).Error(), expectedError) { + t.Errorf("Expected error contains %v, but got %v", expectedError, rawErr.(error).Error()) + } + } + + expectedOutput = []string{ + "Validating VM publish location...", + } + checkOutputLines(t, testWriter, expectedOutput) + + // Test with existing but non-writable "publish_location_name". + clObj := &imgregv1a1.ContentLibrary{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: testNamespace, + Name: testPublishLocationName, + }, + Spec: imgregv1a1.ContentLibrarySpec{ + Writable: false, + }, + } + kubeClient = newFakeKubeClient(clObj) + state.Put(supervisor.StateKeyKubeClient, kubeClient) + + action = step.Run(ctx, state) + if action != multistep.ActionHalt { + t.Fatal("Step should halt") + } + + expectedError = fmt.Sprintf("The specified publish location %q is not writable", testPublishLocationName) + if rawErr, ok := state.GetOk("error"); ok { + if rawErr.(error).Error() != expectedError { + t.Errorf("Expected error is %v, but got %v", expectedError, rawErr.(error).Error()) + } + } + + expectedOutput = []string{ + "Validating VM publish location...", + } + checkOutputLines(t, testWriter, expectedOutput) + + // Test with valid (existing and writable) "publish_location_name". + clObj.Spec.Writable = true + kubeClient = newFakeKubeClient(clObj) + state.Put(supervisor.StateKeyKubeClient, kubeClient) + + action = step.Run(ctx, state) + if action != multistep.ActionContinue { + t.Fatal("Step should continue") + } + + expectedOutput = []string{ + "Validating VM publish location...", + "VM publish location is valid", + } + checkOutputLines(t, testWriter, expectedOutput) +} diff --git a/builder/vsphere/supervisor/step_watch_source.go b/builder/vsphere/supervisor/step_watch_source.go index 1dd36998..ce531f25 100644 --- a/builder/vsphere/supervisor/step_watch_source.go +++ b/builder/vsphere/supervisor/step_watch_source.go @@ -71,7 +71,8 @@ func (s *StepWatchSource) Run(ctx context.Context, state multistep.StateBag) mul timedCtx, cancel := context.WithTimeout(ctx, time.Duration(s.Config.WatchSourceTimeoutSec)*time.Second) defer cancel() - vmIP, err := s.waitForVMReady(timedCtx, logger) + vmIP := "" + vmIP, err = s.waitForVMReady(timedCtx, logger) if err != nil { return multistep.ActionHalt } @@ -79,7 +80,8 @@ func (s *StepWatchSource) Run(ctx context.Context, state multistep.StateBag) mul // Only get the VM ingress IP if the VM service has been created (i.e. communicator is not 'none'). if state.Get(StateKeyVMServiceCreated) == true { - ingressIP, err := s.getVMIngressIP(timedCtx, logger) + ingressIP := "" + ingressIP, err = s.getVMIngressIP(timedCtx, logger) if err != nil { return multistep.ActionHalt } diff --git a/builder/vsphere/supervisor/step_watch_source_test.go b/builder/vsphere/supervisor/step_watch_source_test.go index 62679637..8a425054 100644 --- a/builder/vsphere/supervisor/step_watch_source_test.go +++ b/builder/vsphere/supervisor/step_watch_source_test.go @@ -13,11 +13,8 @@ import ( "github.com/hashicorp/packer-plugin-sdk/multistep" vmopv1alpha1 "github.com/vmware-tanzu/vm-operator/api/v1alpha1" - corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/runtime" "sigs.k8s.io/controller-runtime/pkg/client" - "sigs.k8s.io/controller-runtime/pkg/client/fake" "github.com/hashicorp/packer-plugin-vsphere/builder/vsphere/supervisor" ) @@ -125,14 +122,6 @@ func TestWatchSource_Run(t *testing.T) { wg.Wait() } -func newFakeKubeClient(initObjs ...client.Object) client.WithWatch { - scheme := runtime.NewScheme() - _ = corev1.AddToScheme(scheme) - _ = vmopv1alpha1.AddToScheme(scheme) - - return fake.NewClientBuilder().WithObjects(initObjs...).WithScheme(scheme).Build() -} - func newFakeVMObj(namespace, name, vmIP string) *vmopv1alpha1.VirtualMachine { return &vmopv1alpha1.VirtualMachine{ ObjectMeta: metav1.ObjectMeta{ diff --git a/builder/vsphere/supervisor/utils_test.go b/builder/vsphere/supervisor/utils_test.go index 4f99a94b..634edc8f 100644 --- a/builder/vsphere/supervisor/utils_test.go +++ b/builder/vsphere/supervisor/utils_test.go @@ -13,6 +13,12 @@ import ( "github.com/hashicorp/packer-plugin-sdk/multistep" packersdk "github.com/hashicorp/packer-plugin-sdk/packer" + imgregv1a1 "github.com/vmware-tanzu/image-registry-operator-api/api/v1alpha1" + vmopv1alpha1 "github.com/vmware-tanzu/vm-operator/api/v1alpha1" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" "github.com/hashicorp/packer-plugin-vsphere/builder/vsphere/supervisor" ) @@ -91,3 +97,12 @@ kind: Config return fakeFile } + +func newFakeKubeClient(initObjs ...client.Object) client.WithWatch { + scheme := runtime.NewScheme() + _ = corev1.AddToScheme(scheme) + _ = vmopv1alpha1.AddToScheme(scheme) + _ = imgregv1a1.AddToScheme(scheme) + + return fake.NewClientBuilder().WithObjects(initObjs...).WithScheme(scheme).Build() +} diff --git a/docs-partials/builder/vsphere/supervisor/CreateSourceConfig-not-required.mdx b/docs-partials/builder/vsphere/supervisor/CreateSourceConfig-not-required.mdx index 4bae8a94..839c8b3d 100644 --- a/docs-partials/builder/vsphere/supervisor/CreateSourceConfig-not-required.mdx +++ b/docs-partials/builder/vsphere/supervisor/CreateSourceConfig-not-required.mdx @@ -6,7 +6,7 @@ - `network_name` (string) - Name of the network to attach to the source VM's network interface. Defaults to empty. -- `keep_input_artifact` (bool) - Preserve the created objects even after importing them to the vSphere endpoint. Defaults to `false`. +- `keep_input_artifact` (bool) - Preserve all the created objects in Supervisor cluster after the build finishes. Defaults to `false`. - `bootstrap_provider` (string) - Name of the bootstrap provider to use for configuring the source VM. Supported values are `CloudInit`, `Sysprep`, and `vAppConfig`. Defaults to `CloudInit`. diff --git a/docs-partials/builder/vsphere/supervisor/PublishSourceConfig-not-required.mdx b/docs-partials/builder/vsphere/supervisor/PublishSourceConfig-not-required.mdx new file mode 100644 index 00000000..63a1e961 --- /dev/null +++ b/docs-partials/builder/vsphere/supervisor/PublishSourceConfig-not-required.mdx @@ -0,0 +1,7 @@ + + +- `publish_image_name` (string) - The name of the published VM image. If not specified, the vm-operator API will set a default name. + +- `watch_publish_timeout_sec` (int) - The timeout in seconds to wait for the VM to be published. Defaults to `600`. + + diff --git a/docs-partials/builder/vsphere/supervisor/ValidatePublishConfig-not-required.mdx b/docs-partials/builder/vsphere/supervisor/ValidatePublishConfig-not-required.mdx new file mode 100644 index 00000000..4c5ce6ae --- /dev/null +++ b/docs-partials/builder/vsphere/supervisor/ValidatePublishConfig-not-required.mdx @@ -0,0 +1,5 @@ + + +- `publish_location_name` (string) - Name of a writable ContentLibrary resource associated with namespace where the source VM will be published. + + diff --git a/docs/builders/index.mdx b/docs/builders/index.mdx index 2971dddd..0aa6b041 100644 --- a/docs/builders/index.mdx +++ b/docs/builders/index.mdx @@ -16,16 +16,17 @@ depending on the strategy you want to use to build the image: - [vsphere-iso](/packer/plugins/builders/vsphere/vsphere-iso) - This builder starts from an ISO file and utilizes the vSphere API to build on a remote esx instance. - This allows you to build vms even if you do not have SSH access to your vSphere cluster. + This allows you to build VMs even if you do not have SSH access to your vSphere cluster. - [vsphere-clone](/packer/plugins/builders/vsphere/vsphere-clone) - This builder clones a - vm from an existing template, then modifies it and saves it as a new + VM from an existing template, then modifies it and saves it as a new template. It uses the vSphere API to build on a remote esx instance. - This allows you to build vms even if you do not have SSH access to your vSphere cluster. + This allows you to build VMs even if you do not have SSH access to your vSphere cluster. - [vsphere-supervisor](/packer/plugins/builders/vsphere/vsphere-supervisor) - This builder deploys a - vm to a vSphere Supervisor cluster, using the VM-Service API. This allows you to build - vms without spec yaml files and configure them after using the Packer provisioners. + VM to a vSphere Supervisor cluster, utilizing VM Service. This allows you to build VMs without + spec yaml files, configure them after using the Packer provisioners, and publish them as new VM + images to a specified content library in vSphere. ## How to use this plugin diff --git a/docs/builders/vsphere-supervisor.mdx b/docs/builders/vsphere-supervisor.mdx index 99e2d506..59b402fb 100644 --- a/docs/builders/vsphere-supervisor.mdx +++ b/docs/builders/vsphere-supervisor.mdx @@ -2,7 +2,7 @@ modeline: | vim: set ft=pandoc: description: > - The Packer builder that deploys a VM-Service vm to a vSphere Supervisor cluster. + The Packer builder that deploys, customizes, and publishes a VM-Service VM to vSphere Supervisor. page_title: vSphere Supervisor - Builders sidebar_title: Supervisor --- @@ -12,13 +12,14 @@ sidebar_title: Supervisor Type: `vsphere-supervisor` Artifact BuilderId: `vsphere.supervisor` -This builder deploys new VMs to a vSphere Supervisor cluster. +This builder deploys and publishes new VMs to a vSphere Supervisor cluster using VM Service. +If you are new to VM Service, please refer to [Deploying and Managing Virtual Machines in vSphere with Tanzu +](https://docs.vmware.com/en/VMware-vSphere/7.0/vmware-vsphere-with-tanzu/GUID-F81E3535-C275-4DDE-B35F-CE759EA3B4A0.html) for more information. -- It uses kubeconfig file to connect to the vSphere Supervisor cluster. -- It uses the [VM-Service API](https://vm-operator.readthedocs.io/en/latest/concepts/) to deploy and configure the source VM. -- It uses the Packer provisioners to customize the VM after deployment. -- Planned enhancements that will introduce the ability to publish the - customized VM as a new VM image to the vSphere endpoint. +- It uses a kubeconfig file to connect to the vSphere Supervisor cluster. +- It uses the [VM-Operator API](https://vm-operator.readthedocs.io/en/latest/concepts/) to deploy and configure the source VM. +- It uses the Packer provisioners to customize the VM after establishing a successful connection. +- It publishes the customized VM as a new VM image to the designated content library in vSphere. - The builder supports versions following the VMware Product Lifecycle Matrix from General Availability to End of General Support. Builds on versions that are end of support may work, but configuration options may throw errors if @@ -53,7 +54,7 @@ build { "builders": [ { "type": "vsphere-supervisor", - "image_name": "" + "image_name": "", "class_name": "", "storage_class": "", "bootstrap_provider": "", @@ -77,7 +78,7 @@ There are various configuration options available for each step in this builder. ### Optional -#### Supervisor Cluster Connection +#### Supervisor Connection @include 'builder/vsphere/supervisor/ConnectSupervisorConfig-not-required.mdx' @@ -89,6 +90,10 @@ There are various configuration options available for each step in this builder. @include 'builder/vsphere/supervisor/WatchSourceConfig-not-required.mdx' +#### Source VM Publishing + +@include 'builder/vsphere/supervisor/PublishSourceConfig-not-required.mdx' + #### Communicator Configuration @include 'packer-plugin-sdk/communicator/SSH-not-required.mdx' @@ -99,7 +104,8 @@ There are various configuration options available for each step in this builder. ## Deprovisioning Tasks -If you would like to clean up the VM after the build is complete, you can use the Ansible provisioner to run the following tasks to delete machine-specific files and data. +If you would like to clean up the VM after the build is complete, you could use the Ansible +provisioner to run the following tasks to delete machine-specific files and data. diff --git a/go.mod b/go.mod index aa1d1fa3..190b5d7d 100644 --- a/go.mod +++ b/go.mod @@ -7,13 +7,16 @@ require ( github.com/hashicorp/hcl/v2 v2.13.0 github.com/hashicorp/packer-plugin-sdk v0.4.0 github.com/pkg/errors v0.9.1 + github.com/vmware-tanzu/image-registry-operator-api v0.0.0-20230523235530-62ec5758f097 github.com/vmware-tanzu/vm-operator/api v0.0.0-20230424164826-7ee71aebc7b1 github.com/vmware/govmomi v0.29.0 github.com/zclconf/go-cty v1.10.0 golang.org/x/mobile v0.0.0-20210901025245-1fde1d6c3ca1 + gopkg.in/yaml.v2 v2.4.0 k8s.io/api v0.26.1 k8s.io/apimachinery v0.26.1 k8s.io/client-go v0.26.1 + k8s.io/utils v0.0.0-20221128185143-99ec85e7a448 sigs.k8s.io/controller-runtime v0.14.6 ) @@ -128,13 +131,11 @@ require ( google.golang.org/protobuf v1.28.1 // indirect gopkg.in/inf.v0 v0.9.1 // indirect gopkg.in/square/go-jose.v2 v2.6.0 // indirect - gopkg.in/yaml.v2 v2.4.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect k8s.io/apiextensions-apiserver v0.26.1 // indirect k8s.io/component-base v0.26.1 // indirect k8s.io/klog/v2 v2.80.1 // indirect k8s.io/kube-openapi v0.0.0-20221012153701-172d655c2280 // indirect - k8s.io/utils v0.0.0-20221128185143-99ec85e7a448 // indirect sigs.k8s.io/json v0.0.0-20220713155537-f223a00ba0e2 // indirect sigs.k8s.io/structured-merge-diff/v4 v4.2.3 // indirect sigs.k8s.io/yaml v1.3.0 // indirect diff --git a/go.sum b/go.sum index a4cd9abe..6b00a55b 100644 --- a/go.sum +++ b/go.sum @@ -564,6 +564,8 @@ github.com/ulikunitz/xz v0.5.10/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0o github.com/urfave/cli v0.0.0-20171014202726-7bc6a0acffa5/go.mod h1:70zkFmudgCuE/ngEzBv17Jvp/497gISqfk5gWijbERA= github.com/vmihailenco/msgpack/v4 v4.3.12/go.mod h1:gborTTJjAo/GWTqqRjrLCn9pgNN+NXzzngzBKDPIqw4= github.com/vmihailenco/tagparser v0.1.1/go.mod h1:OeAg3pn3UbLjkWt+rN9oFYB6u/cQgqMEUPoW2WPyhdI= +github.com/vmware-tanzu/image-registry-operator-api v0.0.0-20230523235530-62ec5758f097 h1:QRNNClvbHlX+ZLYzeJpT8CbAaMvXbXfW9x0GMtenXHs= +github.com/vmware-tanzu/image-registry-operator-api v0.0.0-20230523235530-62ec5758f097/go.mod h1:S0HMBgdo3S/0a5hwq+Ya4XZI2aEDtGkSGeojU1cINOg= github.com/vmware-tanzu/vm-operator/api v0.0.0-20230424164826-7ee71aebc7b1 h1:krW4K3Vj8DkVqLMUOlW1OMLr1BY9Lg+PLZ7mAzlJz/A= github.com/vmware-tanzu/vm-operator/api v0.0.0-20230424164826-7ee71aebc7b1/go.mod h1:vauVboD3sQxP+pb28TnI9wfrj+0nH2zSEc9Q7AzWJ54= github.com/vmware/govmomi v0.29.0 h1:SHJQ7DUc4fltFZv16znJNGHR1/XhiDK5iKxm2OqwkuU=