Skip to content

Commit

Permalink
feat: use existing IP for server create (#144)
Browse files Browse the repository at this point in the history
This PR adds the ability to specify an existing Primary IP in the
Hetzner Cloud project to be used for a packer build.

For short-lived IP addresses, Hetzner sometimes sends abuse
notifications to the wrong IP address user.

To avoid this, for most things I run in Hetzner Cloud, I use
pre-allocated Primary IPs, set to not auto-delete when the server is
removed, so that I have easier tracking of IP address allocation to
respond appropriately to Hetzner abuse notifications.

---------

Co-authored-by: Julian Tölle <julian.toelle97@gmail.com>
Co-authored-by: Jonas L. <jooola@users.noreply.github.com>
  • Loading branch information
3 people authored Jan 9, 2024
1 parent 230a5a6 commit 1ebdfe7
Show file tree
Hide file tree
Showing 6 changed files with 346 additions and 0 deletions.
6 changes: 6 additions & 0 deletions .web-docs/components/builder/hcloud/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,12 @@ builder.
- `networks` (array of integers) - List of Network IDs which should be
attached to the server private network interface at creation time.

- `public_ipv4` (string) - ID, name or IP address of a pre-allocated Hetzner
Primary IPv4 address to use for the created server.

- `public_ipv6` (string) - ID, name or IP address of a pre-allocated Hetzner
Primary IPv6 address to use for the created server.

## Basic Example

Here is a basic example. It is completely valid as soon as you enter your own
Expand Down
2 changes: 2 additions & 0 deletions builder/hcloud/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,8 @@ type Config struct {
SSHKeys []string `mapstructure:"ssh_keys"`
SSHKeysLabels map[string]string `mapstructure:"ssh_keys_labels"`
Networks []int64 `mapstructure:"networks"`
PublicIPv4 string `mapstructure:"public_ipv4"`
PublicIPv6 string `mapstructure:"public_ipv6"`

RescueMode string `mapstructure:"rescue"`

Expand Down
4 changes: 4 additions & 0 deletions builder/hcloud/config.hcl2spec.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

45 changes: 45 additions & 0 deletions builder/hcloud/step_create_server.go
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,34 @@ func (s *stepCreateServer) Run(ctx context.Context, state multistep.StateBag) mu
Labels: c.ServerLabels,
}

if c.PublicIPv4 != "" || c.PublicIPv6 != "" {
publicNetOpts := hcloud.ServerCreatePublicNet{
EnableIPv4: true,
EnableIPv6: true,
}
if c.PublicIPv4 != "" {
publicIPv4, msg, err := getPrimaryIP(ctx, client, c.PublicIPv4)
if err != nil {
return errorHandler(state, ui, msg, err)
}
if publicIPv4.Type != hcloud.PrimaryIPTypeIPv4 {
return errorHandler(state, ui, "", fmt.Errorf("Primary ip %s is not an IPv4 address", c.PublicIPv4))
}
publicNetOpts.IPv4 = publicIPv4
}
if c.PublicIPv6 != "" {
publicIPv6, msg, err := getPrimaryIP(ctx, client, c.PublicIPv6)
if err != nil {
return errorHandler(state, ui, msg, err)
}
if publicIPv6.Type != hcloud.PrimaryIPTypeIPv6 {
return errorHandler(state, ui, "", fmt.Errorf("Primary ip %s is not an IPv6 address", c.PublicIPv6))
}
publicNetOpts.IPv6 = publicIPv6
}
serverCreateOpts.PublicNet = &publicNetOpts
}

if c.UpgradeServerType != "" {
serverCreateOpts.StartAfterCreate = hcloud.Ptr(false)
}
Expand Down Expand Up @@ -241,3 +269,20 @@ func getImageWithSelectors(ctx context.Context, client *hcloud.Client, c *Config

return allImages[0], nil
}

func getPrimaryIP(ctx context.Context, client *hcloud.Client, publicIP string) (*hcloud.PrimaryIP, string, error) {
hcloudPublicIP, _, err := client.PrimaryIP.Get(ctx, publicIP)
if err != nil {
return nil, fmt.Sprintf("Could not fetch primary ip '%s'", publicIP), err
}
if hcloudPublicIP == nil {
hcloudPublicIP, _, err = client.PrimaryIP.GetByIP(ctx, publicIP)
if err != nil {
return nil, fmt.Sprintf("Could not fetch primary ip '%s'", publicIP), err
}
if hcloudPublicIP == nil {
return nil, "", fmt.Errorf("Could not find primary ip '%s'", publicIP)
}
}
return hcloudPublicIP, "", nil
}
283 changes: 283 additions & 0 deletions builder/hcloud/step_create_server_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ func TestStepCreateServer(t *testing.T) {
assert.Equal(t, "nbg1", payload.Location)
assert.Equal(t, "cpx11", payload.ServerType)
assert.Nil(t, payload.Networks)
assert.Nil(t, payload.PublicNet)
},
201, `{
"server": { "id": 8, "name": "dummy-server", "public_net": { "ipv4": { "ip": "1.2.3.4" }}},
Expand Down Expand Up @@ -116,5 +117,287 @@ func TestStepCreateServer(t *testing.T) {
assert.Equal(t, "1.2.3.4", serverIP)
},
},
{
Name: "happy with public ipv4 and ipv6 names",
Step: &stepCreateServer{},
SetupConfigFunc: func(c *Config) {
c.PublicIPv4 = "permanent-packer-ipv4"
c.PublicIPv6 = "permanent-packer-ipv6"
},
SetupStateFunc: func(state multistep.StateBag) {
state.Put(StateSSHKeyID, int64(1))
},
WantRequests: []Request{
{"GET", "/ssh_keys/1", nil,
200, `{
"ssh_key": { "id": 1 }
}`,
},
{"GET", "/primary_ips?name=permanent-packer-ipv4", nil,
200, `{
"primary_ips": [
{
"name": "permanent-packer-ipv4",
"id": 1,
"ip": "127.0.0.1",
"type": "ipv4"
}
]
}`,
},
{"GET", "/primary_ips?name=permanent-packer-ipv6", nil,
200, `{
"primary_ips": [
{
"name": "permanent-packer-ipv6",
"id": 2,
"ip": "::1",
"type": "ipv6"
}
]
}`,
},
{"POST", "/servers",
func(t *testing.T, r *http.Request, body []byte) {
payload := schema.ServerCreateRequest{}
assert.NoError(t, json.Unmarshal(body, &payload))
assert.Equal(t, "dummy-server", payload.Name)
assert.Equal(t, "debian-12", payload.Image)
assert.Equal(t, "nbg1", payload.Location)
assert.Equal(t, "cpx11", payload.ServerType)
assert.Nil(t, payload.Networks)
assert.NotNil(t, payload.PublicNet)
assert.Equal(t, int64(1), payload.PublicNet.IPv4ID)
assert.Equal(t, int64(2), payload.PublicNet.IPv6ID)
},
201, `{
"server": { "id": 8, "name": "dummy-server", "public_net": { "ipv4": { "ip": "127.0.0.1" }, "ipv6": { "ip": "::1" }}},
"action": { "id": 3, "status": "progress" }
}`,
},
{"GET", "/actions/3", nil,
200, `{
"action": { "id": 3, "status": "success" }
}`,
},
},
WantStepAction: multistep.ActionContinue,
WantStateFunc: func(t *testing.T, state multistep.StateBag) {
serverID, ok := state.Get(StateServerID).(int64)
assert.True(t, ok)
assert.Equal(t, int64(8), serverID)

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, "127.0.0.1", serverIP)
},
},
{
Name: "happy with public ipv4 and ipv6 addresses",
Step: &stepCreateServer{},
SetupConfigFunc: func(c *Config) {
c.PublicIPv4 = "127.0.0.1"
c.PublicIPv6 = "::1"
},
SetupStateFunc: func(state multistep.StateBag) {
state.Put(StateSSHKeyID, int64(1))
},
WantRequests: []Request{
{"GET", "/ssh_keys/1", nil,
200, `{
"ssh_key": { "id": 1 }
}`,
},
{"GET", "/primary_ips?name=127.0.0.1", nil,
200, `{ "primary_ips": [] }`,
},
{"GET", "/primary_ips?ip=127.0.0.1", nil,
200, `{
"primary_ips": [
{
"id": 1,
"ip": "127.0.0.1",
"type": "ipv4"
}
]
}`,
},
{"GET", "/primary_ips?name=%3A%3A1", nil,
200, `{ "primary_ips": [] }`,
},
{"GET", "/primary_ips?ip=%3A%3A1", nil,
200, `{
"primary_ips": [
{
"id": 2,
"ip": "::1",
"type": "ipv6"
}
]
}`,
},
{"POST", "/servers",
func(t *testing.T, r *http.Request, body []byte) {
payload := schema.ServerCreateRequest{}
assert.NoError(t, json.Unmarshal(body, &payload))
assert.Equal(t, "dummy-server", payload.Name)
assert.Equal(t, "debian-12", payload.Image)
assert.Equal(t, "nbg1", payload.Location)
assert.Equal(t, "cpx11", payload.ServerType)
assert.Nil(t, payload.Networks)
assert.NotNil(t, payload.PublicNet)
assert.Equal(t, int64(1), payload.PublicNet.IPv4ID)
assert.Equal(t, int64(2), payload.PublicNet.IPv6ID)
},
201, `{
"server": { "id": 8, "name": "dummy-server", "public_net": { "ipv4": { "ip": "127.0.0.1" }, "ipv6": { "ip": "::1" }}},
"action": { "id": 3, "status": "progress" }
}`,
},
{"GET", "/actions/3", nil,
200, `{
"action": { "id": 3, "status": "success" }
}`,
},
},
WantStepAction: multistep.ActionContinue,
WantStateFunc: func(t *testing.T, state multistep.StateBag) {
serverID, ok := state.Get(StateServerID).(int64)
assert.True(t, ok)
assert.Equal(t, int64(8), serverID)

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, "127.0.0.1", serverIP)
},
},
{
Name: "fail to get for primary ip by address",
Step: &stepCreateServer{},
SetupConfigFunc: func(c *Config) {
c.PublicIPv4 = "127.0.0.1"
},
SetupStateFunc: func(state multistep.StateBag) {
state.Put(StateSSHKeyID, int64(1))
},
WantRequests: []Request{
{"GET", "/ssh_keys/1", nil,
200, `{
"ssh_key": { "id": 1 }
}`,
},
{"GET", "/primary_ips?name=127.0.0.1", nil,
200, `{ "primary_ips": [] }`,
},
{"GET", "/primary_ips?ip=127.0.0.1", nil,
200, `{ "primary_ips": [] }`,
},
},
WantStepAction: multistep.ActionHalt,
WantStateFunc: func(t *testing.T, state multistep.StateBag) {
err, ok := state.Get(StateError).(error)
assert.True(t, ok)
assert.NotNil(t, err)
assert.Regexp(t, "Could not find primary ip .*", err.Error())
},
},
{
Name: "fail to search for primary ip by address",
Step: &stepCreateServer{},
SetupConfigFunc: func(c *Config) {
c.PublicIPv4 = "127.0.0.1"
},
SetupStateFunc: func(state multistep.StateBag) {
state.Put(StateSSHKeyID, int64(1))
},
WantRequests: []Request{
{"GET", "/ssh_keys/1", nil,
200, `{
"ssh_key": { "id": 1 }
}`,
},
{"GET", "/primary_ips?name=127.0.0.1", nil,
200, `{ "primary_ips": [] }`,
},
{"GET", "/primary_ips?ip=127.0.0.1", nil,
500, `{}`,
},
},
WantStepAction: multistep.ActionHalt,
WantStateFunc: func(t *testing.T, state multistep.StateBag) {
err, ok := state.Get(StateError).(error)
assert.True(t, ok)
assert.NotNil(t, err)
assert.Regexp(t, "Could not fetch primary ip .*", err.Error())
},
},
{
Name: "fail to get for primary ipv4 by address",
Step: &stepCreateServer{},
SetupConfigFunc: func(c *Config) {
c.PublicIPv4 = "127.0.0.1"
},
SetupStateFunc: func(state multistep.StateBag) {
state.Put(StateSSHKeyID, int64(1))
},
WantRequests: []Request{
{"GET", "/ssh_keys/1", nil,
200, `{
"ssh_key": { "id": 1 }
}`,
},
{"GET", "/primary_ips?name=127.0.0.1", nil,
200, `{ "primary_ips": [] }`,
},
{"GET", "/primary_ips?ip=127.0.0.1", nil,
200, `{ "primary_ips": [] }`,
},
},
WantStepAction: multistep.ActionHalt,
WantStateFunc: func(t *testing.T, state multistep.StateBag) {
err, ok := state.Get(StateError).(error)
assert.True(t, ok)
assert.NotNil(t, err)
assert.Regexp(t, "Could not find primary ip .*", err.Error())
},
},
{
Name: "fail to search for primary ipv4 by address",
Step: &stepCreateServer{},
SetupConfigFunc: func(c *Config) {
c.PublicIPv4 = "127.0.0.1"
},
SetupStateFunc: func(state multistep.StateBag) {
state.Put(StateSSHKeyID, int64(1))
},
WantRequests: []Request{
{"GET", "/ssh_keys/1", nil,
200, `{
"ssh_key": { "id": 1 }
}`,
},
{"GET", "/primary_ips?name=127.0.0.1", nil,
200, `{ "primary_ips": [] }`,
},
{"GET", "/primary_ips?ip=127.0.0.1", nil,
500, `{}`,
},
},
WantStepAction: multistep.ActionHalt,
WantStateFunc: func(t *testing.T, state multistep.StateBag) {
err, ok := state.Get(StateError).(error)
assert.True(t, ok)
assert.NotNil(t, err)
assert.Regexp(t, "Could not fetch primary ip .*", err.Error())
},
},
})
}
Loading

0 comments on commit 1ebdfe7

Please sign in to comment.