From a5e4e73242318caf701a157dca0507f8b88e6a64 Mon Sep 17 00:00:00 2001 From: Gerhard Lausser Date: Thu, 14 May 2026 20:02:15 +0200 Subject: [PATCH 01/12] feat(deps): bump proxmox-api-go and adapt to new client + create-options API Pulls Telmate/proxmox-api-go forward from the Oct 2024 pin (b149708f) to the merged tip at f4e48ca. The upstream library has restructured most client methods to take context.Context, typed VM identifiers (GuestID/GuestName), reshaped QemuNetworkInterfaces/QemuPciDevices, and moved EFI/RNG/Tags handling to pointer-wrapped struct types. Every call site and adjacent test mock is adjusted accordingly; behavior is preserved. One observable difference: the post-create SetVmConfig fix-up that re-asserted EFIDisk on proxmox-clone builds is gone. The new Create path handles the EFI disk reliably, and the explicit retry is redundant. api-go's own go.mod declares go 1.26.2; `go mod tidy` cascades that floor into this module. Plugin CI will need to lift its Go matrix accordingly. --- builder/proxmox/clone/builder.go | 52 +-- .../proxmox/clone/step_map_source_disks.go | 16 +- builder/proxmox/common/artifact.go | 3 +- builder/proxmox/common/bootcommand_driver.go | 7 +- builder/proxmox/common/builder.go | 4 +- builder/proxmox/common/client.go | 17 +- builder/proxmox/common/client_test.go | 15 +- .../common/step_convert_to_template.go | 10 +- .../common/step_convert_to_template_test.go | 4 +- .../common/step_download_iso_on_pve.go | 6 +- .../common/step_finalize_template_config.go | 4 +- .../step_finalize_template_config_test.go | 2 +- .../common/step_remove_cloud_init_drive.go | 4 +- .../step_remove_cloud_init_drive_test.go | 2 +- builder/proxmox/common/step_start_vm.go | 310 +++++++++++------- builder/proxmox/common/step_start_vm_test.go | 90 ++--- .../proxmox/common/step_type_boot_command.go | 4 +- .../common/step_type_boot_command_test.go | 2 +- builder/proxmox/common/step_upload_iso.go | 10 +- .../proxmox/common/step_upload_iso_test.go | 4 +- builder/proxmox/iso/builder.go | 4 +- go.mod | 18 +- go.sum | 28 +- 23 files changed, 352 insertions(+), 264 deletions(-) diff --git a/builder/proxmox/clone/builder.go b/builder/proxmox/clone/builder.go index 5827dbd0..d9db4106 100644 --- a/builder/proxmox/clone/builder.go +++ b/builder/proxmox/clone/builder.go @@ -4,7 +4,8 @@ package proxmoxclone import ( - "crypto" + "context" + "fmt" "net/netip" "strings" @@ -13,9 +14,6 @@ import ( proxmox "github.com/hashicorp/packer-plugin-proxmox/builder/proxmox/common" "github.com/hashicorp/packer-plugin-sdk/multistep" packersdk "github.com/hashicorp/packer-plugin-sdk/packer" - - "context" - "fmt" ) // The unique id for the builder @@ -53,7 +51,7 @@ func (b *Builder) Run(ctx context.Context, ui packersdk.Ui, hook packersdk.Hook) type cloneVMCreator struct{} -func (*cloneVMCreator) Create(vmRef *proxmoxapi.VmRef, config proxmoxapi.ConfigQemu, state multistep.StateBag) error { +func (*cloneVMCreator) Create(ctx context.Context, config proxmoxapi.ConfigQemu, state multistep.StateBag) (*proxmoxapi.VmRef, error) { client := state.Get("proxmoxClient").(*proxmoxapi.Client) c := state.Get("clone-config").(*Config) comm := state.Get("config").(*proxmox.Config).Comm @@ -122,15 +120,17 @@ func (*cloneVMCreator) Create(vmRef *proxmoxapi.VmRef, config proxmoxapi.ConfigQ } } - var publicKey []crypto.PublicKey - - if comm.SSHPublicKey != nil { - publicKey = append(publicKey, crypto.PublicKey(string(comm.SSHPublicKey))) + var publicKeys []proxmoxapi.AuthorizedKey + if len(comm.SSHPublicKey) > 0 { + var key proxmoxapi.AuthorizedKey + if err := key.Parse(comm.SSHPublicKey); err == nil { + publicKeys = append(publicKeys, key) + } } config.CloudInit = &proxmoxapi.CloudInit{ Username: &comm.SSHUsername, - PublicSSHkeys: &publicKey, + PublicSSHkeys: &publicKeys, DNS: &proxmoxapi.GuestDNS{ NameServers: &nameServers, SearchDomain: &c.Searchdomain, @@ -140,33 +140,37 @@ func (*cloneVMCreator) Create(vmRef *proxmoxapi.VmRef, config proxmoxapi.ConfigQ var sourceVmr *proxmoxapi.VmRef if c.CloneVM != "" { - sourceVmrs, err := client.GetVmRefsByName(c.CloneVM) + sourceVmrs, err := client.GetVmRefsByName(ctx, proxmoxapi.GuestName(c.CloneVM)) if err != nil { - return err + return nil, err } // prefer source Vm located on same node sourceVmr = sourceVmrs[0] for _, candVmr := range sourceVmrs { - if candVmr.Node() == vmRef.Node() { + if config.Node != nil && candVmr.Node() == *config.Node { sourceVmr = candVmr } } } else if c.CloneVMID != 0 { - sourceVmr = proxmoxapi.NewVmRef(c.CloneVMID) - err := client.CheckVmRef(sourceVmr) - if err != nil { - return err + sourceVmr = proxmoxapi.NewVmRef(proxmoxapi.GuestID(c.CloneVMID)) + if err := client.CheckVmRef(ctx, sourceVmr); err != nil { + return nil, err } } - err := config.CloneVm(sourceVmr, vmRef, client) - if err != nil { - return err + vmRef := proxmoxapi.NewVmRef(0) + if config.ID != nil { + vmRef = proxmoxapi.NewVmRef(*config.ID) + } + if config.Node != nil { + vmRef.SetNode(string(*config.Node)) + } + if err := config.CloneVm(ctx, sourceVmr, vmRef, client); err != nil { + return nil, err } - _, err = config.Update(false, vmRef, client) - if err != nil { - return err + if _, err := config.Update(ctx, false, vmRef, client); err != nil { + return nil, err } - return nil + return vmRef, nil } diff --git a/builder/proxmox/clone/step_map_source_disks.go b/builder/proxmox/clone/step_map_source_disks.go index cb110a01..9547d4aa 100644 --- a/builder/proxmox/clone/step_map_source_disks.go +++ b/builder/proxmox/clone/step_map_source_disks.go @@ -23,9 +23,9 @@ import ( type StepMapSourceDisks struct{} type cloneSource interface { - GetVmConfig(*proxmoxapi.VmRef) (map[string]interface{}, error) - GetVmRefsByName(string) ([]*proxmoxapi.VmRef, error) - CheckVmRef(*proxmoxapi.VmRef) error + GetVmConfig(context.Context, *proxmoxapi.VmRef) (map[string]interface{}, error) + GetVmRefsByName(context.Context, proxmoxapi.GuestName) ([]*proxmoxapi.VmRef, error) + CheckVmRef(context.Context, *proxmoxapi.VmRef) error } var _ cloneSource = &proxmoxapi.Client{} @@ -37,7 +37,7 @@ func (s *StepMapSourceDisks) Run(ctx context.Context, state multistep.StateBag) var sourceVmr *proxmoxapi.VmRef if c.CloneVM != "" { - sourceVmrs, err := client.GetVmRefsByName(c.CloneVM) + sourceVmrs, err := client.GetVmRefsByName(ctx, proxmoxapi.GuestName(c.CloneVM)) if err != nil { state.Put("error", fmt.Errorf("Could not retrieve VM: %s", err)) return multistep.ActionHalt @@ -45,20 +45,20 @@ func (s *StepMapSourceDisks) Run(ctx context.Context, state multistep.StateBag) // prefer source Vm located on same node sourceVmr = sourceVmrs[0] for _, candVmr := range sourceVmrs { - if candVmr.Node() == c.Node { + if string(candVmr.Node()) == c.Node { sourceVmr = candVmr } } } else if c.CloneVMID != 0 { - sourceVmr = proxmoxapi.NewVmRef(c.CloneVMID) - err := client.CheckVmRef(sourceVmr) + sourceVmr = proxmoxapi.NewVmRef(proxmoxapi.GuestID(c.CloneVMID)) + err := client.CheckVmRef(ctx, sourceVmr) if err != nil { state.Put("error", fmt.Errorf("Could not retrieve VM: %s", err)) return multistep.ActionHalt } } - vmParams, err := client.GetVmConfig(sourceVmr) + vmParams, err := client.GetVmConfig(ctx, sourceVmr) if err != nil { err := fmt.Errorf("error fetching template config: %s", err) state.Put("error", err) diff --git a/builder/proxmox/common/artifact.go b/builder/proxmox/common/artifact.go index 8f9679a6..c6831e28 100644 --- a/builder/proxmox/common/artifact.go +++ b/builder/proxmox/common/artifact.go @@ -4,6 +4,7 @@ package proxmox import ( + "context" "fmt" "log" "strconv" @@ -47,6 +48,6 @@ func (a *Artifact) State(name string) interface{} { func (a *Artifact) Destroy() error { log.Printf("Destroying template: %d", a.templateID) - _, err := a.proxmoxClient.DeleteVm(proxmox.NewVmRef(a.templateID)) + _, err := a.proxmoxClient.DeleteVm(context.Background(), proxmox.NewVmRef(proxmox.GuestID(a.templateID))) return err } diff --git a/builder/proxmox/common/bootcommand_driver.go b/builder/proxmox/common/bootcommand_driver.go index cfcbcca7..eb4e7bb6 100644 --- a/builder/proxmox/common/bootcommand_driver.go +++ b/builder/proxmox/common/bootcommand_driver.go @@ -4,6 +4,7 @@ package proxmox import ( + "context" "fmt" "strings" "time" @@ -14,6 +15,7 @@ import ( ) type proxmoxDriver struct { + ctx context.Context client commandTyper vmRef *proxmox.VmRef specialMap map[string]string @@ -23,7 +25,7 @@ type proxmoxDriver struct { normalBuffer []string } -func NewProxmoxDriver(c commandTyper, vmRef *proxmox.VmRef, interval time.Duration) *proxmoxDriver { +func NewProxmoxDriver(ctx context.Context, c commandTyper, vmRef *proxmox.VmRef, interval time.Duration) *proxmoxDriver { // Mappings for packer shorthand to qemu qkeycodes sMap := map[string]string{ "spacebar": "spc", @@ -83,6 +85,7 @@ func NewProxmoxDriver(c commandTyper, vmRef *proxmox.VmRef, interval time.Durati } return &proxmoxDriver{ + ctx: ctx, client: c, vmRef: vmRef, specialMap: sMap, @@ -134,7 +137,7 @@ func (p *proxmoxDriver) send(key string) error { keys := append(p.specialBuffer, p.normalBuffer...) keys = append(keys, key) keyEventString := bufferToKeyEvent(keys) - err := p.client.Sendkey(p.vmRef, keyEventString) + err := p.client.Sendkey(p.ctx, p.vmRef, keyEventString) if err != nil { return err } diff --git a/builder/proxmox/common/builder.go b/builder/proxmox/common/builder.go index cebcc9cb..e4986a03 100644 --- a/builder/proxmox/common/builder.go +++ b/builder/proxmox/common/builder.go @@ -37,7 +37,7 @@ type Builder struct { func (b *Builder) Run(ctx context.Context, ui packersdk.Ui, hook packersdk.Hook, state multistep.StateBag) (packersdk.Artifact, error) { var err error - b.proxmoxClient, err = newProxmoxClient(b.config) + b.proxmoxClient, err = newProxmoxClient(ctx, b.config) if err != nil { return nil, err } @@ -152,7 +152,7 @@ func getVMIP(state multistep.StateBag) (string, error) { config := state.Get("config").(*Config) vmRef := state.Get("vmRef").(*proxmox.VmRef) - ifs, err := client.GetVmAgentNetworkInterfaces(vmRef) + ifs, err := client.GetVmAgentNetworkInterfaces(context.Background(), vmRef) if err != nil { return "", err } diff --git a/builder/proxmox/common/client.go b/builder/proxmox/common/client.go index e7235059..2a0f86c3 100644 --- a/builder/proxmox/common/client.go +++ b/builder/proxmox/common/client.go @@ -4,6 +4,7 @@ package proxmox import ( + "context" "crypto/tls" "log" "strings" @@ -11,26 +12,26 @@ import ( "github.com/Telmate/proxmox-api-go/proxmox" ) -func newProxmoxClient(config Config) (*proxmox.Client, error) { +func newProxmoxClient(ctx context.Context, config Config) (*proxmox.Client, error) { tlsConfig := &tls.Config{ InsecureSkipVerify: config.SkipCertValidation, } - client, err := proxmox.NewClient(strings.TrimSuffix(config.proxmoxURL.String(), "/"), nil, "", tlsConfig, "", int(config.TaskTimeout.Seconds())) + client, err := proxmox.NewClient(strings.TrimSuffix(config.proxmoxURL.String(), "/"), nil, "", tlsConfig, "", int(config.TaskTimeout.Seconds()), config.PackerDebug) if err != nil { return nil, err } - *proxmox.Debug = config.PackerDebug - if config.Token != "" { - // configure token auth log.Print("using token auth") - client.SetAPIToken(config.Username, config.Token) + var tokenID proxmox.ApiTokenID + if err := tokenID.Parse(config.Username); err != nil { + return nil, err + } + client.SetAPIToken(tokenID, proxmox.ApiTokenSecret(config.Token)) } else { - // fallback to login if not using tokens log.Print("using password auth") - err = client.Login(config.Username, config.Password, "") + err = client.Login(ctx, config.Username, config.Password, "") if err != nil { return nil, err } diff --git a/builder/proxmox/common/client_test.go b/builder/proxmox/common/client_test.go index c93fb589..e9053dd7 100644 --- a/builder/proxmox/common/client_test.go +++ b/builder/proxmox/common/client_test.go @@ -4,6 +4,7 @@ package proxmox import ( + "context" "encoding/json" "io/ioutil" "net/http" @@ -33,13 +34,14 @@ func TestTokenAuth(t *testing.T) { Token: "ac5293bf-15e2-477f-b04c-a6dfa7a46b80", } - client, err := newProxmoxClient(config) + ctx := context.Background() + client, err := newProxmoxClient(ctx, config) require.NoError(t, err) ref := proxmox.NewVmRef(110) ref.SetNode("node1") - ref.SetVmType("qemu") - err = client.Sendkey(ref, "ping") + ref.SetVmType(proxmox.GuestQemu) + err = client.Sendkey(ctx, ref, "ping") require.NoError(t, err) } @@ -83,12 +85,13 @@ func TestLogin(t *testing.T) { Token: "", } - client, err := newProxmoxClient(config) + ctx := context.Background() + client, err := newProxmoxClient(ctx, config) require.NoError(t, err) ref := proxmox.NewVmRef(110) ref.SetNode("node1") - ref.SetVmType("qemu") - err = client.Sendkey(ref, "ping") + ref.SetVmType(proxmox.GuestQemu) + err = client.Sendkey(ctx, ref, "ping") require.NoError(t, err) } diff --git a/builder/proxmox/common/step_convert_to_template.go b/builder/proxmox/common/step_convert_to_template.go index cb5fa98f..fd8337de 100644 --- a/builder/proxmox/common/step_convert_to_template.go +++ b/builder/proxmox/common/step_convert_to_template.go @@ -20,8 +20,8 @@ import ( type stepConvertToTemplate struct{} type templateConverter interface { - ShutdownVm(*proxmox.VmRef) (string, error) - CreateTemplate(*proxmox.VmRef) error + ShutdownVm(context.Context, *proxmox.VmRef) (string, error) + CreateTemplate(context.Context, *proxmox.VmRef) error } var _ templateConverter = &proxmox.Client{} @@ -32,7 +32,7 @@ func (s *stepConvertToTemplate) Run(ctx context.Context, state multistep.StateBa vmRef := state.Get("vmRef").(*proxmox.VmRef) ui.Say("Stopping VM") - _, err := client.ShutdownVm(vmRef) + _, err := client.ShutdownVm(ctx, vmRef) if err != nil { err := fmt.Errorf("Error converting VM to template, could not stop: %s", err) state.Put("error", err) @@ -41,7 +41,7 @@ func (s *stepConvertToTemplate) Run(ctx context.Context, state multistep.StateBa } ui.Say("Converting VM to template") - err = client.CreateTemplate(vmRef) + err = client.CreateTemplate(ctx, vmRef) if err != nil { err := fmt.Errorf("Error converting VM to template: %s", err) state.Put("error", err) @@ -49,7 +49,7 @@ func (s *stepConvertToTemplate) Run(ctx context.Context, state multistep.StateBa return multistep.ActionHalt } log.Printf("template_id: %d", vmRef.VmId()) - state.Put("template_id", vmRef.VmId()) + state.Put("template_id", int(vmRef.VmId())) return multistep.ActionContinue } diff --git a/builder/proxmox/common/step_convert_to_template_test.go b/builder/proxmox/common/step_convert_to_template_test.go index 5bdbf38a..5b205dac 100644 --- a/builder/proxmox/common/step_convert_to_template_test.go +++ b/builder/proxmox/common/step_convert_to_template_test.go @@ -18,10 +18,10 @@ type converterMock struct { createTemplate func(*proxmox.VmRef) error } -func (m converterMock) ShutdownVm(r *proxmox.VmRef) (string, error) { +func (m converterMock) ShutdownVm(_ context.Context, r *proxmox.VmRef) (string, error) { return m.shutdownVm(r) } -func (m converterMock) CreateTemplate(r *proxmox.VmRef) error { +func (m converterMock) CreateTemplate(_ context.Context, r *proxmox.VmRef) error { return m.createTemplate(r) } diff --git a/builder/proxmox/common/step_download_iso_on_pve.go b/builder/proxmox/common/step_download_iso_on_pve.go index bdb3f127..9b61effa 100644 --- a/builder/proxmox/common/step_download_iso_on_pve.go +++ b/builder/proxmox/common/step_download_iso_on_pve.go @@ -25,7 +25,7 @@ type stepDownloadISOOnPVE struct { func (s *stepDownloadISOOnPVE) Run(ctx context.Context, state multistep.StateBag) multistep.StepAction { var isoStoragePath string - isoStoragePath, err := DownloadISOOnPVE(state, s.ISO.ISOUrls, s.ISO.ISOChecksum, s.ISO.ISOStoragePool) + isoStoragePath, err := DownloadISOOnPVE(ctx, state, s.ISO.ISOUrls, s.ISO.ISOChecksum, s.ISO.ISOStoragePool) // Abort if no ISO can be downloaded if err != nil { @@ -44,7 +44,7 @@ func (s *stepDownloadISOOnPVE) Run(ctx context.Context, state multistep.StateBag // If a download was successful it skips the additonal downlaod mirrors and returns the path to the iso on the node. // // Returns: When successful, the path to the iso on the node, else an empty string. -func DownloadISOOnPVE(state multistep.StateBag, ISOUrls []string, ISOChecksum string, ISOStoragePool string) (string, error) { +func DownloadISOOnPVE(ctx context.Context, state multistep.StateBag, ISOUrls []string, ISOChecksum string, ISOStoragePool string) (string, error) { ui := state.Get("ui").(packersdk.Ui) client := state.Get("proxmoxClient").(*proxmox.Client) c := state.Get("config").(*Config) @@ -80,7 +80,7 @@ func DownloadISOOnPVE(state multistep.StateBag, ISOUrls []string, ISOChecksum st } log.Printf("[INFO] - beginning download of %s to node %s", isoConfig.DownloadUrl, isoConfig.Node) - err := proxmox.DownloadIsoFromUrl(client, isoConfig) + err := proxmox.DownloadIsoFromUrl(ctx, client, isoConfig) // On error continues with the next URL and logs the error if err != nil { log.Printf("[ERROR] - failed to download iso from %s: %s", isoConfig.DownloadUrl, err) diff --git a/builder/proxmox/common/step_finalize_template_config.go b/builder/proxmox/common/step_finalize_template_config.go index b36e60ae..797f1387 100644 --- a/builder/proxmox/common/step_finalize_template_config.go +++ b/builder/proxmox/common/step_finalize_template_config.go @@ -20,7 +20,7 @@ import ( type stepFinalizeTemplateConfig struct{} type templateFinalizer interface { - GetVmConfig(*proxmox.VmRef) (map[string]interface{}, error) + GetVmConfig(context.Context, *proxmox.VmRef) (map[string]interface{}, error) SetVmConfig(*proxmox.VmRef, map[string]interface{}) (interface{}, error) } @@ -43,7 +43,7 @@ func (s *stepFinalizeTemplateConfig) Run(ctx context.Context, state multistep.St // set, we need to clear it changes["description"] = c.TemplateDescription - vmParams, err := client.GetVmConfig(vmRef) + vmParams, err := client.GetVmConfig(ctx, vmRef) if err != nil { err := fmt.Errorf("error fetching template config: %s", err) state.Put("error", err) diff --git a/builder/proxmox/common/step_finalize_template_config_test.go b/builder/proxmox/common/step_finalize_template_config_test.go index 1d03dbad..c3cfd06d 100644 --- a/builder/proxmox/common/step_finalize_template_config_test.go +++ b/builder/proxmox/common/step_finalize_template_config_test.go @@ -20,7 +20,7 @@ type finalizerMock struct { setConfig func(map[string]interface{}) (string, error) } -func (m finalizerMock) GetVmConfig(*proxmox.VmRef) (map[string]interface{}, error) { +func (m finalizerMock) GetVmConfig(context.Context, *proxmox.VmRef) (map[string]interface{}, error) { return m.getConfig() } func (m finalizerMock) SetVmConfig(vmref *proxmox.VmRef, c map[string]interface{}) (interface{}, error) { diff --git a/builder/proxmox/common/step_remove_cloud_init_drive.go b/builder/proxmox/common/step_remove_cloud_init_drive.go index 6f4d482b..58e874e7 100644 --- a/builder/proxmox/common/step_remove_cloud_init_drive.go +++ b/builder/proxmox/common/step_remove_cloud_init_drive.go @@ -18,7 +18,7 @@ import ( type stepRemoveCloudInitDrive struct{} type CloudInitDriveRemover interface { - GetVmConfig(*proxmox.VmRef) (map[string]interface{}, error) + GetVmConfig(context.Context, *proxmox.VmRef) (map[string]interface{}, error) SetVmConfig(*proxmox.VmRef, map[string]interface{}) (interface{}, error) } @@ -32,7 +32,7 @@ func (s *stepRemoveCloudInitDrive) Run(ctx context.Context, state multistep.Stat changes := make(map[string]interface{}) delete := []string{} - vmParams, err := client.GetVmConfig(vmRef) + vmParams, err := client.GetVmConfig(ctx, vmRef) if err != nil { err := fmt.Errorf("error fetching template config: %s", err) state.Put("error", err) diff --git a/builder/proxmox/common/step_remove_cloud_init_drive_test.go b/builder/proxmox/common/step_remove_cloud_init_drive_test.go index 557d2f8c..b1d04fb9 100644 --- a/builder/proxmox/common/step_remove_cloud_init_drive_test.go +++ b/builder/proxmox/common/step_remove_cloud_init_drive_test.go @@ -17,7 +17,7 @@ type CloudInitDriveRemoverMock struct { setConfig func(map[string]interface{}) (string, error) } -func (m CloudInitDriveRemoverMock) GetVmConfig(*proxmox.VmRef) (map[string]interface{}, error) { +func (m CloudInitDriveRemoverMock) GetVmConfig(context.Context, *proxmox.VmRef) (map[string]interface{}, error) { return m.getConfig() } func (m CloudInitDriveRemoverMock) SetVmConfig(vmref *proxmox.VmRef, c map[string]interface{}) (interface{}, error) { diff --git a/builder/proxmox/common/step_start_vm.go b/builder/proxmox/common/step_start_vm.go index 6e0dbccd..9eeb7930 100644 --- a/builder/proxmox/common/step_start_vm.go +++ b/builder/proxmox/common/step_start_vm.go @@ -7,10 +7,12 @@ import ( "context" "fmt" "log" + "net" "reflect" "slices" "strconv" "strings" + "time" "github.com/Telmate/proxmox-api-go/proxmox" "github.com/hashicorp/packer-plugin-sdk/multistep" @@ -27,16 +29,16 @@ type stepStartVM struct { } type ProxmoxVMCreator interface { - Create(*proxmox.VmRef, proxmox.ConfigQemu, multistep.StateBag) error + Create(context.Context, proxmox.ConfigQemu, multistep.StateBag) (*proxmox.VmRef, error) } type vmStarter interface { - CheckVmRef(vmr *proxmox.VmRef) (err error) - DeleteVm(vmr *proxmox.VmRef) (exitStatus string, err error) - GetNextID(int) (int, error) - GetVmConfig(vmr *proxmox.VmRef) (vmConfig map[string]interface{}, err error) - GetVmRefsByName(vmName string) (vmrs []*proxmox.VmRef, err error) + CheckVmRef(ctx context.Context, vmr *proxmox.VmRef) (err error) + DeleteVm(ctx context.Context, vmr *proxmox.VmRef) (exitStatus string, err error) + GetNextID(ctx context.Context, startID *proxmox.GuestID) (proxmox.GuestID, error) + GetVmConfig(ctx context.Context, vmr *proxmox.VmRef) (vmConfig map[string]interface{}, err error) + GetVmRefsByName(ctx context.Context, vmName proxmox.GuestName) (vmrs []*proxmox.VmRef, err error) SetVmConfig(*proxmox.VmRef, map[string]interface{}) (interface{}, error) - StartVm(*proxmox.VmRef) (string, error) + StartVm(ctx context.Context, vmr *proxmox.VmRef) (string, error) } var ( @@ -45,12 +47,12 @@ var ( // Check if the given builder configuration maps to an existing VM template on the Proxmox cluster. // Returns an empty *proxmox.VmRef when no matching ID or name is found. -func getExistingTemplate(c *Config, client vmStarter) (*proxmox.VmRef, error) { +func getExistingTemplate(ctx context.Context, c *Config, client vmStarter) (*proxmox.VmRef, error) { vmRef := &proxmox.VmRef{} if c.VMID > 0 { log.Printf("looking up VM with ID %d", c.VMID) - vmRef = proxmox.NewVmRef(c.VMID) - err := client.CheckVmRef(vmRef) + vmRef = proxmox.NewVmRef(proxmox.GuestID(c.VMID)) + err := client.CheckVmRef(ctx, vmRef) if err != nil { // expect an error if no VM is found // the error string is defined in GetVmInfo() of proxmox-api-go @@ -64,7 +66,7 @@ func getExistingTemplate(c *Config, client vmStarter) (*proxmox.VmRef, error) { log.Printf("found VM with ID %d", vmRef.VmId()) } else { log.Printf("looking up VMs with name '%s'", c.TemplateName) - vmRefs, err := client.GetVmRefsByName(c.TemplateName) + vmRefs, err := client.GetVmRefsByName(ctx, proxmox.GuestName(c.TemplateName)) if err != nil { // expect an error if no VMs are found // the error string is defined in GetVmRefsByName() of proxmox-api-go @@ -76,7 +78,7 @@ func getExistingTemplate(c *Config, client vmStarter) (*proxmox.VmRef, error) { return &proxmox.VmRef{}, err } if len(vmRefs) > 1 { - vmIDs := []int{} + vmIDs := []proxmox.GuestID{} for _, vmr := range vmRefs { vmIDs = append(vmIDs, vmr.VmId()) } @@ -86,7 +88,7 @@ func getExistingTemplate(c *Config, client vmStarter) (*proxmox.VmRef, error) { log.Printf("found VM with name '%s' (ID: %d)", c.TemplateName, vmRef.VmId()) } log.Printf("check if VM %d is a template", vmRef.VmId()) - vmConfig, err := client.GetVmConfig(vmRef) + vmConfig, err := client.GetVmConfig(ctx, vmRef) if err != nil { return &proxmox.VmRef{}, err } @@ -120,9 +122,11 @@ func (s *stepStartVM) Run(ctx context.Context, state multistep.StateBag) multist } var description = "Packer ephemeral build VM" + name := proxmox.GuestName(c.VMName) + pool := proxmox.PoolName(c.Pool) config := proxmox.ConfigQemu{ - Name: c.VMName, + Name: &name, Agent: generateAgentConfig(c.Agent), QemuKVM: &kvm, Tags: generateTags(c.Tags), @@ -137,21 +141,21 @@ func (s *stepStartVM) Run(ctx context.Context, state multistep.StateBag) multist Memory: &proxmox.QemuMemory{ CapacityMiB: (*proxmox.QemuMemoryCapacity)(&c.Memory), }, - QemuOs: c.OS, - Bios: c.BIOS, - EFIDisk: generateProxmoxEfi(c.EFIConfig), - Machine: c.Machine, - RNGDrive: generateProxmoxRng0(c.Rng0), - TPM: generateProxmoxTpm(c.TPMConfig), - QemuVga: generateProxmoxVga(c.VGA), - QemuNetworks: generateProxmoxNetworkAdapters(c.NICs), - Disks: disks, - QemuPCIDevices: generateProxmoxPCIDeviceMap(c.PCIDevices), - Serials: generateProxmoxSerials(c.Serials), - Scsihw: c.SCSIController, - Onboot: &c.Onboot, - Args: c.AdditionalArgs, - Pool: (*proxmox.PoolName)(&c.Pool), + QemuOs: c.OS, + Bios: c.BIOS, + EfiDisk: generateProxmoxEfi(c.EFIConfig), + Machine: c.Machine, + RandomnessDevice: generateProxmoxRng0(c.Rng0), + TPM: generateProxmoxTpm(c.TPMConfig), + QemuVga: generateProxmoxVga(c.VGA), + Networks: generateProxmoxNetworkAdapters(c.NICs), + Disks: disks, + PciDevices: generateProxmoxPCIDeviceMap(c.PCIDevices), + Serials: generateProxmoxSerials(c.Serials), + Scsihw: c.SCSIController, + StartAtNodeBoot: &c.Onboot, + Args: c.AdditionalArgs, + Pool: &pool, } // 0 disables the ballooning device, which is useful for all VMs @@ -163,7 +167,7 @@ func (s *stepStartVM) Run(ctx context.Context, state multistep.StateBag) multist if c.PackerForce { ui.Say("Force set, checking for existing artifact on PVE cluster") - vmRef, err := getExistingTemplate(c, client) + vmRef, err := getExistingTemplate(ctx, c, client) if err != nil { state.Put("error", err) ui.Error(err.Error()) @@ -171,7 +175,7 @@ func (s *stepStartVM) Run(ctx context.Context, state multistep.StateBag) multist } if vmRef.VmId() != 0 { ui.Say(fmt.Sprintf("found existing VM template with ID %d on PVE node %s, deleting it", vmRef.VmId(), vmRef.Node())) - _, err = client.DeleteVm(vmRef) + _, err = client.DeleteVm(ctx, vmRef) if err != nil { state.Put("error", err) ui.Error(fmt.Sprintf("error deleting VM template: %s", err.Error())) @@ -184,27 +188,26 @@ func (s *stepStartVM) Run(ctx context.Context, state multistep.StateBag) multist } ui.Say("Creating VM") + node := proxmox.NodeName(c.Node) + config.Node = &node var vmRef *proxmox.VmRef for i := 1; ; i++ { - id := c.VMID - if id == 0 { + if c.VMID > 0 { + id := proxmox.GuestID(c.VMID) + config.ID = &id + } else { ui.Say("No VM ID given, getting next free from Proxmox") - genID, err := client.GetNextID(0) + genID, err := client.GetNextID(ctx, nil) if err != nil { state.Put("error", err) ui.Error(err.Error()) return multistep.ActionHalt } - id = genID - config.VmID = genID - } - vmRef = proxmox.NewVmRef(id) - vmRef.SetNode(c.Node) - if c.Pool != "" { - vmRef.SetPool(c.Pool) + config.ID = &genID } - err := s.vmCreator.Create(vmRef, config, state) + var err error + vmRef, err = s.vmCreator.Create(ctx, config, state) if err == nil { break } @@ -222,30 +225,16 @@ func (s *stepStartVM) Run(ctx context.Context, state multistep.StateBag) multist return multistep.ActionHalt } - // The EFI disk doesn't get created reliably when using the clone builder, - // so let's make sure it's there. - if c.EFIConfig != (efiConfig{}) && c.Ctx.BuildType == "proxmox-clone" { - addEFIConfig := make(map[string]interface{}) - config.CreateQemuEfiParams(addEFIConfig) - _, err := client.SetVmConfig(vmRef, addEFIConfig) - if err != nil { - err := fmt.Errorf("error updating template: %s", err) - state.Put("error", err) - ui.Error(err.Error()) - return multistep.ActionHalt - } - } - // Store the vm id for later state.Put("vmRef", vmRef) // instance_id is the generic term used so that users can have access to the // instance id inside of the provisioners, used in step_provision. // Note that this is just the VMID, we do not keep the node, pool and other // info available in the vmref type. - state.Put("instance_id", vmRef.VmId()) + state.Put("instance_id", int(vmRef.VmId())) ui.Say("Starting VM") - _, err := client.StartVm(vmRef) + _, err := client.StartVm(ctx, vmRef) if err != nil { err := fmt.Errorf("Error starting VM: %s", err) state.Put("error", err) @@ -268,39 +257,54 @@ func generateAgentConfig(agent config.Trilean) *proxmox.QemuGuestAgent { } } -func generateTags(rawTags string) *[]proxmox.Tag { - tags := make([]proxmox.Tag, 0) +func generateTags(rawTags string) *proxmox.Tags { + tags := proxmox.Tags{} if rawTags == "" { return &tags } - tagArray := strings.Split(rawTags, ";") - for _, tag := range tagArray { + for _, tag := range strings.Split(rawTags, ";") { tags = append(tags, proxmox.Tag(tag)) } return &tags } -func generateProxmoxNetworkAdapters(nics []NICConfig) proxmox.QemuDevices { - devs := make(proxmox.QemuDevices) - for idx := range nics { - devs[idx] = make(proxmox.QemuDevice) - setDeviceParamIfDefined(devs[idx], "model", nics[idx].Model) - setDeviceParamIfDefined(devs[idx], "macaddr", nics[idx].MACAddress) - setDeviceParamIfDefined(devs[idx], "bridge", nics[idx].Bridge) - setDeviceParamIfDefined(devs[idx], "tag", nics[idx].VLANTag) - - if nics[idx].Firewall { - devs[idx]["firewall"] = nics[idx].Firewall +func generateProxmoxNetworkAdapters(nics []NICConfig) proxmox.QemuNetworkInterfaces { + out := make(proxmox.QemuNetworkInterfaces, len(nics)) + for idx, n := range nics { + iface := proxmox.QemuNetworkInterface{} + if n.Model != "" { + model := proxmox.QemuNetworkModel(n.Model) + iface.Model = &model } - - if nics[idx].MTU > 0 { - devs[idx]["mtu"] = nics[idx].MTU + if n.MACAddress != "" { + if hw, err := net.ParseMAC(n.MACAddress); err == nil { + iface.MAC = &hw + } } - if nics[idx].PacketQueues > 0 { - devs[idx]["queues"] = nics[idx].PacketQueues + if n.Bridge != "" { + bridge := n.Bridge + iface.Bridge = &bridge } + if n.VLANTag != "" { + if v, err := strconv.Atoi(n.VLANTag); err == nil { + vlan := proxmox.Vlan(v) + iface.NativeVlan = &vlan + } + } + if n.Firewall { + fw := true + iface.Firewall = &fw + } + if n.MTU > 0 { + iface.MTU = &proxmox.QemuMTU{Value: proxmox.MTU(n.MTU)} + } + if n.PacketQueues > 0 { + q := proxmox.QemuNetworkQueue(n.PacketQueues) + iface.MultiQueue = &q + } + out[proxmox.QemuNetworkInterfaceID(idx)] = iface } - return devs + return out } func generateProxmoxDisks(disks []diskConfig, isos []ISOsConfig, cloneSourceDisks []string) (*packersdk.MultiError, []string, *proxmox.QemuStorages) { @@ -680,25 +684,71 @@ func generateProxmoxDisks(disks []diskConfig, isos []ISOsConfig, cloneSourceDisk return errs, warnings, &qemuStorages } -func generateProxmoxPCIDeviceMap(devices []pciDeviceConfig) proxmox.QemuDevices { - devs := make(proxmox.QemuDevices) - for idx := range devices { - devs[idx] = make(proxmox.QemuDevice) - setDeviceParamIfDefined(devs[idx], "host", devices[idx].Host) - setDeviceParamIfDefined(devs[idx], "device-id", devices[idx].DeviceID) - setDeviceParamIfDefined(devs[idx], "mapping", devices[idx].Mapping) - setDeviceParamIfDefined(devs[idx], "mdev", devices[idx].MDEV) - setDeviceParamIfDefined(devs[idx], "romfile", devices[idx].ROMFile) - setDeviceParamIfDefined(devs[idx], "sub-device-id", devices[idx].SubDeviceID) - setDeviceParamIfDefined(devs[idx], "sub-vendor-id", devices[idx].SubVendorID) - setDeviceParamIfDefined(devs[idx], "vendor-id", devices[idx].VendorID) - - devs[idx]["pcie"] = strconv.FormatBool(devices[idx].PCIe) - devs[idx]["rombar"] = strconv.FormatBool(!devices[idx].HideROMBAR) - devs[idx]["x-vga"] = strconv.FormatBool(devices[idx].XVGA) - devs[idx]["legacy-igd"] = strconv.FormatBool(devices[idx].LegacyIGD) +func generateProxmoxPCIDeviceMap(devices []pciDeviceConfig) proxmox.QemuPciDevices { + out := make(proxmox.QemuPciDevices, len(devices)) + for idx, d := range devices { + pcie := d.PCIe + primary := d.XVGA + rombar := !d.HideROMBAR + var mdev *proxmox.PciMediatedDevice + if d.MDEV != "" { + m := proxmox.PciMediatedDevice(d.MDEV) + mdev = &m + } + var deviceID *proxmox.PciDeviceID + if d.DeviceID != "" { + id := proxmox.PciDeviceID(d.DeviceID) + deviceID = &id + } + var subDeviceID *proxmox.PciSubDeviceID + if d.SubDeviceID != "" { + id := proxmox.PciSubDeviceID(d.SubDeviceID) + subDeviceID = &id + } + var subVendorID *proxmox.PciSubVendorID + if d.SubVendorID != "" { + id := proxmox.PciSubVendorID(d.SubVendorID) + subVendorID = &id + } + var vendorID *proxmox.PciVendorID + if d.VendorID != "" { + id := proxmox.PciVendorID(d.VendorID) + vendorID = &id + } + var entry proxmox.QemuPci + if d.Mapping != "" { + mappingID := proxmox.ResourceMappingPciID(d.Mapping) + entry.Mapping = &proxmox.QemuPciMapping{ + ID: &mappingID, + PCIe: &pcie, + PrimaryGPU: &primary, + ROMbar: &rombar, + MDev: mdev, + DeviceID: deviceID, + SubDeviceID: subDeviceID, + SubVendorID: subVendorID, + VendorID: vendorID, + } + } else { + raw := &proxmox.QemuPciRaw{ + PCIe: &pcie, + PrimaryGPU: &primary, + ROMbar: &rombar, + MDev: mdev, + DeviceID: deviceID, + SubDeviceID: subDeviceID, + SubVendorID: subVendorID, + VendorID: vendorID, + } + if d.Host != "" { + rawID := proxmox.PciID(d.Host) + raw.ID = &rawID + } + entry.Raw = raw + } + out[proxmox.QemuPciID(idx)] = entry } - return devs + return out } func generateProxmoxSerials(serials []string) proxmox.SerialInterfaces { @@ -718,17 +768,26 @@ func generateProxmoxSerials(serials []string) proxmox.SerialInterfaces { return devs } -func generateProxmoxRng0(rng0 rng0Config) proxmox.QemuDevice { - dev := make(proxmox.QemuDevice) - setDeviceParamIfDefined(dev, "source", rng0.Source) - - if rng0.MaxBytes >= 0 { - dev["max_bytes"] = rng0.MaxBytes +func generateProxmoxRng0(rng0 rng0Config) *proxmox.VirtIoRNG { + if rng0 == (rng0Config{}) { + return nil + } + out := &proxmox.VirtIoRNG{} + if rng0.Source != "" { + var source proxmox.EntropySource + if err := source.Parse(rng0.Source); err == nil { + out.Source = &source + } + } + if rng0.MaxBytes > 0 { + limit := uint(rng0.MaxBytes) + out.Limit = &limit } if rng0.Period > 0 { - dev["period"] = rng0.Period + period := time.Duration(rng0.Period) * time.Millisecond + out.Period = &period } - return dev + return out } func generateProxmoxVga(vga vgaConfig) proxmox.QemuDevice { @@ -741,21 +800,26 @@ func generateProxmoxVga(vga vgaConfig) proxmox.QemuDevice { return dev } -func generateProxmoxEfi(efi efiConfig) proxmox.QemuDevice { - dev := make(proxmox.QemuDevice) - setDeviceParamIfDefined(dev, "storage", efi.EFIStoragePool) - setDeviceParamIfDefined(dev, "efitype", efi.EFIType) - setDeviceParamIfDefined(dev, "format", efi.EFIFormat) - // efi.PreEnrolledKeys can be false, but we only want to set pre-enrolled-keys=0 - // when other EFI options are set. - if len(dev) > 0 { - if efi.PreEnrolledKeys { - dev["pre-enrolled-keys"] = "1" - } else { - dev["pre-enrolled-keys"] = "0" - } +func generateProxmoxEfi(efi efiConfig) *proxmox.EfiDisk { + if efi == (efiConfig{}) { + return nil } - return dev + out := &proxmox.EfiDisk{} + if efi.EFIStoragePool != "" { + storage := proxmox.StorageName(efi.EFIStoragePool) + out.Storage = &storage + } + if efi.EFIType != "" { + efiType := proxmox.EfiDiskType(efi.EFIType) + out.Type = &efiType + } + if efi.EFIFormat != "" { + format := proxmox.QemuDiskFormat(efi.EFIFormat) + out.Format = &format + } + preEnrolled := efi.PreEnrolledKeys + out.PreEnrolledKeys = &preEnrolled + return out } func generateProxmoxTpm(tpm tpmConfig) *proxmox.TpmState { @@ -783,8 +847,8 @@ func isDuplicateIDError(err error) bool { } type startedVMCleaner interface { - StopVm(*proxmox.VmRef) (string, error) - DeleteVm(*proxmox.VmRef) (string, error) + StopVm(context.Context, *proxmox.VmRef) (string, error) + DeleteVm(context.Context, *proxmox.VmRef) (string, error) } var _ startedVMCleaner = &proxmox.Client{} @@ -808,14 +872,14 @@ func (s *stepStartVM) Cleanup(state multistep.StateBag) { // Destroy the server we just created ui.Say("Stopping VM") - _, err := client.StopVm(vmRef) + _, err := client.StopVm(context.Background(), vmRef) if err != nil { ui.Error(fmt.Sprintf("Error stopping VM. Please stop and delete it manually: %s", err)) return } ui.Say("Deleting VM") - _, err = client.DeleteVm(vmRef) + _, err = client.DeleteVm(context.Background(), vmRef) if err != nil { ui.Error(fmt.Sprintf("Error deleting VM. Please delete it manually: %s", err)) return diff --git a/builder/proxmox/common/step_start_vm_test.go b/builder/proxmox/common/step_start_vm_test.go index 86f75b00..ced61631 100644 --- a/builder/proxmox/common/step_start_vm_test.go +++ b/builder/proxmox/common/step_start_vm_test.go @@ -20,10 +20,10 @@ type startedVMCleanerMock struct { deleteVm func() (string, error) } -func (m startedVMCleanerMock) StopVm(*proxmox.VmRef) (string, error) { +func (m startedVMCleanerMock) StopVm(context.Context, *proxmox.VmRef) (string, error) { return m.stopVm() } -func (m startedVMCleanerMock) DeleteVm(*proxmox.VmRef) (string, error) { +func (m startedVMCleanerMock) DeleteVm(context.Context, *proxmox.VmRef) (string, error) { return m.deleteVm() } @@ -114,38 +114,42 @@ func TestCleanupStartVM(t *testing.T) { } type startVMMock struct { - create func(*proxmox.VmRef, proxmox.ConfigQemu, multistep.StateBag) error + create func(proxmox.ConfigQemu, multistep.StateBag) (*proxmox.VmRef, error) startVm func(*proxmox.VmRef) (string, error) setVmConfig func(*proxmox.VmRef, map[string]interface{}) (interface{}, error) - getNextID func(id int) (int, error) + getNextID func(id proxmox.GuestID) (proxmox.GuestID, error) getVmConfig func(vmr *proxmox.VmRef) (vmConfig map[string]interface{}, err error) checkVmRef func(vmr *proxmox.VmRef) (err error) - getVmByName func(vmName string) (vmrs []*proxmox.VmRef, err error) + getVmByName func(vmName proxmox.GuestName) (vmrs []*proxmox.VmRef, err error) deleteVm func(vmr *proxmox.VmRef) (exitStatus string, err error) } -func (m *startVMMock) Create(vmRef *proxmox.VmRef, config proxmox.ConfigQemu, state multistep.StateBag) error { - return m.create(vmRef, config, state) +func (m *startVMMock) Create(_ context.Context, config proxmox.ConfigQemu, state multistep.StateBag) (*proxmox.VmRef, error) { + return m.create(config, state) } -func (m *startVMMock) StartVm(vmRef *proxmox.VmRef) (string, error) { +func (m *startVMMock) StartVm(_ context.Context, vmRef *proxmox.VmRef) (string, error) { return m.startVm(vmRef) } func (m *startVMMock) SetVmConfig(vmRef *proxmox.VmRef, config map[string]interface{}) (interface{}, error) { return m.setVmConfig(vmRef, config) } -func (m *startVMMock) GetNextID(id int) (int, error) { +func (m *startVMMock) GetNextID(_ context.Context, startID *proxmox.GuestID) (proxmox.GuestID, error) { + var id proxmox.GuestID + if startID != nil { + id = *startID + } return m.getNextID(id) } -func (m *startVMMock) GetVmConfig(vmr *proxmox.VmRef) (map[string]interface{}, error) { +func (m *startVMMock) GetVmConfig(_ context.Context, vmr *proxmox.VmRef) (map[string]interface{}, error) { return m.getVmConfig(vmr) } -func (m *startVMMock) CheckVmRef(vmr *proxmox.VmRef) (err error) { +func (m *startVMMock) CheckVmRef(_ context.Context, vmr *proxmox.VmRef) (err error) { return m.checkVmRef(vmr) } -func (m *startVMMock) GetVmRefsByName(vmName string) (vmrs []*proxmox.VmRef, err error) { +func (m *startVMMock) GetVmRefsByName(_ context.Context, vmName proxmox.GuestName) (vmrs []*proxmox.VmRef, err error) { return m.getVmByName(vmName) } -func (m *startVMMock) DeleteVm(vmr *proxmox.VmRef) (exitStatus string, err error) { +func (m *startVMMock) DeleteVm(_ context.Context, vmr *proxmox.VmRef) (exitStatus string, err error) { return m.deleteVm(vmr) } @@ -183,8 +187,8 @@ func TestStartVM(t *testing.T) { for _, c := range cs { t.Run(c.name, func(t *testing.T) { mock := &startVMMock{ - create: func(vmRef *proxmox.VmRef, config proxmox.ConfigQemu, state multistep.StateBag) error { - return nil + create: func(config proxmox.ConfigQemu, state multistep.StateBag) (*proxmox.VmRef, error) { + return proxmox.NewVmRef(1), nil }, startVm: func(*proxmox.VmRef) (string, error) { return "", nil @@ -192,7 +196,7 @@ func TestStartVM(t *testing.T) { setVmConfig: func(*proxmox.VmRef, map[string]interface{}) (interface{}, error) { return nil, nil }, - getNextID: func(id int) (int, error) { + getNextID: func(id proxmox.GuestID) (proxmox.GuestID, error) { return 1, nil }, } @@ -211,41 +215,41 @@ func TestStartVM(t *testing.T) { } func TestStartVMRetryOnDuplicateID(t *testing.T) { - newDuplicateError := func(id int) error { + newDuplicateError := func(id proxmox.GuestID) error { return fmt.Errorf("unable to create VM %d - VM %d already exists on node 'test'", id, id) } cs := []struct { name string config *Config - createErrorGenerator func(id int) error + createErrorGenerator func(id proxmox.GuestID) error expectedCallsToCreate int expectedAction multistep.StepAction }{ { name: "Succeed immediately if non-duplicate", config: &Config{}, - createErrorGenerator: func(id int) error { return nil }, + createErrorGenerator: func(id proxmox.GuestID) error { return nil }, expectedCallsToCreate: 1, expectedAction: multistep.ActionContinue, }, { name: "Fail immediately if duplicate and VMID explicitly configured", config: &Config{VMID: 1}, - createErrorGenerator: func(id int) error { return newDuplicateError(id) }, + createErrorGenerator: func(id proxmox.GuestID) error { return newDuplicateError(id) }, expectedCallsToCreate: 1, expectedAction: multistep.ActionHalt, }, { name: "Fail immediately if error not caused by duplicate ID", config: &Config{}, - createErrorGenerator: func(id int) error { return fmt.Errorf("Something else went wrong") }, + createErrorGenerator: func(id proxmox.GuestID) error { return fmt.Errorf("Something else went wrong") }, expectedCallsToCreate: 1, expectedAction: multistep.ActionHalt, }, { name: "Retry if error caused by duplicate ID", config: &Config{}, - createErrorGenerator: func(id int) error { + createErrorGenerator: func(id proxmox.GuestID) error { if id < 2 { return newDuplicateError(id) } @@ -257,7 +261,7 @@ func TestStartVMRetryOnDuplicateID(t *testing.T) { { name: "Retry only up to maxDuplicateIDRetries times", config: &Config{}, - createErrorGenerator: func(id int) error { + createErrorGenerator: func(id proxmox.GuestID) error { return newDuplicateError(id) }, expectedCallsToCreate: maxDuplicateIDRetries, @@ -269,9 +273,16 @@ func TestStartVMRetryOnDuplicateID(t *testing.T) { t.Run(c.name, func(t *testing.T) { createCalls := 0 mock := &startVMMock{ - create: func(vmRef *proxmox.VmRef, config proxmox.ConfigQemu, state multistep.StateBag) error { + create: func(config proxmox.ConfigQemu, state multistep.StateBag) (*proxmox.VmRef, error) { createCalls++ - return c.createErrorGenerator(vmRef.VmId()) + var id proxmox.GuestID + if config.ID != nil { + id = *config.ID + } + if err := c.createErrorGenerator(id); err != nil { + return nil, err + } + return proxmox.NewVmRef(id), nil }, startVm: func(*proxmox.VmRef) (string, error) { return "", nil @@ -279,8 +290,8 @@ func TestStartVMRetryOnDuplicateID(t *testing.T) { setVmConfig: func(*proxmox.VmRef, map[string]interface{}) (interface{}, error) { return nil, nil }, - getNextID: func(id int) (int, error) { - return createCalls + 1, nil + getNextID: func(id proxmox.GuestID) (proxmox.GuestID, error) { + return proxmox.GuestID(createCalls + 1), nil }, } state := new(multistep.BasicStateBag) @@ -306,7 +317,7 @@ func TestStartVMWithForce(t *testing.T) { config *Config expectedCallToDelete bool expectedAction multistep.StepAction - mockGetVmRefsByName func(vmName string) (vmrs []*proxmox.VmRef, err error) + mockGetVmRefsByName func(vmName proxmox.GuestName) (vmrs []*proxmox.VmRef, err error) mockGetVmConfig func(vmr *proxmox.VmRef) (map[string]interface{}, error) }{ { @@ -362,7 +373,7 @@ func TestStartVMWithForce(t *testing.T) { }, expectedCallToDelete: false, expectedAction: multistep.ActionHalt, - mockGetVmRefsByName: func(vmName string) (vmrs []*proxmox.VmRef, err error) { + mockGetVmRefsByName: func(vmName proxmox.GuestName) (vmrs []*proxmox.VmRef, err error) { return []*proxmox.VmRef{ proxmox.NewVmRef(100), proxmox.NewVmRef(101), @@ -382,7 +393,7 @@ func TestStartVMWithForce(t *testing.T) { }, expectedCallToDelete: true, expectedAction: multistep.ActionContinue, - mockGetVmRefsByName: func(vmName string) (vmrs []*proxmox.VmRef, err error) { + mockGetVmRefsByName: func(vmName proxmox.GuestName) (vmrs []*proxmox.VmRef, err error) { return []*proxmox.VmRef{ proxmox.NewVmRef(100), }, nil @@ -397,8 +408,8 @@ func TestStartVMWithForce(t *testing.T) { t.Run(c.name, func(t *testing.T) { deleteWasCalled := false mock := &startVMMock{ - create: func(vmRef *proxmox.VmRef, config proxmox.ConfigQemu, state multistep.StateBag) error { - return nil + create: func(config proxmox.ConfigQemu, state multistep.StateBag) (*proxmox.VmRef, error) { + return proxmox.NewVmRef(101), nil }, startVm: func(*proxmox.VmRef) (string, error) { return "", nil @@ -406,13 +417,13 @@ func TestStartVMWithForce(t *testing.T) { setVmConfig: func(*proxmox.VmRef, map[string]interface{}) (interface{}, error) { return nil, nil }, - getNextID: func(id int) (int, error) { + getNextID: func(id proxmox.GuestID) (proxmox.GuestID, error) { return 101, nil }, checkVmRef: func(vmr *proxmox.VmRef) (err error) { return nil }, - getVmByName: func(vmName string) (vmrs []*proxmox.VmRef, err error) { + getVmByName: func(vmName proxmox.GuestName) (vmrs []*proxmox.VmRef, err error) { return c.mockGetVmRefsByName(vmName) }, getVmConfig: func(vmr *proxmox.VmRef) (config map[string]interface{}, err error) { @@ -459,7 +470,10 @@ func TestStartVM_AssertInitialQuemuConfig(t *testing.T) { }, }, assertQemuConfig: func(t *testing.T, config proxmox.ConfigQemu) { - assert.Equal(t, "true", config.QemuPCIDevices[0]["rombar"]) + dev := config.PciDevices[proxmox.QemuPciID(0)] + if dev.Raw == nil || dev.Raw.ROMbar == nil || !*dev.Raw.ROMbar { + t.Errorf("expected ROMbar to be true (HideROMBAR=false inverted), got %#v", dev) + } }, }, } @@ -469,15 +483,15 @@ func TestStartVM_AssertInitialQuemuConfig(t *testing.T) { startVMWasCalled := false qemuConfig := proxmox.ConfigQemu{} mock := &startVMMock{ - create: func(vmRef *proxmox.VmRef, config proxmox.ConfigQemu, state multistep.StateBag) error { + create: func(config proxmox.ConfigQemu, state multistep.StateBag) (*proxmox.VmRef, error) { qemuConfig = config - return nil + return proxmox.NewVmRef(101), nil }, startVm: func(*proxmox.VmRef) (string, error) { startVMWasCalled = true return "", nil }, - getNextID: func(id int) (int, error) { + getNextID: func(id proxmox.GuestID) (proxmox.GuestID, error) { return 101, nil }, } diff --git a/builder/proxmox/common/step_type_boot_command.go b/builder/proxmox/common/step_type_boot_command.go index af3137aa..909010f8 100644 --- a/builder/proxmox/common/step_type_boot_command.go +++ b/builder/proxmox/common/step_type_boot_command.go @@ -31,7 +31,7 @@ type bootCommandTemplateData struct { } type commandTyper interface { - Sendkey(*proxmox.VmRef, string) error + Sendkey(context.Context, *proxmox.VmRef, string) error } var _ commandTyper = &proxmox.Client{} @@ -77,7 +77,7 @@ func (s *stepTypeBootCommand) Run(ctx context.Context, state multistep.StateBag) } ui.Say("Typing the boot command") - d := NewProxmoxDriver(client, vmRef, c.BootKeyInterval) + d := NewProxmoxDriver(ctx, client, vmRef, c.BootKeyInterval) command, err := interpolate.Render(s.FlatBootCommand(), &s.Ctx) if err != nil { err := fmt.Errorf("Error preparing boot command: %s", err) diff --git a/builder/proxmox/common/step_type_boot_command_test.go b/builder/proxmox/common/step_type_boot_command_test.go index a69581ec..ab9a355d 100644 --- a/builder/proxmox/common/step_type_boot_command_test.go +++ b/builder/proxmox/common/step_type_boot_command_test.go @@ -19,7 +19,7 @@ type commandTyperMock struct { sendkey func(*proxmox.VmRef, string) error } -func (m commandTyperMock) Sendkey(ref *proxmox.VmRef, cmd string) error { +func (m commandTyperMock) Sendkey(_ context.Context, ref *proxmox.VmRef, cmd string) error { return m.sendkey(ref, cmd) } diff --git a/builder/proxmox/common/step_upload_iso.go b/builder/proxmox/common/step_upload_iso.go index 4564244d..f1506f72 100644 --- a/builder/proxmox/common/step_upload_iso.go +++ b/builder/proxmox/common/step_upload_iso.go @@ -21,8 +21,8 @@ type stepUploadISO struct { } type uploader interface { - Upload(node string, storage string, contentType string, filename string, file io.Reader) error - DeleteVolume(vmr *proxmoxapi.VmRef, storageName string, volumeName string) (exitStatus interface{}, err error) + Upload(ctx context.Context, node string, storage string, contentType string, filename string, file io.Reader) error + DeleteVolume(ctx context.Context, vmr *proxmoxapi.VmRef, storageName string, volumeName string) (exitStatus interface{}, err error) } var _ uploader = &proxmoxapi.Client{} @@ -72,7 +72,7 @@ func (s *stepUploadISO) Run(ctx context.Context, state multistep.StateBag) multi } filename := filepath.Base(isoPath) - err = client.Upload(c.Node, s.ISO.ISOStoragePool, "iso", filename, r) + err = client.Upload(ctx, c.Node, s.ISO.ISOStoragePool, "iso", filename, r) if err != nil { state.Put("error", err) ui.Error(err.Error()) @@ -100,9 +100,9 @@ func (s *stepUploadISO) Cleanup(state multistep.StateBag) { // Fake a VM reference, DeleteVolume just needs the node to be valid vmRef := &proxmoxapi.VmRef{} vmRef.SetNode(c.Node) - vmRef.SetVmType("qemu") + vmRef.SetVmType(proxmoxapi.GuestQemu) - _, err := client.DeleteVolume(vmRef, s.ISO.ISOStoragePool, s.ISO.ISOFile) + _, err := client.DeleteVolume(context.Background(), vmRef, s.ISO.ISOStoragePool, s.ISO.ISOFile) if err != nil { state.Put("error", err) ui.Error(fmt.Sprintf("delete volume failed: %s", err.Error())) diff --git a/builder/proxmox/common/step_upload_iso_test.go b/builder/proxmox/common/step_upload_iso_test.go index 7f4f94a5..c74ec8eb 100644 --- a/builder/proxmox/common/step_upload_iso_test.go +++ b/builder/proxmox/common/step_upload_iso_test.go @@ -22,7 +22,7 @@ type uploaderMock struct { deleteWasCalled bool } -func (m *uploaderMock) Upload(node string, storage string, contentType string, filename string, file io.Reader) error { +func (m *uploaderMock) Upload(_ context.Context, node string, storage string, contentType string, filename string, file io.Reader) error { m.uploadWasCalled = true if m.uploadFail { return fmt.Errorf("Testing induced Upload failure") @@ -30,7 +30,7 @@ func (m *uploaderMock) Upload(node string, storage string, contentType string, f return nil } -func (m *uploaderMock) DeleteVolume(vmr *proxmox.VmRef, storageName string, volumeName string) (exitStatus interface{}, err error) { +func (m *uploaderMock) DeleteVolume(_ context.Context, vmr *proxmox.VmRef, storageName string, volumeName string) (exitStatus interface{}, err error) { m.deleteWasCalled = true if m.deleteFail { return nil, fmt.Errorf("Testing induced DeleteVolume failure") diff --git a/builder/proxmox/iso/builder.go b/builder/proxmox/iso/builder.go index e9f52a2d..6a316020 100644 --- a/builder/proxmox/iso/builder.go +++ b/builder/proxmox/iso/builder.go @@ -49,7 +49,7 @@ func (b *Builder) Run(ctx context.Context, ui packersdk.Ui, hook packersdk.Hook) type isoVMCreator struct{} -func (*isoVMCreator) Create(vmRef *proxmoxapi.VmRef, config proxmoxapi.ConfigQemu, state multistep.StateBag) error { +func (*isoVMCreator) Create(ctx context.Context, config proxmoxapi.ConfigQemu, state multistep.StateBag) (*proxmoxapi.VmRef, error) { client := state.Get("proxmoxClient").(*proxmoxapi.Client) - return config.Create(vmRef, client) + return config.Create(ctx, client) } diff --git a/go.mod b/go.mod index 690a846c..412cad70 100644 --- a/go.mod +++ b/go.mod @@ -1,11 +1,9 @@ module github.com/hashicorp/packer-plugin-proxmox -go 1.23.0 - -toolchain go1.23.7 +go 1.26.2 require ( - github.com/Telmate/proxmox-api-go v0.0.0-20241022204517-b149708f750b + github.com/Telmate/proxmox-api-go v0.0.0-20260513185805-f4e48ca8d9d6 github.com/hashicorp/go-getter/v2 v2.2.2 github.com/hashicorp/hcl/v2 v2.19.1 github.com/hashicorp/packer-plugin-sdk v0.6.1 @@ -85,15 +83,15 @@ require ( github.com/vmihailenco/msgpack/v5 v5.3.5 // indirect github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect go.opencensus.io v0.24.0 // indirect - golang.org/x/crypto v0.36.0 // indirect + golang.org/x/crypto v0.45.0 // indirect golang.org/x/exp v0.0.0-20230321023759-10a507213a29 // indirect golang.org/x/mobile v0.0.0-20210901025245-1fde1d6c3ca1 // indirect - golang.org/x/net v0.37.0 // indirect + golang.org/x/net v0.47.0 // indirect golang.org/x/oauth2 v0.13.0 // indirect - golang.org/x/sync v0.12.0 // indirect - golang.org/x/sys v0.31.0 // indirect - golang.org/x/term v0.30.0 // indirect - golang.org/x/text v0.23.0 // indirect + golang.org/x/sync v0.18.0 // indirect + golang.org/x/sys v0.38.0 // indirect + golang.org/x/term v0.37.0 // indirect + golang.org/x/text v0.31.0 // indirect golang.org/x/time v0.11.0 // indirect golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 // indirect google.golang.org/api v0.150.0 // indirect diff --git a/go.sum b/go.sum index aca62709..1e6dbea8 100644 --- a/go.sum +++ b/go.sum @@ -18,8 +18,8 @@ github.com/ChrisTrenkamp/goxpath v0.0.0-20170922090931-c385f95c6022/go.mod h1:nu github.com/ChrisTrenkamp/goxpath v0.0.0-20210404020558-97928f7e12b6 h1:w0E0fgc1YafGEh5cROhlROMWXiNoZqApk2PDN0M1+Ns= github.com/ChrisTrenkamp/goxpath v0.0.0-20210404020558-97928f7e12b6/go.mod h1:nuWgzSkT5PnyOd+272uUmV0dnAnAn42Mk7PiQC5VzN4= github.com/DataDog/datadog-go v3.2.0+incompatible/go.mod h1:LButxg5PwREeZtORoXG3tL4fMGNddJ+vMq1mwgfaqoQ= -github.com/Telmate/proxmox-api-go v0.0.0-20241022204517-b149708f750b h1:vgK9CbUZpd37RYab6lCEQTyP0fw/Ygq5NOWctBI5rpA= -github.com/Telmate/proxmox-api-go v0.0.0-20241022204517-b149708f750b/go.mod h1:Gu6n6vEn1hlyFUkjrvU+X1fdgaSXLoM9HKYYJqy1fsY= +github.com/Telmate/proxmox-api-go v0.0.0-20260513185805-f4e48ca8d9d6 h1:+5WnYfKG0a4tPc46Jk6vAYQ0TRQ+AAUBQw9R3G7rzEA= +github.com/Telmate/proxmox-api-go v0.0.0-20260513185805-f4e48ca8d9d6/go.mod h1:90hDRcdB9vqdHdtkKje50NGkGbphbGKhtefqezo/Bgc= github.com/agext/levenshtein v1.2.3 h1:YB2fHEn0UJagG8T1rrWknE3ZQzWM06O8AMAatNn7lmo= github.com/agext/levenshtein v1.2.3/go.mod h1:JEDfjyjHDjOF/1e4FlBE/PkbqA9OfWu2ki2W0IB5558= github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= @@ -352,8 +352,8 @@ golang.org/x/crypto v0.0.0-20190923035154-9ee001bba392/go.mod h1:/lpIB1dKB+9EgE3 golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4= -golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34= -golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc= +golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q= +golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190731235908-ec7cb31e5a56/go.mod h1:JhuoJpWY28nO4Vef9tZUw9qufEGTyX1+7lmHxV5q5G4= golang.org/x/exp v0.0.0-20230321023759-10a507213a29 h1:ooxPy7fPvB4kwsA2h+iBNHkAbp/4JxTSwCmvdjEYmug= @@ -383,8 +383,8 @@ golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= golang.org/x/net v0.0.0-20210410081132-afb366fc7cd1/go.mod h1:9tjilg8BloeKEkVJvy7fQ90B1CfIiPueXVOjqfkSzI8= golang.org/x/net v0.0.0-20220127200216-cd36cc0744dd/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= -golang.org/x/net v0.37.0 h1:1zLorHbz+LYj7MQlSf1+2tPIIgibq2eL5xkrGk6f+2c= -golang.org/x/net v0.37.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8= +golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= +golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.13.0 h1:jDDenyj+WgFtmV3zYVoi8aE2BwtXFLWOA67ZfNWftiY= golang.org/x/oauth2 v0.13.0/go.mod h1:/JMhi4ZRXAf4HG9LiNmxvk+45+96RUlVThiH8FzNBn0= @@ -394,8 +394,8 @@ golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw= -golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I= +golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= @@ -424,19 +424,19 @@ golang.org/x/sys v0.0.0-20220503163025-988cb79eb6c6/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220728004956-3c1f35247d10/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= -golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= +golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= -golang.org/x/term v0.30.0 h1:PQ39fJZ+mfadBm0y5WlL4vlM7Sx1Hgf13sMIY2+QS9Y= -golang.org/x/term v0.30.0/go.mod h1:NYYFdzHoI5wRh/h5tDMdMqCqPJZEuNqVR5xJLd/n67g= +golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU= +golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= -golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= -golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= +golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= +golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= golang.org/x/time v0.11.0 h1:/bpjEDfN9tkoN/ryeYHnv5hcMlc8ncjMcM4XBk5NWV0= golang.org/x/time v0.11.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= From d8aaf530bedba7e558aad9a1b7f6110b04d9cf95 Mon Sep 17 00:00:00 2001 From: Gerhard Lausser Date: Thu, 14 May 2026 20:18:46 +0200 Subject: [PATCH 02/12] feat(builder): add arch field with x86_64 / aarch64 / host-default values Adds a new `arch` HCL field on the proxmox-iso and proxmox-clone builders that selects the QEMU CPU architecture for the created VM template. Accepted values: `""` (host default), `"x86_64"`, `"aarch64"`. Invalid values are rejected at validate time before any API call is made. This commit lands the field, the validation whitelist, and propagation into the api-go CreateOptions on the initial POST. Companion validation (bios/efi_config/vga/serials requirements when arch=aarch64) and arch-aware defaults follow in the next two commits. --- builder/proxmox/clone/config.hcl2spec.go | 2 + builder/proxmox/common/config.go | 31 ++++++++++++ builder/proxmox/common/config.hcl2spec.go | 2 + builder/proxmox/common/config_test.go | 48 +++++++++++++++++++ builder/proxmox/common/step_start_vm.go | 10 ++++ builder/proxmox/iso/config.hcl2spec.go | 2 + .../proxmox/common/Config-not-required.mdx | 21 ++++++++ 7 files changed, 116 insertions(+) diff --git a/builder/proxmox/clone/config.hcl2spec.go b/builder/proxmox/clone/config.hcl2spec.go index 86547e24..c787582c 100644 --- a/builder/proxmox/clone/config.hcl2spec.go +++ b/builder/proxmox/clone/config.hcl2spec.go @@ -99,6 +99,7 @@ type FlatConfig struct { Numa *bool `mapstructure:"numa" cty:"numa" hcl:"numa"` OS *string `mapstructure:"os" cty:"os" hcl:"os"` BIOS *string `mapstructure:"bios" cty:"bios" hcl:"bios"` + Arch *string `mapstructure:"arch" cty:"arch" hcl:"arch"` EFIConfig *proxmox.FlatefiConfig `mapstructure:"efi_config" cty:"efi_config" hcl:"efi_config"` EFIDisk *string `mapstructure:"efidisk" cty:"efidisk" hcl:"efidisk"` Machine *string `mapstructure:"machine" cty:"machine" hcl:"machine"` @@ -229,6 +230,7 @@ func (*FlatConfig) HCL2Spec() map[string]hcldec.Spec { "numa": &hcldec.AttrSpec{Name: "numa", Type: cty.Bool, Required: false}, "os": &hcldec.AttrSpec{Name: "os", Type: cty.String, Required: false}, "bios": &hcldec.AttrSpec{Name: "bios", Type: cty.String, Required: false}, + "arch": &hcldec.AttrSpec{Name: "arch", Type: cty.String, Required: false}, "efi_config": &hcldec.BlockSpec{TypeName: "efi_config", Nested: hcldec.ObjectSpec((*proxmox.FlatefiConfig)(nil).HCL2Spec())}, "efidisk": &hcldec.AttrSpec{Name: "efidisk", Type: cty.String, Required: false}, "machine": &hcldec.AttrSpec{Name: "machine", Type: cty.String, Required: false}, diff --git a/builder/proxmox/common/config.go b/builder/proxmox/common/config.go index 0aa43688..bd0011e5 100644 --- a/builder/proxmox/common/config.go +++ b/builder/proxmox/common/config.go @@ -128,6 +128,27 @@ type Config struct { OS string `mapstructure:"os"` // Set the machine bios. This can be set to ovmf or seabios. The default value is seabios. BIOS string `mapstructure:"bios"` + // The CPU architecture for the VM. Allowed values: `""` (host default), + // `"x86_64"`, `"aarch64"`. Default: `""`. + // + // When set to `"aarch64"`, four arch-aware defaults activate (each is + // overridden by setting the corresponding field explicitly): + // + // - The default `boot_iso.type` changes from `"ide"` (which the QEMU + // `virt` machine type does not expose) to `"scsi"`. + // - The default `additional_iso_files[*].type` changes from `"ide"` to + // `"scsi"`. + // - The default `cpu_type` changes from `"kvm64"` (x86-only) to + // `"cortex-a57"`. + // - When `qemu_additional_args` is empty, the plugin injects + // `-device qemu-xhci -device usb-kbd` so that `boot_command` + // keystrokes reach the guest. The `virt` machine type ships without + // a keyboard; QMP `send-key` is a silent no-op without one. + // Supplying *any* `qemu_additional_args` disables this auto-inject. + // + // See [Building aarch64 templates](#building-aarch64-arm64-templates) + // for the full set of companion fields required and a worked example. + Arch string `mapstructure:"arch"` // Set the efidisk storage options. See [EFI Config](#efi-config). EFIConfig efiConfig `mapstructure:"efi_config"` // This option is deprecated, please use `efi_config` instead. @@ -914,6 +935,16 @@ func (c *Config) Prepare(upper interface{}, raws ...interface{}) ([]string, []st errs = packersdk.MultiErrorAppend(errs, errors.New("efi_storage_pool not set for efi_config")) } } + + // FR-005 — arch whitelist. + switch c.Arch { + case "", "x86_64", "aarch64": + default: + errs = packersdk.MultiErrorAppend(errs, fmt.Errorf( + "arch must be one of %q, %q, %q, got %q", + "", "x86_64", "aarch64", c.Arch)) + } + if c.TPMConfig != (tpmConfig{}) { if c.TPMConfig.TPMStoragePool == "" { errs = packersdk.MultiErrorAppend(errs, errors.New("tpm_storage_pool not set for tpm_config")) diff --git a/builder/proxmox/common/config.hcl2spec.go b/builder/proxmox/common/config.hcl2spec.go index 0743154b..80caf64e 100644 --- a/builder/proxmox/common/config.hcl2spec.go +++ b/builder/proxmox/common/config.hcl2spec.go @@ -98,6 +98,7 @@ type FlatConfig struct { Numa *bool `mapstructure:"numa" cty:"numa" hcl:"numa"` OS *string `mapstructure:"os" cty:"os" hcl:"os"` BIOS *string `mapstructure:"bios" cty:"bios" hcl:"bios"` + Arch *string `mapstructure:"arch" cty:"arch" hcl:"arch"` EFIConfig *FlatefiConfig `mapstructure:"efi_config" cty:"efi_config" hcl:"efi_config"` EFIDisk *string `mapstructure:"efidisk" cty:"efidisk" hcl:"efidisk"` Machine *string `mapstructure:"machine" cty:"machine" hcl:"machine"` @@ -222,6 +223,7 @@ func (*FlatConfig) HCL2Spec() map[string]hcldec.Spec { "numa": &hcldec.AttrSpec{Name: "numa", Type: cty.Bool, Required: false}, "os": &hcldec.AttrSpec{Name: "os", Type: cty.String, Required: false}, "bios": &hcldec.AttrSpec{Name: "bios", Type: cty.String, Required: false}, + "arch": &hcldec.AttrSpec{Name: "arch", Type: cty.String, Required: false}, "efi_config": &hcldec.BlockSpec{TypeName: "efi_config", Nested: hcldec.ObjectSpec((*FlatefiConfig)(nil).HCL2Spec())}, "efidisk": &hcldec.AttrSpec{Name: "efidisk", Type: cty.String, Required: false}, "machine": &hcldec.AttrSpec{Name: "machine", Type: cty.String, Required: false}, diff --git a/builder/proxmox/common/config_test.go b/builder/proxmox/common/config_test.go index b1d41f0b..71aa0540 100644 --- a/builder/proxmox/common/config_test.go +++ b/builder/proxmox/common/config_test.go @@ -712,3 +712,51 @@ func TestPCIDeviceMapping(t *testing.T) { }) } } + +// validAarch64Config returns a config map satisfying every hard requirement +// for arch="aarch64" so each arch-aarch64 test can tweak one field in +// isolation. +func validAarch64Config(t *testing.T) map[string]interface{} { + cfg := mandatoryConfig(t) + cfg["arch"] = "aarch64" + cfg["bios"] = "ovmf" + cfg["efi_config"] = map[string]interface{}{ + "efi_storage_pool": "local-lvm", + "efi_type": "4m", + "pre_enrolled_keys": true, + } + cfg["vga"] = map[string]interface{}{"type": "serial0"} + cfg["serials"] = []string{"socket"} + return cfg +} + +func TestArch_Whitelist(t *testing.T) { + cases := []struct { + arch string + expectedError string + }{ + {arch: "", expectedError: ""}, + {arch: "x86_64", expectedError: ""}, + {arch: "aarch64", expectedError: ""}, + {arch: "riscv64", expectedError: `arch must be one of "", "x86_64", "aarch64", got "riscv64"`}, + {arch: "ARM64", expectedError: `arch must be one of "", "x86_64", "aarch64", got "ARM64"`}, + {arch: "amd64", expectedError: `arch must be one of "", "x86_64", "aarch64", got "amd64"`}, + } + for _, tc := range cases { + t.Run(fmt.Sprintf("arch=%q", tc.arch), func(t *testing.T) { + cfg := validAarch64Config(t) + cfg["arch"] = tc.arch + var c Config + _, _, err := c.Prepare(&c, cfg) + if tc.expectedError == "" { + if err != nil && strings.Contains(err.Error(), "arch must be one of") { + t.Errorf("expected arch %q to be accepted, got %s", tc.arch, err.Error()) + } + return + } + if err == nil || !strings.Contains(err.Error(), tc.expectedError) { + t.Errorf("expected error %q, got %v", tc.expectedError, err) + } + }) + } +} diff --git a/builder/proxmox/common/step_start_vm.go b/builder/proxmox/common/step_start_vm.go index 9eeb7930..4b152829 100644 --- a/builder/proxmox/common/step_start_vm.go +++ b/builder/proxmox/common/step_start_vm.go @@ -142,6 +142,7 @@ func (s *stepStartVM) Run(ctx context.Context, state multistep.StateBag) multist CapacityMiB: (*proxmox.QemuMemoryCapacity)(&c.Memory), }, QemuOs: c.OS, + CreateOptions: generateProxmoxCreateOptions(c.Arch), Bios: c.BIOS, EfiDisk: generateProxmoxEfi(c.EFIConfig), Machine: c.Machine, @@ -800,6 +801,15 @@ func generateProxmoxVga(vga vgaConfig) proxmox.QemuDevice { return dev } +// arch is a create-only Proxmox setting; a post-create PUT silently fails and leaves the VM unbootable. +func generateProxmoxCreateOptions(arch string) *proxmox.QemuCreateOptions { + if arch == "" { + return nil + } + cpuArch := proxmox.CpuArchitecture(arch) + return &proxmox.QemuCreateOptions{Architecture: &cpuArch} +} + func generateProxmoxEfi(efi efiConfig) *proxmox.EfiDisk { if efi == (efiConfig{}) { return nil diff --git a/builder/proxmox/iso/config.hcl2spec.go b/builder/proxmox/iso/config.hcl2spec.go index 15d8bb6c..9e610011 100644 --- a/builder/proxmox/iso/config.hcl2spec.go +++ b/builder/proxmox/iso/config.hcl2spec.go @@ -99,6 +99,7 @@ type FlatConfig struct { Numa *bool `mapstructure:"numa" cty:"numa" hcl:"numa"` OS *string `mapstructure:"os" cty:"os" hcl:"os"` BIOS *string `mapstructure:"bios" cty:"bios" hcl:"bios"` + Arch *string `mapstructure:"arch" cty:"arch" hcl:"arch"` EFIConfig *proxmox.FlatefiConfig `mapstructure:"efi_config" cty:"efi_config" hcl:"efi_config"` EFIDisk *string `mapstructure:"efidisk" cty:"efidisk" hcl:"efidisk"` Machine *string `mapstructure:"machine" cty:"machine" hcl:"machine"` @@ -233,6 +234,7 @@ func (*FlatConfig) HCL2Spec() map[string]hcldec.Spec { "numa": &hcldec.AttrSpec{Name: "numa", Type: cty.Bool, Required: false}, "os": &hcldec.AttrSpec{Name: "os", Type: cty.String, Required: false}, "bios": &hcldec.AttrSpec{Name: "bios", Type: cty.String, Required: false}, + "arch": &hcldec.AttrSpec{Name: "arch", Type: cty.String, Required: false}, "efi_config": &hcldec.BlockSpec{TypeName: "efi_config", Nested: hcldec.ObjectSpec((*proxmox.FlatefiConfig)(nil).HCL2Spec())}, "efidisk": &hcldec.AttrSpec{Name: "efidisk", Type: cty.String, Required: false}, "machine": &hcldec.AttrSpec{Name: "machine", Type: cty.String, Required: false}, diff --git a/docs-partials/builder/proxmox/common/Config-not-required.mdx b/docs-partials/builder/proxmox/common/Config-not-required.mdx index 83de1cde..b07ae2af 100644 --- a/docs-partials/builder/proxmox/common/Config-not-required.mdx +++ b/docs-partials/builder/proxmox/common/Config-not-required.mdx @@ -76,6 +76,27 @@ - `bios` (string) - Set the machine bios. This can be set to ovmf or seabios. The default value is seabios. +- `arch` (string) - The CPU architecture for the VM. Allowed values: `""` (host default), + `"x86_64"`, `"aarch64"`. Default: `""`. + + When set to `"aarch64"`, four arch-aware defaults activate (each is + overridden by setting the corresponding field explicitly): + + - The default `boot_iso.type` changes from `"ide"` (which the QEMU + `virt` machine type does not expose) to `"scsi"`. + - The default `additional_iso_files[*].type` changes from `"ide"` to + `"scsi"`. + - The default `cpu_type` changes from `"kvm64"` (x86-only) to + `"cortex-a57"`. + - When `qemu_additional_args` is empty, the plugin injects + `-device qemu-xhci -device usb-kbd` so that `boot_command` + keystrokes reach the guest. The `virt` machine type ships without + a keyboard; QMP `send-key` is a silent no-op without one. + Supplying *any* `qemu_additional_args` disables this auto-inject. + + See [Building aarch64 templates](#building-aarch64-arm64-templates) + for the full set of companion fields required and a worked example. + - `efi_config` (efiConfig) - Set the efidisk storage options. See [EFI Config](#efi-config). - `efidisk` (string) - This option is deprecated, please use `efi_config` instead. From 74a949136e5723759045b8991bfe226f7564fe69 Mon Sep 17 00:00:00 2001 From: Gerhard Lausser Date: Thu, 14 May 2026 20:26:49 +0200 Subject: [PATCH 03/12] feat(builder): validate aarch64 against incompatible companion settings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When arch=aarch64 is set, five companion settings must be configured correctly for the build to reach a working PVE template. Each rule fires at validate time, before any API call, naming the offending field by its HCL name: - FR-004: arch=aarch64 + bios=seabios → error. aarch64 requires UEFI. - FR-009: arch=aarch64 + empty efi_config → error. - FR-010: arch=aarch64 + non-serial vga.type → error. QMP send-key for boot_command keystrokes only reaches the guest through a serial console. - FR-011: arch=aarch64 + serial vga.type + empty serials → error. - FR-012: arch=aarch64 + boot_iso.type="ide" → error. The QEMU virt machine type has no IDE bus. Rules 2–5 live in builder/proxmox/common/config.go and run immediately after the EFI normalization block (FR-009 reads the post-normalize EFIStoragePool, so the position is load-bearing). Rule 6 lives in builder/proxmox/iso/config.go because BootISO is on iso.Config. Verbatim error strings are stable surface — see specs/002-aarch64-proxmox-iso/contracts/feature-surface.md. --- builder/proxmox/common/config.go | 33 ++++++++++++++ builder/proxmox/common/config_test.go | 64 +++++++++++++++++++++++++++ builder/proxmox/iso/config.go | 5 +++ builder/proxmox/iso/config_test.go | 47 ++++++++++++++++++++ 4 files changed, 149 insertions(+) diff --git a/builder/proxmox/common/config.go b/builder/proxmox/common/config.go index bd0011e5..79c3ecf4 100644 --- a/builder/proxmox/common/config.go +++ b/builder/proxmox/common/config.go @@ -945,6 +945,39 @@ func (c *Config) Prepare(upper interface{}, raws ...interface{}) ([]string, []st "", "x86_64", "aarch64", c.Arch)) } + // arch=aarch64 imposes hard requirements on companion settings. Position + // is load-bearing: FR-009 reads the post-normalize c.EFIConfig.EFIStoragePool, + // so this block must run after the EFI normalization above. + if c.Arch == "aarch64" { + // FR-004 — aarch64 + seabios. EqualFold because bios is treated + // case-insensitively elsewhere in the plugin. + if strings.EqualFold(c.BIOS, "seabios") { + errs = packersdk.MultiErrorAppend(errs, fmt.Errorf( + "arch=aarch64 requires bios=ovmf")) + } + // FR-009 — aarch64 without efi_config. + if c.EFIConfig.EFIStoragePool == "" { + errs = packersdk.MultiErrorAppend(errs, fmt.Errorf( + "arch=aarch64 requires efi_config to be set")) + } + // FR-010 — aarch64 with a non-serial vga.type. boot_command keystrokes + // only reach an aarch64 guest through a serial console. + validSerialVGA := map[string]struct{}{ + "serial0": {}, "serial1": {}, "serial2": {}, "serial3": {}, + } + _, vgaIsSerial := validSerialVGA[c.VGA.Type] + if !vgaIsSerial { + errs = packersdk.MultiErrorAppend(errs, fmt.Errorf( + "arch=aarch64 requires vga.type to be one of serial0, serial1, serial2, serial3, got %q", + c.VGA.Type)) + } + // FR-011 — a serial vga.type requires at least one declared serial device. + if vgaIsSerial && len(c.Serials) == 0 { + errs = packersdk.MultiErrorAppend(errs, fmt.Errorf( + "arch=aarch64 with a serial vga.type requires at least one entry in serials")) + } + } + if c.TPMConfig != (tpmConfig{}) { if c.TPMConfig.TPMStoragePool == "" { errs = packersdk.MultiErrorAppend(errs, errors.New("tpm_storage_pool not set for tpm_config")) diff --git a/builder/proxmox/common/config_test.go b/builder/proxmox/common/config_test.go index 71aa0540..752bb20d 100644 --- a/builder/proxmox/common/config_test.go +++ b/builder/proxmox/common/config_test.go @@ -730,6 +730,70 @@ func validAarch64Config(t *testing.T) map[string]interface{} { return cfg } +func TestArch_Aarch64_Validation(t *testing.T) { + cases := []struct { + name string + mutate func(cfg map[string]interface{}) + expectedError string + }{ + { + name: "happy path — all companion fields set correctly", + mutate: func(cfg map[string]interface{}) {}, + expectedError: "", + }, + { + name: "FR-004: bios=seabios rejected (lowercase)", + mutate: func(cfg map[string]interface{}) { cfg["bios"] = "seabios" }, + expectedError: "arch=aarch64 requires bios=ovmf", + }, + { + name: "FR-004: bios=SeaBIOS rejected (EqualFold)", + mutate: func(cfg map[string]interface{}) { cfg["bios"] = "SeaBIOS" }, + expectedError: "arch=aarch64 requires bios=ovmf", + }, + { + name: "FR-009: missing efi_config rejected", + mutate: func(cfg map[string]interface{}) { delete(cfg, "efi_config") }, + expectedError: "arch=aarch64 requires efi_config to be set", + }, + { + name: "FR-010: vga.type=std rejected", + mutate: func(cfg map[string]interface{}) { cfg["vga"] = map[string]interface{}{"type": "std"} }, + expectedError: "arch=aarch64 requires vga.type to be one of serial0, serial1, serial2, serial3", + }, + { + name: "FR-010: vga.type unset rejected", + mutate: func(cfg map[string]interface{}) { delete(cfg, "vga") }, + expectedError: "arch=aarch64 requires vga.type to be one of serial0, serial1, serial2, serial3", + }, + { + name: "FR-011: serial vga.type with empty serials rejected", + mutate: func(cfg map[string]interface{}) { cfg["serials"] = []string{} }, + expectedError: "arch=aarch64 with a serial vga.type requires at least one entry in serials", + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + cfg := validAarch64Config(t) + tc.mutate(cfg) + var c Config + _, _, err := c.Prepare(&c, cfg) + if tc.expectedError == "" { + if err != nil && strings.Contains(err.Error(), "arch=aarch64") { + t.Errorf("expected happy-path config to pass aarch64 validation, got: %s", err.Error()) + } + return + } + if err == nil { + t.Fatalf("expected error containing %q, got nil", tc.expectedError) + } + if !strings.Contains(err.Error(), tc.expectedError) { + t.Errorf("expected error containing %q, got %q", tc.expectedError, err.Error()) + } + }) + } +} + func TestArch_Whitelist(t *testing.T) { cases := []struct { arch string diff --git a/builder/proxmox/iso/config.go b/builder/proxmox/iso/config.go index 135bfe3d..d686f3c3 100644 --- a/builder/proxmox/iso/config.go +++ b/builder/proxmox/iso/config.go @@ -137,6 +137,11 @@ func (c *Config) Prepare(raws ...interface{}) ([]string, []string, error) { // For backwards compatibility <= v1.8, set ide2 as default if not configured switch c.BootISO.Type { case "ide", "sata", "scsi": + // FR-012 — aarch64 has no IDE bus. + if c.Arch == "aarch64" && c.BootISO.Type == "ide" { + errs = packersdk.MultiErrorAppend(errs, fmt.Errorf( + `arch=aarch64 does not support boot_iso.type="ide"; use "scsi" or "sata"`)) + } case "": log.Print("boot_iso device type not set, using default type 'ide' and index '2'") c.BootISO.Type = "ide" diff --git a/builder/proxmox/iso/config_test.go b/builder/proxmox/iso/config_test.go index 1292a8bc..6bd45ad1 100644 --- a/builder/proxmox/iso/config_test.go +++ b/builder/proxmox/iso/config_test.go @@ -293,3 +293,50 @@ func mandatoryConfig(t *testing.T) map[string]interface{} { }, } } + +// validAarch64IsoConfig is the iso-package counterpart to common's +// validAarch64Config: it returns a Packer config map that satisfies every +// hard requirement for arch="aarch64" iso builds, so individual tests can +// tweak one field in isolation. +func validAarch64IsoConfig(t *testing.T) map[string]interface{} { + cfg := mandatoryConfig(t) + cfg["arch"] = "aarch64" + cfg["bios"] = "ovmf" + cfg["efi_config"] = map[string]interface{}{ + "efi_storage_pool": "local-lvm", + "efi_type": "4m", + "pre_enrolled_keys": true, + } + cfg["vga"] = map[string]interface{}{"type": "serial0"} + cfg["serials"] = []string{"socket"} + return cfg +} + +func TestArch_Aarch64_BootISO_IDE_Rejected(t *testing.T) { + cases := []struct { + bus string + expectedError string + }{ + {bus: "ide", expectedError: `arch=aarch64 does not support boot_iso.type="ide"; use "scsi" or "sata"`}, + {bus: "scsi", expectedError: ""}, + {bus: "sata", expectedError: ""}, + } + for _, tc := range cases { + t.Run("bus="+tc.bus, func(t *testing.T) { + cfg := validAarch64IsoConfig(t) + boot := cfg["boot_iso"].(map[string]interface{}) + boot["type"] = tc.bus + var c Config + _, _, err := c.Prepare(cfg) + if tc.expectedError == "" { + if err != nil && strings.Contains(err.Error(), `does not support boot_iso.type="ide"`) { + t.Errorf("expected bus=%s to be accepted on aarch64, got %s", tc.bus, err.Error()) + } + return + } + if err == nil || !strings.Contains(err.Error(), tc.expectedError) { + t.Errorf("expected error containing %q, got %v", tc.expectedError, err) + } + }) + } +} From e29dd9b509b71e3ec9e057929b8d582d44c0c712 Mon Sep 17 00:00:00 2001 From: Gerhard Lausser Date: Thu, 14 May 2026 20:41:53 +0200 Subject: [PATCH 04/12] feat(builder): arch-aware defaults for aarch64 builds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When arch=aarch64 and the corresponding companion field is unset, the plugin substitutes an aarch64-appropriate default. Explicit user values are always honored. - FR-006: boot_iso.type defaults to "scsi" (not "ide" — the QEMU virt machine type has no IDE bus). Index is intentionally left unset; SCSI auto-assignment finds a free slot at runtime. - FR-006: additional_iso_files[*].type defaults to "scsi" for the same reason. - FR-015: cpu_type defaults to "cortex-a57". PVE rejects "kvm64" (the plugin's x86 default) on the virt machine type with "CPU model 'kvm64' does not exist for configured vCPU architecture 'aarch64'". - FR-013: qemu_additional_args auto-injects "-device qemu-xhci -device usb-kbd" so boot_command keystrokes reach the guest. The virt machine type ships without a keyboard; QMP send-key is a silent no-op without one. Detection is strict empty-string equality — supplying any value fully disables the auto-inject. Device order is load-bearing. --- builder/proxmox/common/config.go | 32 +++++++-- builder/proxmox/common/config_test.go | 93 +++++++++++++++++++++++++++ builder/proxmox/iso/config.go | 15 ++++- builder/proxmox/iso/config_test.go | 54 ++++++++++++++++ 4 files changed, 186 insertions(+), 8 deletions(-) diff --git a/builder/proxmox/common/config.go b/builder/proxmox/common/config.go index 79c3ecf4..4b0db198 100644 --- a/builder/proxmox/common/config.go +++ b/builder/proxmox/common/config.go @@ -685,8 +685,16 @@ func (c *Config) Prepare(upper interface{}, raws ...interface{}) ([]string, []st c.Sockets = 1 } if c.CPUType == "" { - log.Printf("CPU type not set, using default 'kvm64'") - c.CPUType = "kvm64" + // FR-015 — aarch64 picks a cortex-a57 default; PVE rejects kvm64 on + // the virt machine type. cortex-a57 is the broadest-compatibility + // aarch64 CPU model accepted by PVE 8 and 9. + if c.Arch == "aarch64" { + log.Printf("CPU type not set, using default 'cortex-a57' for arch=aarch64") + c.CPUType = "cortex-a57" + } else { + log.Printf("CPU type not set, using default 'kvm64'") + c.CPUType = "kvm64" + } } if c.OS == "" { log.Printf("OS not set, using default 'other'") @@ -763,12 +771,18 @@ func (c *Config) Prepare(upper interface{}, raws ...interface{}) ([]string, []st } } } - // validate device type, assign if unset + // validate device type, assign if unset. FR-006: aarch64 picks scsi + // rather than ide because the virt machine type has no IDE bus. switch c.ISOs[idx].Type { case "ide", "sata", "scsi": case "": - log.Printf("additional_iso %d device type not set, using default 'ide'", idx) - c.ISOs[idx].Type = "ide" + if c.Arch == "aarch64" { + log.Printf("additional_iso %d device type not set, using default 'scsi' for arch=aarch64", idx) + c.ISOs[idx].Type = "scsi" + } else { + log.Printf("additional_iso %d device type not set, using default 'ide'", idx) + c.ISOs[idx].Type = "ide" + } default: errs = packersdk.MultiErrorAppend(errs, errors.New("ISOs must be of type ide, sata or scsi. VirtIO not supported by Proxmox for ISO devices")) } @@ -976,6 +990,14 @@ func (c *Config) Prepare(upper interface{}, raws ...interface{}) ([]string, []st errs = packersdk.MultiErrorAppend(errs, fmt.Errorf( "arch=aarch64 with a serial vga.type requires at least one entry in serials")) } + // FR-013 — keyboard hardware default. virt ships without a keyboard; + // QMP send-key is a silent no-op without one. Detection is strict + // empty-string equality — any user-supplied args fully disable the + // auto-inject. Device order is load-bearing: qemu-xhci controller + // must come before the usb-kbd device that binds to it. + if c.AdditionalArgs == "" { + c.AdditionalArgs = "-device qemu-xhci -device usb-kbd" + } } if c.TPMConfig != (tpmConfig{}) { diff --git a/builder/proxmox/common/config_test.go b/builder/proxmox/common/config_test.go index 752bb20d..0336d851 100644 --- a/builder/proxmox/common/config_test.go +++ b/builder/proxmox/common/config_test.go @@ -730,6 +730,99 @@ func validAarch64Config(t *testing.T) map[string]interface{} { return cfg } +func TestArch_Aarch64_Defaults(t *testing.T) { + t.Run("cpu_type defaults to cortex-a57", func(t *testing.T) { + cfg := validAarch64Config(t) + var c Config + if _, _, err := c.Prepare(&c, cfg); err != nil { + t.Fatalf("unexpected error: %s", err) + } + if c.CPUType != "cortex-a57" { + t.Errorf("expected default cpu_type cortex-a57, got %q", c.CPUType) + } + }) + t.Run("cpu_type explicit value is honored", func(t *testing.T) { + cfg := validAarch64Config(t) + cfg["cpu_type"] = "cortex-a72" + var c Config + if _, _, err := c.Prepare(&c, cfg); err != nil { + t.Fatalf("unexpected error: %s", err) + } + if c.CPUType != "cortex-a72" { + t.Errorf("expected cpu_type cortex-a72, got %q", c.CPUType) + } + }) + t.Run("cpu_type default stays kvm64 on x86_64", func(t *testing.T) { + cfg := mandatoryConfig(t) + cfg["arch"] = "x86_64" + var c Config + if _, _, err := c.Prepare(&c, cfg); err != nil { + t.Fatalf("unexpected error: %s", err) + } + if c.CPUType != "kvm64" { + t.Errorf("expected cpu_type kvm64 on x86_64, got %q", c.CPUType) + } + }) + t.Run("qemu_additional_args auto-injects keyboard devices on aarch64", func(t *testing.T) { + cfg := validAarch64Config(t) + var c Config + if _, _, err := c.Prepare(&c, cfg); err != nil { + t.Fatalf("unexpected error: %s", err) + } + want := "-device qemu-xhci -device usb-kbd" + if c.AdditionalArgs != want { + t.Errorf("expected qemu_additional_args %q, got %q", want, c.AdditionalArgs) + } + }) + t.Run("qemu_additional_args opt-out: any user value disables auto-inject", func(t *testing.T) { + cfg := validAarch64Config(t) + cfg["qemu_additional_args"] = "-cpu foo" + var c Config + if _, _, err := c.Prepare(&c, cfg); err != nil { + t.Fatalf("unexpected error: %s", err) + } + if c.AdditionalArgs != "-cpu foo" { + t.Errorf("expected user-supplied args preserved verbatim, got %q", c.AdditionalArgs) + } + }) + t.Run("qemu_additional_args stays empty on x86_64", func(t *testing.T) { + cfg := mandatoryConfig(t) + var c Config + if _, _, err := c.Prepare(&c, cfg); err != nil { + t.Fatalf("unexpected error: %s", err) + } + if c.AdditionalArgs != "" { + t.Errorf("expected qemu_additional_args empty on x86_64, got %q", c.AdditionalArgs) + } + }) + t.Run("additional_iso_files type defaults to scsi on aarch64", func(t *testing.T) { + cfg := validAarch64Config(t) + cfg["additional_iso_files"] = []map[string]interface{}{ + {"iso_file": "local:iso/cloud-init.iso"}, + } + var c Config + if _, _, err := c.Prepare(&c, cfg); err != nil { + t.Fatalf("unexpected error: %s", err) + } + if len(c.ISOs) != 1 || c.ISOs[0].Type != "scsi" { + t.Errorf("expected ISOs[0].Type=scsi for aarch64, got %+v", c.ISOs) + } + }) + t.Run("additional_iso_files type stays ide for non-aarch64", func(t *testing.T) { + cfg := mandatoryConfig(t) + cfg["additional_iso_files"] = []map[string]interface{}{ + {"iso_file": "local:iso/cloud-init.iso"}, + } + var c Config + if _, _, err := c.Prepare(&c, cfg); err != nil { + t.Fatalf("unexpected error: %s", err) + } + if len(c.ISOs) != 1 || c.ISOs[0].Type != "ide" { + t.Errorf("expected ISOs[0].Type=ide for x86_64, got %+v", c.ISOs) + } + }) +} + func TestArch_Aarch64_Validation(t *testing.T) { cases := []struct { name string diff --git a/builder/proxmox/iso/config.go b/builder/proxmox/iso/config.go index d686f3c3..14c25271 100644 --- a/builder/proxmox/iso/config.go +++ b/builder/proxmox/iso/config.go @@ -143,9 +143,18 @@ func (c *Config) Prepare(raws ...interface{}) ([]string, []string, error) { `arch=aarch64 does not support boot_iso.type="ide"; use "scsi" or "sata"`)) } case "": - log.Print("boot_iso device type not set, using default type 'ide' and index '2'") - c.BootISO.Type = "ide" - c.BootISO.Index = "2" + // FR-006 — aarch64 defaults to scsi (the virt machine type has no + // IDE bus). Index is intentionally left unset: SCSI auto-assignment + // finds a free slot at runtime, so a fixed index risks colliding + // with a disk allocation. + if c.Arch == "aarch64" { + log.Print("boot_iso device type not set, using default type 'scsi' for arch=aarch64") + c.BootISO.Type = "scsi" + } else { + log.Print("boot_iso device type not set, using default type 'ide' and index '2'") + c.BootISO.Type = "ide" + c.BootISO.Index = "2" + } default: errs = packersdk.MultiErrorAppend(errs, errors.New("ISOs must be of type ide, sata or scsi. VirtIO not supported by Proxmox for ISO devices")) } diff --git a/builder/proxmox/iso/config_test.go b/builder/proxmox/iso/config_test.go index 6bd45ad1..fa678985 100644 --- a/builder/proxmox/iso/config_test.go +++ b/builder/proxmox/iso/config_test.go @@ -312,6 +312,60 @@ func validAarch64IsoConfig(t *testing.T) map[string]interface{} { return cfg } +func TestArch_Aarch64_BootISO_DefaultBus(t *testing.T) { + t.Run("aarch64 + unset boot_iso.type → scsi (no index)", func(t *testing.T) { + cfg := validAarch64IsoConfig(t) + boot := cfg["boot_iso"].(map[string]interface{}) + delete(boot, "type") + var c Config + if _, _, err := c.Prepare(cfg); err != nil { + t.Fatalf("unexpected error: %s", err) + } + if c.BootISO.Type != "scsi" { + t.Errorf("expected BootISO.Type=scsi for aarch64, got %q", c.BootISO.Type) + } + if c.BootISO.Index != "" { + t.Errorf("expected BootISO.Index unset for aarch64 (auto-assignment), got %q", c.BootISO.Index) + } + }) + t.Run("x86_64 + unset boot_iso.type → ide index 2", func(t *testing.T) { + cfg := mandatoryConfig(t) + boot := cfg["boot_iso"].(map[string]interface{}) + delete(boot, "type") + var c Config + if _, _, err := c.Prepare(cfg); err != nil { + t.Fatalf("unexpected error: %s", err) + } + if c.BootISO.Type != "ide" || c.BootISO.Index != "2" { + t.Errorf("expected BootISO ide/2 for x86_64, got type=%q index=%q", c.BootISO.Type, c.BootISO.Index) + } + }) + t.Run("aarch64 + explicit boot_iso.type=scsi → unchanged, no index", func(t *testing.T) { + cfg := validAarch64IsoConfig(t) + boot := cfg["boot_iso"].(map[string]interface{}) + boot["type"] = "scsi" + var c Config + if _, _, err := c.Prepare(cfg); err != nil { + t.Fatalf("unexpected error: %s", err) + } + if c.BootISO.Type != "scsi" { + t.Errorf("expected scsi, got %q", c.BootISO.Type) + } + }) + t.Run("aarch64 + explicit boot_iso.type=sata → unchanged", func(t *testing.T) { + cfg := validAarch64IsoConfig(t) + boot := cfg["boot_iso"].(map[string]interface{}) + boot["type"] = "sata" + var c Config + if _, _, err := c.Prepare(cfg); err != nil { + t.Fatalf("unexpected error: %s", err) + } + if c.BootISO.Type != "sata" { + t.Errorf("expected sata, got %q", c.BootISO.Type) + } + }) +} + func TestArch_Aarch64_BootISO_IDE_Rejected(t *testing.T) { cases := []struct { bus string From d4cd632feb62641039e8c1bfc580b0b1a7185612 Mon Sep 17 00:00:00 2001 From: Gerhard Lausser Date: Thu, 14 May 2026 20:55:21 +0200 Subject: [PATCH 05/12] docs(builder): aarch64 builder guide Adds a Building aarch64 (ARM64) templates section to docs/builders/iso.mdx that documents the prerequisites, the four hard companion fields the plugin enforces when arch=aarch64, the arch-aware defaults it substitutes, the qemu_additional_args opt-out gotcha, and a worked HCL2 example that produces a Debian 13 ARM64 template on PVE 9. --- docs/builders/iso.mdx | 144 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 144 insertions(+) diff --git a/docs/builders/iso.mdx b/docs/builders/iso.mdx index 99ca64b4..0fd60caf 100644 --- a/docs/builders/iso.mdx +++ b/docs/builders/iso.mdx @@ -238,3 +238,147 @@ build { } ``` +## Building aarch64 (ARM64) templates + +Setting `arch = "aarch64"` turns the proxmox-iso builder into an ARM64 +template factory. This section documents the prerequisites, the +companion fields the plugin enforces, the arch-aware defaults it +substitutes, and a worked example. The rest of the builder documentation +continues to apply unchanged. + +### Prerequisites + +- A **Proxmox VE 9.1.9 or newer** cluster. Earlier 9.x point releases + ship a `qemu-server` that rejects `cortex-a57` as an unknown built-in + CPU model, even though the package vendor advertises support. +- The AAVMF firmware available on the PVE node: + - **PVE 9**: included in the base `pve-edk2-firmware` package. + - **PVE 8**: install `pve-edk2-firmware-aarch64` from + `pve-no-subscription`. +- **API-token credentials.** Long-running aarch64 builds — typical of + preseed-driven Debian installs over a serial console — frequently + outlive a password ticket (~2 hours). Use a token instead. + +### Required companion fields + +When `arch = "aarch64"`, the plugin enforces four hard requirements at +validate time. Missing any of them aborts the build before any API call +is made. + +| Field | Required value | Why | +|--------------|-------------------------------------------------|------------------------------------------------------------------------------------------------------------------------------------| +| `bios` | `"ovmf"` | aarch64 boots via UEFI; SeaBIOS is x86-only. | +| `efi_config` | Block present with `efi_storage_pool` non-empty | OVMF needs the EFI variable store. | +| `vga.type` | One of `serial0`–`serial3` | `boot_command` keystrokes only reach the guest through a serial console — there is no emulated graphics path on the `virt` machine. | +| `serials` | At least one entry (typically `["socket"]`) | Backs the serial console that `vga.type` selects. | + +In addition, explicitly setting `boot_iso.type = "ide"` is rejected: +the QEMU `virt` machine type has no IDE bus. + +### Arch-aware defaults + +When the corresponding field is unset, the plugin substitutes the value +that works for the QEMU `virt` machine type. Explicit user values are +honored verbatim. + +| Field | aarch64 default | Rationale | +|--------------------------------|---------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------| +| `boot_iso.type` | `"scsi"` (index auto-assigned) | `virt` has no IDE bus. | +| `additional_iso_files[*].type` | `"scsi"` | Same reason. | +| `cpu_type` | `"cortex-a57"` | PVE rejects `kvm64` (the x86 default) on `virt`. `cortex-a72` is also accepted if you set it explicitly. | +| `qemu_additional_args` | `"-device qemu-xhci -device usb-kbd"` | `virt` ships without a keyboard; without one, QMP `send-key` for `boot_command` is a silent no-op and the build hangs at the bootloader's first prompt. | + +> **Gotcha — keyboard injection is all-or-nothing.** The +> `qemu_additional_args` auto-inject only fires when the field is the +> empty string. If you set *any* value for it, the auto-inject is +> disabled — even unrelated additions like `-cpu host` will leave the +> guest without a keyboard, and your `boot_command` will type into the +> void. Symptom: the build hangs at the GRUB or kernel prompt until +> `ssh_timeout` expires. If you need extra QEMU args **and** the +> keyboard, include `-device qemu-xhci -device usb-kbd` in your own +> value. + +### Worked example + +A minimum-viable aarch64 build that produces a Debian 13 template on +PVE 9. Inline comments mark each required companion field. + +```hcl +source "proxmox-iso" "debian-arm64" { + node = "monpxmx04" + arch = "aarch64" + + # Required companion: aarch64 boots UEFI. + bios = "ovmf" + efi_config { + efi_storage_pool = "local-lvm" + efi_type = "4m" + pre_enrolled_keys = true + } + + # Required companion: boot_command keystrokes need a serial console. + vga { + type = "serial0" + } + serials = ["socket"] + + # Boot order is load-bearing on aarch64: PVE re-asserts the EFI + # BootOrder entry on every VM start, and a disk-last order makes the + # post-install reboot fall back to the CD-ROM (or PXE), looping the + # installer. Putting the installed disks first matches what the + # guest's bootloader writes into NVRAM at the end of the install. + boot = "order=scsi0;scsi1" + + boot_iso { + iso_file = "local:iso/debian-13.1.0-arm64-netinst.iso" + iso_storage_pool = "local" + unmount = true + # type defaults to "scsi" (no IDE on virt); index is auto-assigned. + } + + disks { + type = "scsi" + storage_pool = "local-lvm" + disk_size = "20G" + format = "qcow2" + } + + network_adapters { + bridge = "vmbr0" + model = "virtio" + } + + http_directory = "http" + + boot_wait = "5s" + boot_command = [ + "", + "auto url=http://{{.HTTPIP}}:{{.HTTPPort}}/preseed.cfg ", + "console=ttyAMA0,115200 ", + "auto=true priority=critical interface=auto" + ] + + # API-token credentials outlive long aarch64 builds. + proxmox_url = "https://monpxmx04.example.invalid:8006/api2/json" + username = "packer@pve!build" + token = "00000000-0000-0000-0000-000000000000" + insecure_skip_tls_verify = true + + ssh_username = "packer" + ssh_password = "packer" + ssh_timeout = "60m" + + template_name = "debian-13-arm64" + template_description = "Debian 13 ARM64, built {{ isotime \"2006-01-02T15:04:05Z\" }}" +} + +build { + sources = ["source.proxmox-iso.debian-arm64"] +} +``` + +`cpu_type` is intentionally omitted; the plugin defaults it to +`cortex-a57`, which PVE accepts on every aarch64-capable release. Set +it explicitly to `cortex-a72` for newer host hardware. `cpu_type = +"host"` also works if your build node is itself an aarch64 host. + From 83e92b52e8ecc84f07aeea07b2404162cd18ea75 Mon Sep 17 00:00:00 2001 From: Gerhard Lausser Date: Fri, 15 May 2026 00:55:37 +0200 Subject: [PATCH 06/12] ci: bump .go-version to 1.26.2 Pinning Telmate/proxmox-api-go@f4e48ca in a5e4e73 cascades go 1.26.2 into our go.mod via go mod tidy (api-go's own go.mod floor). The CI matrix reads .go-version for golangci-lint and the generate check; the old 1.21.13 value causes a type-export format mismatch that breaks both jobs. Bump to match. --- .go-version | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.go-version b/.go-version index 8cacbae5..c7c3f333 100644 --- a/.go-version +++ b/.go-version @@ -1,2 +1 @@ -1.21.13 - +1.26.2 From da1cf3c586973c48dc4d398e5d6aa356ab4330a5 Mon Sep 17 00:00:00 2001 From: Gerhard Lausser Date: Fri, 15 May 2026 01:03:17 +0200 Subject: [PATCH 07/12] docs: regenerate web-docs for aarch64 builder guide Rerun make generate after adding the "Building aarch64 (ARM64) templates" section to docs/builders/iso.mdx. Picks up the new arch field docs in the compiled .web-docs/ tree. --- .web-docs/components/builder/clone/README.md | 21 +++ .web-docs/components/builder/iso/README.md | 165 +++++++++++++++++++ 2 files changed, 186 insertions(+) diff --git a/.web-docs/components/builder/clone/README.md b/.web-docs/components/builder/clone/README.md index d4552350..b224ebfe 100644 --- a/.web-docs/components/builder/clone/README.md +++ b/.web-docs/components/builder/clone/README.md @@ -213,6 +213,27 @@ boot time. - `bios` (string) - Set the machine bios. This can be set to ovmf or seabios. The default value is seabios. +- `arch` (string) - The CPU architecture for the VM. Allowed values: `""` (host default), + `"x86_64"`, `"aarch64"`. Default: `""`. + + When set to `"aarch64"`, four arch-aware defaults activate (each is + overridden by setting the corresponding field explicitly): + + - The default `boot_iso.type` changes from `"ide"` (which the QEMU + `virt` machine type does not expose) to `"scsi"`. + - The default `additional_iso_files[*].type` changes from `"ide"` to + `"scsi"`. + - The default `cpu_type` changes from `"kvm64"` (x86-only) to + `"cortex-a57"`. + - When `qemu_additional_args` is empty, the plugin injects + `-device qemu-xhci -device usb-kbd` so that `boot_command` + keystrokes reach the guest. The `virt` machine type ships without + a keyboard; QMP `send-key` is a silent no-op without one. + Supplying *any* `qemu_additional_args` disables this auto-inject. + + See [Building aarch64 templates](#building-aarch64-arm64-templates) + for the full set of companion fields required and a worked example. + - `efi_config` (efiConfig) - Set the efidisk storage options. See [EFI Config](#efi-config). - `efidisk` (string) - This option is deprecated, please use `efi_config` instead. diff --git a/.web-docs/components/builder/iso/README.md b/.web-docs/components/builder/iso/README.md index f23468c9..4554f167 100644 --- a/.web-docs/components/builder/iso/README.md +++ b/.web-docs/components/builder/iso/README.md @@ -148,6 +148,27 @@ in the image's Cloud-Init settings for provisioning. - `bios` (string) - Set the machine bios. This can be set to ovmf or seabios. The default value is seabios. +- `arch` (string) - The CPU architecture for the VM. Allowed values: `""` (host default), + `"x86_64"`, `"aarch64"`. Default: `""`. + + When set to `"aarch64"`, four arch-aware defaults activate (each is + overridden by setting the corresponding field explicitly): + + - The default `boot_iso.type` changes from `"ide"` (which the QEMU + `virt` machine type does not expose) to `"scsi"`. + - The default `additional_iso_files[*].type` changes from `"ide"` to + `"scsi"`. + - The default `cpu_type` changes from `"kvm64"` (x86-only) to + `"cortex-a57"`. + - When `qemu_additional_args` is empty, the plugin injects + `-device qemu-xhci -device usb-kbd` so that `boot_command` + keystrokes reach the guest. The `virt` machine type ships without + a keyboard; QMP `send-key` is a silent no-op without one. + Supplying *any* `qemu_additional_args` disables this auto-inject. + + See [Building aarch64 templates](#building-aarch64-arm64-templates) + for the full set of companion fields required and a worked example. + - `efi_config` (efiConfig) - Set the efidisk storage options. See [EFI Config](#efi-config). - `efidisk` (string) - This option is deprecated, please use `efi_config` instead. @@ -1160,3 +1181,147 @@ build { ] } ``` + +## Building aarch64 (ARM64) templates + +Setting `arch = "aarch64"` turns the proxmox-iso builder into an ARM64 +template factory. This section documents the prerequisites, the +companion fields the plugin enforces, the arch-aware defaults it +substitutes, and a worked example. The rest of the builder documentation +continues to apply unchanged. + +### Prerequisites + +- A **Proxmox VE 9.1.9 or newer** cluster. Earlier 9.x point releases + ship a `qemu-server` that rejects `cortex-a57` as an unknown built-in + CPU model, even though the package vendor advertises support. +- The AAVMF firmware available on the PVE node: + - **PVE 9**: included in the base `pve-edk2-firmware` package. + - **PVE 8**: install `pve-edk2-firmware-aarch64` from + `pve-no-subscription`. +- **API-token credentials.** Long-running aarch64 builds — typical of + preseed-driven Debian installs over a serial console — frequently + outlive a password ticket (~2 hours). Use a token instead. + +### Required companion fields + +When `arch = "aarch64"`, the plugin enforces four hard requirements at +validate time. Missing any of them aborts the build before any API call +is made. + +| Field | Required value | Why | +|--------------|-------------------------------------------------|------------------------------------------------------------------------------------------------------------------------------------| +| `bios` | `"ovmf"` | aarch64 boots via UEFI; SeaBIOS is x86-only. | +| `efi_config` | Block present with `efi_storage_pool` non-empty | OVMF needs the EFI variable store. | +| `vga.type` | One of `serial0`–`serial3` | `boot_command` keystrokes only reach the guest through a serial console — there is no emulated graphics path on the `virt` machine. | +| `serials` | At least one entry (typically `["socket"]`) | Backs the serial console that `vga.type` selects. | + +In addition, explicitly setting `boot_iso.type = "ide"` is rejected: +the QEMU `virt` machine type has no IDE bus. + +### Arch-aware defaults + +When the corresponding field is unset, the plugin substitutes the value +that works for the QEMU `virt` machine type. Explicit user values are +honored verbatim. + +| Field | aarch64 default | Rationale | +|--------------------------------|---------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------| +| `boot_iso.type` | `"scsi"` (index auto-assigned) | `virt` has no IDE bus. | +| `additional_iso_files[*].type` | `"scsi"` | Same reason. | +| `cpu_type` | `"cortex-a57"` | PVE rejects `kvm64` (the x86 default) on `virt`. `cortex-a72` is also accepted if you set it explicitly. | +| `qemu_additional_args` | `"-device qemu-xhci -device usb-kbd"` | `virt` ships without a keyboard; without one, QMP `send-key` for `boot_command` is a silent no-op and the build hangs at the bootloader's first prompt. | + +> **Gotcha — keyboard injection is all-or-nothing.** The +> `qemu_additional_args` auto-inject only fires when the field is the +> empty string. If you set *any* value for it, the auto-inject is +> disabled — even unrelated additions like `-cpu host` will leave the +> guest without a keyboard, and your `boot_command` will type into the +> void. Symptom: the build hangs at the GRUB or kernel prompt until +> `ssh_timeout` expires. If you need extra QEMU args **and** the +> keyboard, include `-device qemu-xhci -device usb-kbd` in your own +> value. + +### Worked example + +A minimum-viable aarch64 build that produces a Debian 13 template on +PVE 9. Inline comments mark each required companion field. + +```hcl +source "proxmox-iso" "debian-arm64" { + node = "monpxmx04" + arch = "aarch64" + + # Required companion: aarch64 boots UEFI. + bios = "ovmf" + efi_config { + efi_storage_pool = "local-lvm" + efi_type = "4m" + pre_enrolled_keys = true + } + + # Required companion: boot_command keystrokes need a serial console. + vga { + type = "serial0" + } + serials = ["socket"] + + # Boot order is load-bearing on aarch64: PVE re-asserts the EFI + # BootOrder entry on every VM start, and a disk-last order makes the + # post-install reboot fall back to the CD-ROM (or PXE), looping the + # installer. Putting the installed disks first matches what the + # guest's bootloader writes into NVRAM at the end of the install. + boot = "order=scsi0;scsi1" + + boot_iso { + iso_file = "local:iso/debian-13.1.0-arm64-netinst.iso" + iso_storage_pool = "local" + unmount = true + # type defaults to "scsi" (no IDE on virt); index is auto-assigned. + } + + disks { + type = "scsi" + storage_pool = "local-lvm" + disk_size = "20G" + format = "qcow2" + } + + network_adapters { + bridge = "vmbr0" + model = "virtio" + } + + http_directory = "http" + + boot_wait = "5s" + boot_command = [ + "", + "auto url=http://{{.HTTPIP}}:{{.HTTPPort}}/preseed.cfg ", + "console=ttyAMA0,115200 ", + "auto=true priority=critical interface=auto" + ] + + # API-token credentials outlive long aarch64 builds. + proxmox_url = "https://monpxmx04.example.invalid:8006/api2/json" + username = "packer@pve!build" + token = "00000000-0000-0000-0000-000000000000" + insecure_skip_tls_verify = true + + ssh_username = "packer" + ssh_password = "packer" + ssh_timeout = "60m" + + template_name = "debian-13-arm64" + template_description = "Debian 13 ARM64, built {{ isotime \"2006-01-02T15:04:05Z\" }}" +} + +build { + sources = ["source.proxmox-iso.debian-arm64"] +} +``` + +`cpu_type` is intentionally omitted; the plugin defaults it to +`cortex-a57`, which PVE accepts on every aarch64-capable release. Set +it explicitly to `cortex-a72` for newer host hardware. `cpu_type = +"host"` also works if your build node is itself an aarch64 host. From 17f5d3cb86071607333933d2238bfac66d3bb524 Mon Sep 17 00:00:00 2001 From: Gerhard Lausser Date: Fri, 15 May 2026 01:05:48 +0200 Subject: [PATCH 08/12] ci: upgrade golangci-lint to v2.12.2 for Go 1.26 compatibility MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit golangci-lint v1.60.1 (the previous pinned version) bundles a Go typechecker that cannot read export data produced by Go 1.26, causing all typecheck-dependent linters to fail with "unsupported version: 2". v2.x is the first release line with full Go 1.26 support; v1.x is no longer maintained. - go-validate.yml: bump golangci-lint-action v5 → v9.2.0, lint version v1.60.1 → v2.12.2 - .golangci.yml: migrate to v2 config format (version: "2", linters.default: none replaces disable-all, output.formats as array, remove linters.fast and run.skip-dirs-use-default which v2 dropped) --- .github/workflows/go-validate.yml | 4 +- .golangci.yml | 89 ++++--------------------------- 2 files changed, 12 insertions(+), 81 deletions(-) diff --git a/.github/workflows/go-validate.yml b/.github/workflows/go-validate.yml index f413893b..631f85a5 100644 --- a/.github/workflows/go-validate.yml +++ b/.github/workflows/go-validate.yml @@ -49,9 +49,9 @@ jobs: - uses: actions/setup-go@0c52d547c9bc32b1aa3301fd7a9cb496313a4491 # v5.0.0 with: go-version: ${{ needs.get-go-version.outputs.go-version }} - - uses: golangci/golangci-lint-action@82d40c283aeb1f2b6595839195e95c2d6a49081b # v5.0.0 + - uses: golangci/golangci-lint-action@1e7e51e771db61008b38414a730f564565cf7c20 # v9.2.0 with: - version: v1.60.1 + version: v2.12.2 only-new-issues: true check-fmt: needs: diff --git a/.golangci.yml b/.golangci.yml index 642e0e37..e4be9f27 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -1,16 +1,13 @@ # Copyright (c) HashiCorp, Inc. # SPDX-License-Identifier: MPL-2.0 -issues: - # List of regexps of issue texts to exclude, empty list by default. - # But independently from this option we use default exclude patterns, - # it can be disabled by `exclude-use-default: false`. To list all - # excluded by default patterns execute `golangci-lint run --help` +version: "2" +issues: exclude-rules: # Exclude gosimple bool check - linters: - - gosimple + - gosimple text: "S(1002|1008|1021)" # Exclude failing staticchecks for now - linters: @@ -24,14 +21,14 @@ issues: - errcheck path: ".*_test.go" - # Maximum issues count per one linter. Set to 0 to disable. Default is 50. max-issues-per-linter: 0 - - # Maximum count of issues with the same text. Set to 0 to disable. Default is 3. max-same-issues: 0 + exclude-files: + - ".*\\.hcl2spec\\.go$" + linters: - disable-all: true + default: none enable: - errcheck - goimports @@ -41,88 +38,22 @@ linters: - staticcheck - unconvert - unused - fast: true -# options for analysis running run: - # default concurrency is a available CPU number concurrency: 4 - - # timeout for analysis, e.g. 30s, 5m, default is 1m timeout: 10m - - # exit code when at least one issue was found, default is 1 issues-exit-code: 1 - - # include test files or not, default is true tests: true - # list of build tags, all linters use it. Default is empty list. - #build-tags: - # - mytag - - # which dirs to skip: issues from them won't be reported; - # can use regexp here: generated.*, regexp is applied on full path; - # default value is empty list, but default dirs are skipped independently - # from this option's value (see skip-dirs-use-default). - #skip-dirs: - # - src/external_libs - # - autogenerated_by_my_lib - - # default is true. Enables skipping of directories: - # vendor$, third_party$, testdata$, examples$, Godeps$, builtin$ - skip-dirs-use-default: true - - # which files to skip: they will be analyzed, but issues from them - # won't be reported. Default value is empty list, but there is - # no need to include all autogenerated files, we confidently recognize - # autogenerated files. If it's not please let us know. - exclude-files: - - ".*\\.hcl2spec\\.go$" - # - lib/bad.go - - # by default isn't set. If set we pass it to "go list -mod={option}". From "go help modules": - # If invoked with -mod=readonly, the go command is disallowed from the implicit - # automatic updating of go.mod described above. Instead, it fails when any changes - # to go.mod are needed. This setting is most useful to check that go.mod does - # not need updates, such as in a continuous integration and testing system. - # If invoked with -mod=vendor, the go command assumes that the vendor - # directory holds the correct copies of dependencies and ignores - # the dependency descriptions in go.mod. - # modules-download-mode: vendor - - -# output configuration options output: - # colored-line-number|line-number|json|tab|checkstyle|code-climate, default is "colored-line-number" - formats: colored-line-number - - # print lines of code with issue, default is true + formats: + - format: colored-line-number print-issued-lines: true - - # print linter name in the end of issue text, default is true print-linter-name: true - - # make issues output unique by line, default is true uniq-by-line: true - -# all available settings of specific linters linters-settings: - errcheck: - # report about not checking of errors in type assetions: `a := b.(MyStruct)`; - # default is false: such cases aren't reported by default. + errcheck: check-type-assertions: false - - # report about assignment of errors to blank identifier: `num, _ := strconv.Atoi(numStr)`; - # default is false: such cases aren't reported by default. check-blank: false - - # [deprecated] comma-separated list of pairs of the form pkg:regex - # the regex is used to ignore names within pkg. (default "fmt:.*"). - # see https://github.com/kisielk/errcheck#the-deprecated-method for details exclude-functions: fmt:.*,io/ioutil:^Read.*,io:Close - - # path to a file containing a list of functions to exclude from checking - # see https://github.com/kisielk/errcheck#excluding-functions for details - #exclude: /path/to/file.txt From 68c1536858ec49e26a501aa7f653c41f5706c16f Mon Sep 17 00:00:00 2001 From: Gerhard Lausser Date: Fri, 15 May 2026 01:11:11 +0200 Subject: [PATCH 09/12] ci: simplify golangci-lint v2 config to minimal valid shape The previous v2 migration kept output sub-settings and linters-settings that are not valid top-level keys in v2, causing exit code 3 (config parse failure). Strip to the essential sections: linters, issues, run. --- .golangci.yml | 16 ---------------- 1 file changed, 16 deletions(-) diff --git a/.golangci.yml b/.golangci.yml index e4be9f27..178d2588 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -23,7 +23,6 @@ issues: max-issues-per-linter: 0 max-same-issues: 0 - exclude-files: - ".*\\.hcl2spec\\.go$" @@ -42,18 +41,3 @@ linters: run: concurrency: 4 timeout: 10m - issues-exit-code: 1 - tests: true - -output: - formats: - - format: colored-line-number - print-issued-lines: true - print-linter-name: true - uniq-by-line: true - -linters-settings: - errcheck: - check-type-assertions: false - check-blank: false - exclude-functions: fmt:.*,io/ioutil:^Read.*,io:Close From 5a86583b3552007c2b9fd0a522acc40680c022d6 Mon Sep 17 00:00:00 2001 From: Gerhard Lausser Date: Fri, 15 May 2026 01:38:21 +0200 Subject: [PATCH 10/12] =?UTF-8?q?ci:=20fix=20golangci-lint=20v2=20config?= =?UTF-8?q?=20schema=20=E2=80=94=20move=20exclusions=20under=20linters?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In v2 the exclusion settings moved out of the top-level issues section: issues.exclude-rules → linters.exclusions.rules issues.exclude-files → linters.exclusions.paths The old keys cause a jsonschema validation error and prevent the linter from starting at all. --- .golangci.yml | 46 +++++++++++++++++++++++----------------------- 1 file changed, 23 insertions(+), 23 deletions(-) diff --git a/.golangci.yml b/.golangci.yml index 178d2588..aad4ea7b 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -3,29 +3,6 @@ version: "2" -issues: - exclude-rules: - # Exclude gosimple bool check - - linters: - - gosimple - text: "S(1002|1008|1021)" - # Exclude failing staticchecks for now - - linters: - - staticcheck - text: "SA(1006|1019|4006|4010|4017|5007|6005|9004):" - # Exclude lll issues for long lines with go:generate - - linters: - - lll - source: "^//go:generate " - - linters: - - errcheck - path: ".*_test.go" - - max-issues-per-linter: 0 - max-same-issues: 0 - exclude-files: - - ".*\\.hcl2spec\\.go$" - linters: default: none enable: @@ -37,6 +14,29 @@ linters: - staticcheck - unconvert - unused + exclusions: + paths: + - ".*\\.hcl2spec\\.go$" + rules: + # Exclude gosimple bool check + - linters: + - gosimple + text: "S(1002|1008|1021)" + # Exclude failing staticchecks for now + - linters: + - staticcheck + text: "SA(1006|1019|4006|4010|4017|5007|6005|9004):" + # Exclude lll issues for long lines with go:generate + - linters: + - lll + source: "^//go:generate " + - linters: + - errcheck + path: ".*_test\\.go" + +issues: + max-issues-per-linter: 0 + max-same-issues: 0 run: concurrency: 4 From e82412c824904512c8adaa1f40ce09cbd1828145 Mon Sep 17 00:00:00 2001 From: Gerhard Lausser Date: Fri, 15 May 2026 01:47:39 +0200 Subject: [PATCH 11/12] =?UTF-8?q?ci:=20fix=20golangci-lint=20v2=20config?= =?UTF-8?q?=20=E2=80=94=20gosimple=20merged=20into=20staticcheck,=20goimpo?= =?UTF-8?q?rts=20is=20a=20formatter?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In v2, gosimple was merged into staticcheck and goimports was reclassified as a formatter. Update the config accordingly: - remove gosimple from linters.enable (now covered by staticcheck) - move goimports to formatters.enable with its own exclusions block - update the gosimple S-code exclusion rule to use staticcheck --- .golangci.yml | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/.golangci.yml b/.golangci.yml index aad4ea7b..64d5675c 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -7,8 +7,6 @@ linters: default: none enable: - errcheck - - goimports - - gosimple - govet - ineffassign - staticcheck @@ -18,9 +16,9 @@ linters: paths: - ".*\\.hcl2spec\\.go$" rules: - # Exclude gosimple bool check + # Exclude gosimple bool checks (now part of staticcheck in v2) - linters: - - gosimple + - staticcheck text: "S(1002|1008|1021)" # Exclude failing staticchecks for now - linters: @@ -34,6 +32,13 @@ linters: - errcheck path: ".*_test\\.go" +formatters: + enable: + - goimports + exclusions: + paths: + - ".*\\.hcl2spec\\.go$" + issues: max-issues-per-linter: 0 max-same-issues: 0 From b3ccc31760b7ca931bd229e6f8dfa7bf6e95fb99 Mon Sep 17 00:00:00 2001 From: Gerhard Lausser Date: Sat, 16 May 2026 21:10:26 +0200 Subject: [PATCH 12/12] docs(builder): fix aarch64 worked example accuracy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four corrections to the iso.mdx aarch64 worked example: - boot_command: replace ISOLINUX boot: prompt sequence with the verified GRUB EFI edit-mode sequence (e, arrow navigation, F10). Debian arm64 netinst uses GRUB EFI, not ISOLINUX; the previous sequence was silently swallowed by the GRUB menu and the build would hang until ssh_timeout. Verified empirically against Debian 13.4.0 arm64 netinst on PVE 9.1.11. - boot_wait: 5s → 10s to give GRUB EFI time to render before keystrokes land. - pre_enrolled_keys: true → false; Secure Boot key enrollment breaks unsigned or self-signed bootloaders in template builds. - boot_iso: remove redundant iso_storage_pool (only meaningful with iso_url). Regenerate .web-docs/ to match. --- .web-docs/components/builder/iso/README.md | 21 ++++++++++++--------- docs/builders/iso.mdx | 21 ++++++++++++--------- 2 files changed, 24 insertions(+), 18 deletions(-) diff --git a/.web-docs/components/builder/iso/README.md b/.web-docs/components/builder/iso/README.md index 4554f167..0440b8fa 100644 --- a/.web-docs/components/builder/iso/README.md +++ b/.web-docs/components/builder/iso/README.md @@ -1257,7 +1257,7 @@ source "proxmox-iso" "debian-arm64" { efi_config { efi_storage_pool = "local-lvm" efi_type = "4m" - pre_enrolled_keys = true + pre_enrolled_keys = false } # Required companion: boot_command keystrokes need a serial console. @@ -1274,9 +1274,8 @@ source "proxmox-iso" "debian-arm64" { boot = "order=scsi0;scsi1" boot_iso { - iso_file = "local:iso/debian-13.1.0-arm64-netinst.iso" - iso_storage_pool = "local" - unmount = true + iso_file = "local:iso/debian-13.1.0-arm64-netinst.iso" + unmount = true # type defaults to "scsi" (no IDE on virt); index is auto-assigned. } @@ -1294,12 +1293,16 @@ source "proxmox-iso" "debian-arm64" { http_directory = "http" - boot_wait = "5s" + boot_wait = "10s" + # Debian arm64 netinst uses GRUB EFI (not ISOLINUX). Press 'e' to enter + # edit mode, navigate to the linux line, append preseed args, boot with F10. + # Three s land on the linux line of the Install entry — verified + # empirically against Debian 13 arm64 netinst on PVE 9. boot_command = [ - "", - "auto url=http://{{.HTTPIP}}:{{.HTTPPort}}/preseed.cfg ", - "console=ttyAMA0,115200 ", - "auto=true priority=critical interface=auto" + "e", + "", + " auto=true priority=critical url=http://{{.HTTPIP}}:{{.HTTPPort}}/preseed.cfg interface=auto", + "" ] # API-token credentials outlive long aarch64 builds. diff --git a/docs/builders/iso.mdx b/docs/builders/iso.mdx index 0fd60caf..11a55e46 100644 --- a/docs/builders/iso.mdx +++ b/docs/builders/iso.mdx @@ -313,7 +313,7 @@ source "proxmox-iso" "debian-arm64" { efi_config { efi_storage_pool = "local-lvm" efi_type = "4m" - pre_enrolled_keys = true + pre_enrolled_keys = false } # Required companion: boot_command keystrokes need a serial console. @@ -330,9 +330,8 @@ source "proxmox-iso" "debian-arm64" { boot = "order=scsi0;scsi1" boot_iso { - iso_file = "local:iso/debian-13.1.0-arm64-netinst.iso" - iso_storage_pool = "local" - unmount = true + iso_file = "local:iso/debian-13.1.0-arm64-netinst.iso" + unmount = true # type defaults to "scsi" (no IDE on virt); index is auto-assigned. } @@ -350,12 +349,16 @@ source "proxmox-iso" "debian-arm64" { http_directory = "http" - boot_wait = "5s" + boot_wait = "10s" + # Debian arm64 netinst uses GRUB EFI (not ISOLINUX). Press 'e' to enter + # edit mode, navigate to the linux line, append preseed args, boot with F10. + # Three s land on the linux line of the Install entry — verified + # empirically against Debian 13 arm64 netinst on PVE 9. boot_command = [ - "", - "auto url=http://{{.HTTPIP}}:{{.HTTPPort}}/preseed.cfg ", - "console=ttyAMA0,115200 ", - "auto=true priority=critical interface=auto" + "e", + "", + " auto=true priority=critical url=http://{{.HTTPIP}}:{{.HTTPPort}}/preseed.cfg interface=auto", + "" ] # API-token credentials outlive long aarch64 builds.