From aac9ecbf1880e1b3b862c444c42bdb0e1bfb70e4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bartosz=20Cie=C5=9Blik?= Date: Tue, 21 Jul 2026 17:48:20 +0300 Subject: [PATCH] feat: add possibility do select IP used to connect to server MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Bartosz Cieślik --- builder/hcloud/config.go | 9 +++ builder/hcloud/config.hcl2spec.go | 2 + builder/hcloud/config_test.go | 58 ++++++++++++++++ builder/hcloud/step_create_server.go | 51 ++++++++++++-- builder/hcloud/step_create_server_test.go | 85 +++++++++++++++++++---- docs/builders/hcloud.mdx | 8 +++ 6 files changed, 193 insertions(+), 20 deletions(-) create mode 100644 builder/hcloud/config_test.go diff --git a/builder/hcloud/config.go b/builder/hcloud/config.go index 27b28622..37fb3c28 100644 --- a/builder/hcloud/config.go +++ b/builder/hcloud/config.go @@ -49,6 +49,7 @@ type Config struct { SSHKeysLabels map[string]string `mapstructure:"ssh_keys_labels"` Networks []int64 `mapstructure:"networks"` + SSHInterface string `mapstructure:"ssh_interface"` PublicIPv4 string `mapstructure:"public_ipv4"` PublicIPv4Disabled bool `mapstructure:"public_ipv4_disabled"` PublicIPv6 string `mapstructure:"public_ipv6"` @@ -153,6 +154,14 @@ func (c *Config) Prepare(raws ...interface{}) ([]string, error) { errs, fmt.Errorf("user_data_file not found: %s", c.UserDataFile)) } } + if c.SSHInterface != "" { + switch c.SSHInterface { + case "public_ipv4", "public_ipv6", "private_ipv4": + default: + errs = packersdk.MultiErrorAppend( + errs, errors.New("ssh_interface must be one of public_ipv4, public_ipv6, or private_ipv4")) + } + } if errs != nil && len(errs.Errors) > 0 { return nil, errs diff --git a/builder/hcloud/config.hcl2spec.go b/builder/hcloud/config.hcl2spec.go index 3bd0fc59..db09dd00 100644 --- a/builder/hcloud/config.hcl2spec.go +++ b/builder/hcloud/config.hcl2spec.go @@ -85,6 +85,7 @@ type FlatConfig struct { SSHKeys []string `mapstructure:"ssh_keys" cty:"ssh_keys" hcl:"ssh_keys"` SSHKeysLabels map[string]string `mapstructure:"ssh_keys_labels" cty:"ssh_keys_labels" hcl:"ssh_keys_labels"` Networks []int64 `mapstructure:"networks" cty:"networks" hcl:"networks"` + SSHInterface *string `mapstructure:"ssh_interface" cty:"ssh_interface" hcl:"ssh_interface"` PublicIPv4 *string `mapstructure:"public_ipv4" cty:"public_ipv4" hcl:"public_ipv4"` PublicIPv4Disabled *bool `mapstructure:"public_ipv4_disabled" cty:"public_ipv4_disabled" hcl:"public_ipv4_disabled"` PublicIPv6 *string `mapstructure:"public_ipv6" cty:"public_ipv6" hcl:"public_ipv6"` @@ -180,6 +181,7 @@ func (*FlatConfig) HCL2Spec() map[string]hcldec.Spec { "ssh_keys": &hcldec.AttrSpec{Name: "ssh_keys", Type: cty.List(cty.String), Required: false}, "ssh_keys_labels": &hcldec.AttrSpec{Name: "ssh_keys_labels", Type: cty.Map(cty.String), Required: false}, "networks": &hcldec.AttrSpec{Name: "networks", Type: cty.List(cty.Number), Required: false}, + "ssh_interface": &hcldec.AttrSpec{Name: "ssh_interface", Type: cty.String, Required: false}, "public_ipv4": &hcldec.AttrSpec{Name: "public_ipv4", Type: cty.String, Required: false}, "public_ipv4_disabled": &hcldec.AttrSpec{Name: "public_ipv4_disabled", Type: cty.Bool, Required: false}, "public_ipv6": &hcldec.AttrSpec{Name: "public_ipv6", Type: cty.String, Required: false}, diff --git a/builder/hcloud/config_test.go b/builder/hcloud/config_test.go new file mode 100644 index 00000000..133e8f6d --- /dev/null +++ b/builder/hcloud/config_test.go @@ -0,0 +1,58 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package hcloud + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestConfigPrepareSSHInterface(t *testing.T) { + testCases := []struct { + name string + sshInterface string + wantErr string + }{ + { + name: "public_ipv4", + sshInterface: "public_ipv4", + }, + { + name: "public_ipv6", + sshInterface: "public_ipv6", + }, + { + name: "private_ipv4", + sshInterface: "private_ipv4", + }, + { + name: "invalid", + sshInterface: "public", + wantErr: "ssh_interface must be one of public_ipv4, public_ipv6, or private_ipv4", + }, + } + + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + c := &Config{} + _, err := c.Prepare(map[string]interface{}{ + "token": "dummy-token", + "image": "debian-12", + "location": "nbg1", + "server_type": "cpx22", + "ssh_username": "root", + "ssh_interface": testCase.sshInterface, + }) + + if testCase.wantErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), testCase.wantErr) + return + } + require.NoError(t, err) + }) + } +} diff --git a/builder/hcloud/step_create_server.go b/builder/hcloud/step_create_server.go index e7c16a44..27ac68eb 100644 --- a/builder/hcloud/step_create_server.go +++ b/builder/hcloud/step_create_server.go @@ -160,7 +160,7 @@ func (s *stepCreateServer) Run(ctx context.Context, state multistep.StateBag) mu // instance id inside of the provisioners, used in step_provision. state.Put(StateInstanceID, server.ID) - serverIP := firstAvailableIP(server) + serverIP := selectedServerIP(server, c.SSHInterface) if serverIP == "" { return errorHandler(state, ui, "", fmt.Errorf("Could not find available ip")) } @@ -325,18 +325,55 @@ func getPrimaryIP(ctx context.Context, client *hcloud.Client, publicIP string) ( func firstAvailableIP(server *hcloud.Server) string { switch { case !server.PublicNet.IPv4.IsUnspecified(): - return server.PublicNet.IPv4.IP.String() + return publicIPv4(server) case !server.PublicNet.IPv6.IsUnspecified(): - network, ok := netip.AddrFromSlice(server.PublicNet.IPv6.IP) - if ok { - return network.Next().String() - } + return publicIPv6(server) case len(server.PrivateNet) > 0: - return server.PrivateNet[0].IP.String() + return privateIPv4(server) } return "" } +func selectedServerIP(server *hcloud.Server, sshInterface string) string { + switch sshInterface { + case "": + return firstAvailableIP(server) + case "public_ipv4": + return publicIPv4(server) + case "public_ipv6": + return publicIPv6(server) + case "private_ipv4": + return privateIPv4(server) + default: + return "" + } +} + +func publicIPv4(server *hcloud.Server) string { + if server.PublicNet.IPv4.IsUnspecified() { + return "" + } + return server.PublicNet.IPv4.IP.String() +} + +func publicIPv6(server *hcloud.Server) string { + if server.PublicNet.IPv6.IsUnspecified() { + return "" + } + network, ok := netip.AddrFromSlice(server.PublicNet.IPv6.IP) + if !ok { + return "" + } + return network.Next().String() +} + +func privateIPv4(server *hcloud.Server) string { + if len(server.PrivateNet) == 0 { + return "" + } + return server.PrivateNet[0].IP.String() +} + func getServerRunningActions(ctx context.Context, client *hcloud.Client, server *hcloud.Server) ([]*hcloud.Action, error) { actions, err := client.Firewall.Action.All(ctx, hcloud.ActionListOpts{ diff --git a/builder/hcloud/step_create_server_test.go b/builder/hcloud/step_create_server_test.go index 862aa48e..16b10eba 100644 --- a/builder/hcloud/step_create_server_test.go +++ b/builder/hcloud/step_create_server_test.go @@ -87,10 +87,11 @@ func TestStepCreateServer(t *testing.T) { }, }, { - Name: "happy with firewall", + Name: "happy with firewall and public ipv6 communicator", Step: &stepCreateServer{}, SetupConfigFunc: func(c *Config) { c.Firewalls = []string{"allow-ssh"} + c.SSHInterface = "public_ipv6" }, SetupStateFunc: func(state multistep.StateBag) { state.Put(StateSSHKeyID, int64(1)) @@ -126,7 +127,7 @@ func TestStepCreateServer(t *testing.T) { }, Status: 201, JSONRaw: `{ - "server": { "id": 8, "name": "dummy-server", "public_net": { "ipv4": { "ip": "127.0.0.1" }, "ipv6": { "ip": "::1" }}}, + "server": { "id": 8, "name": "dummy-server", "public_net": { "ipv4": { "ip": "127.0.0.1" }, "ipv6": { "ip": "2a01:4f8:1c19:1403::/64" }}}, "action": { "id": 3, "status": "running" } }`, }, @@ -160,6 +161,10 @@ func TestStepCreateServer(t *testing.T) { instanceID, ok := state.Get(StateInstanceID).(int64) assert.True(t, ok) assert.Equal(t, int64(8), instanceID) + + serverIP, ok := state.Get(StateServerIP).(string) + assert.True(t, ok) + assert.Equal(t, "2a01:4f8:1c19:1403::1", serverIP) }, }, { @@ -631,6 +636,15 @@ func TestStepCreateServer(t *testing.T) { } func TestFirstAvailableIP(t *testing.T) { + server := &hcloud.Server{ + PublicNet: hcloud.ServerPublicNetFromSchema(schema.ServerPublicNet{ + IPv4: schema.ServerPublicNetIPv4{ID: 1, IP: "1.2.3.4"}, + IPv6: schema.ServerPublicNetIPv6{ID: 2, IP: "2a01:4f8:1c19:1403::/64"}, + }), + PrivateNet: []hcloud.ServerPrivateNet{ + hcloud.ServerPrivateNetFromSchema(schema.ServerPrivateNet{Network: 3, IP: "10.0.0.1"}), + }, + } testCases := []struct { name string server *hcloud.Server @@ -642,17 +656,9 @@ func TestFirstAvailableIP(t *testing.T) { want: "", }, { - name: "public_ipv4", - server: &hcloud.Server{ - PublicNet: hcloud.ServerPublicNetFromSchema(schema.ServerPublicNet{ - IPv4: schema.ServerPublicNetIPv4{ID: 1, IP: "1.2.3.4"}, - IPv6: schema.ServerPublicNetIPv6{ID: 2, IP: "2a01:4f8:1c19:1403::/64"}, - }), - PrivateNet: []hcloud.ServerPrivateNet{ - hcloud.ServerPrivateNetFromSchema(schema.ServerPrivateNet{Network: 3, IP: "10.0.0.1"}), - }, - }, - want: "1.2.3.4", + name: "public_ipv4", + server: server, + want: "1.2.3.4", }, { name: "public_ipv6", @@ -683,3 +689,56 @@ func TestFirstAvailableIP(t *testing.T) { }) } } + +func TestSelectedServerIP(t *testing.T) { + server := &hcloud.Server{ + PublicNet: hcloud.ServerPublicNetFromSchema(schema.ServerPublicNet{ + IPv4: schema.ServerPublicNetIPv4{ID: 1, IP: "1.2.3.4"}, + IPv6: schema.ServerPublicNetIPv6{ID: 2, IP: "2a01:4f8:1c19:1403::/64"}, + }), + PrivateNet: []hcloud.ServerPrivateNet{ + hcloud.ServerPrivateNetFromSchema(schema.ServerPrivateNet{Network: 3, IP: "10.0.0.1"}), + }, + } + testCases := []struct { + name string + sshInterface string + want string + }{ + { + name: "default", + sshInterface: "", + want: "1.2.3.4", + }, + { + name: "public_ipv4", + sshInterface: "public_ipv4", + want: "1.2.3.4", + }, + { + name: "public_ipv6", + sshInterface: "public_ipv6", + want: "2a01:4f8:1c19:1403::1", + }, + { + name: "private_ipv4", + sshInterface: "private_ipv4", + want: "10.0.0.1", + }, + { + name: "missing selected ip", + sshInterface: "private_ipv4", + want: "", + }, + } + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + testServer := server + if testCase.name == "missing selected ip" { + testServer = &hcloud.Server{} + } + result := selectedServerIP(testServer, testCase.sshInterface) + assert.Equal(t, testCase.want, result) + }) + } +} diff --git a/docs/builders/hcloud.mdx b/docs/builders/hcloud.mdx index d6be1bdb..1e7865cc 100644 --- a/docs/builders/hcloud.mdx +++ b/docs/builders/hcloud.mdx @@ -32,6 +32,9 @@ The builder will connect to the server using the first available IP, in the foll - `private_ipv4`: If the server is attached to private networks, the private IPv4 of the first private network will be used. +You can override this automatic selection with `ssh_interface`. Valid values +are `public_ipv4`, `public_ipv6`, and `private_ipv4`. + ## Configuration Reference There are many configuration options available for the builder. They are @@ -132,6 +135,11 @@ builder. - `networks` (array of integers) - List of Network IDs which should be attached to the server private network interface at creation time. +- `ssh_interface` (string) - Selects which server IP address the communicator + should use. Valid values are `public_ipv4`, `public_ipv6`, and + `private_ipv4`. When unset, the builder uses the first available IP in the + default order described above. + - `public_ipv4` (string) - ID, name or IP address of a pre-allocated Hetzner Primary IPv4 address to use for the created server.