Repository: hetznercloud/hcloud-cloud-controller-manager
Pull request #385 (2023-03-09 12:20:20)

feat: add InstancesV2 interface
The new interface is way simpler than the previous `Instances`+`Zones` interfaces and promises a lower Cloud API request rate.

Related to #384 


Patch:
diff --git a/hcloud/cloud.go b/hcloud/cloud.go
index 273f7228..fc547c54 100644
--- a/hcloud/cloud.go
+++ b/hcloud/cloud.go
@@ -58,7 +58,6 @@ const (
 type cloud struct {
 	client       *hcloud.Client
 	instances    *instances
-	zones        *zones
 	routes       *routes
 	loadBalancer *loadBalancers
 	networkID    int
@@ -163,7 +162,6 @@ func newCloud(config io.Reader) (cloudprovider.Interface, error) {
 
 	return &cloud{
 		client:       client,
-		zones:        newZones(client, nodeName),
 		instances:    newInstances(client, instancesAddressFamily),
 		loadBalancer: loadBalancers,
 		routes:       nil,
@@ -175,17 +173,17 @@ func (c *cloud) Initialize(clientBuilder cloudprovider.ControllerClientBuilder,
 }
 
 func (c *cloud) Instances() (cloudprovider.Instances, bool) {
-	return c.instances, true
+	// Replaced by InstancesV2
+	return nil, false
 }
 
 func (c *cloud) InstancesV2() (cloudprovider.InstancesV2, bool) {
-	// TODO disable InstancesV2 for now. We first need to implement it and
-	// find out what to do about the Deprecation of Zones
-	return nil, false
+	return c.instances, true
 }
 
 func (c *cloud) Zones() (cloudprovider.Zones, bool) {
-	return c.zones, true
+	// Replaced by InstancesV2
+	return nil, false
 }
 
 func (c *cloud) LoadBalancer() (cloudprovider.LoadBalancer, bool) {
diff --git a/hcloud/instances.go b/hcloud/instances.go
index 9d80a1a7..8aeef934 100644
--- a/hcloud/instances.go
+++ b/hcloud/instances.go
@@ -20,12 +20,10 @@ import (
 	"context"
 	"fmt"
 	"os"
-	"strconv"
 
 	"github.com/hetznercloud/hcloud-cloud-controller-manager/internal/metrics"
 	"github.com/hetznercloud/hcloud-go/hcloud"
 	v1 "k8s.io/api/core/v1"
-	"k8s.io/apimachinery/pkg/types"
 	cloudprovider "k8s.io/cloud-provider"
 )
 
@@ -46,120 +44,71 @@ func newInstances(client *hcloud.Client, addressFamily addressFamily) *instances
 	return &instances{client, addressFamily}
 }
 
-func (i *instances) NodeAddressesByProviderID(ctx context.Context, providerID string) ([]v1.NodeAddress, error) {
-	const op = "hcloud/instances.NodeAddressesByProviderID"
-	metrics.OperationCalled.WithLabelValues(op).Inc()
-
-	id, err := providerIDToServerID(providerID)
-	if err != nil {
-		return nil, fmt.Errorf("%s: %w", op, err)
-	}
-
-	server, err := getServerByID(ctx, i.client, id)
-	if err != nil {
-		return nil, fmt.Errorf("%s: %w", op, err)
-	}
-	return i.nodeAddresses(ctx, server), nil
-}
-
-func (i *instances) NodeAddresses(ctx context.Context, nodeName types.NodeName) ([]v1.NodeAddress, error) {
-	const op = "hcloud/instances.NodeAddresses"
-	metrics.OperationCalled.WithLabelValues(op).Inc()
-
-	server, err := getServerByName(ctx, i.client, string(nodeName))
-	if err != nil {
-		return nil, fmt.Errorf("%s: %w", op, err)
-	}
-	return i.nodeAddresses(ctx, server), nil
-}
-
-func (i *instances) ExternalID(ctx context.Context, nodeName types.NodeName) (string, error) {
-	const op = "hcloud/instances.ExternalID"
-	metrics.OperationCalled.WithLabelValues(op).Inc()
-
-	id, err := i.InstanceID(ctx, nodeName)
-	if err != nil {
-		return "", fmt.Errorf("%s: %w", op, err)
-	}
-	return id, nil
-}
-
-func (i *instances) InstanceID(ctx context.Context, nodeName types.NodeName) (string, error) {
-	const op = "hcloud/instances.InstanceID"
-	metrics.OperationCalled.WithLabelValues(op).Inc()
-
-	server, err := getServerByName(ctx, i.client, string(nodeName))
-	if err != nil {
-		return "", fmt.Errorf("%s: %w", op, err)
-	}
-	return strconv.Itoa(server.ID), nil
-}
-
-func (i *instances) InstanceType(ctx context.Context, nodeName types.NodeName) (string, error) {
-	const op = "hcloud/instances.InstanceType"
-	metrics.OperationCalled.WithLabelValues(op).Inc()
+// lookupServer attempts to locate the corresponding hcloud.Server for a given v1.Node
+// It returns an error if the Node has an invalid provider ID or if API requests failed.
+// It can return a nil hcloud.Server if no
+func (i *instances) lookupServer(ctx context.Context, node *v1.Node) (*hcloud.Server, error) {
+	var server *hcloud.Server
+	if node.Spec.ProviderID != "" {
+		serverID, err := providerIDToServerID(node.Spec.ProviderID)
+		if err != nil {
+			return nil, fmt.Errorf("failed to convert provider id to server id: %w", err)
+		}
 
-	server, err := getServerByName(ctx, i.client, string(nodeName))
-	if err != nil {
-		return "", fmt.Errorf("%s: %w", op, err)
+		server, _, err = i.client.Server.GetByID(ctx, serverID)
+		if err != nil {
+			return nil, fmt.Errorf("failed to lookup server \"%d\": %w", serverID, err)
+		}
+	} else {
+		var err error
+		server, _, err = i.client.Server.GetByName(ctx, node.Name)
+		if err != nil {
+			return nil, fmt.Errorf("failed to lookup server \"%s\": %w", node.Name, err)
+		}
 	}
-	return server.ServerType.Name, nil
+	return server, nil
 }
 
-func (i *instances) InstanceTypeByProviderID(ctx context.Context, providerID string) (string, error) {
-	const op = "hcloud/instances.InstanceTypeByProviderID"
+func (i *instances) InstanceExists(ctx context.Context, node *v1.Node) (bool, error) {
+	const op = "hcloud/instancesv2.InstanceExists"
 	metrics.OperationCalled.WithLabelValues(op).Inc()
 
-	id, err := providerIDToServerID(providerID)
-	if err != nil {
-		return "", fmt.Errorf("%s: %w", op, err)
-	}
-
-	server, err := getServerByID(ctx, i.client, id)
+	server, err := i.lookupServer(ctx, node)
 	if err != nil {
-		return "", fmt.Errorf("%s: %w", op, err)
+		return false, err
 	}
-	return server.ServerType.Name, nil
-}
 
-func (i *instances) AddSSHKeyToAllInstances(ctx context.Context, user string, keyData []byte) error {
-	return cloudprovider.NotImplemented
-}
-
-func (i *instances) CurrentNodeName(ctx context.Context, hostname string) (types.NodeName, error) {
-	return types.NodeName(hostname), nil
+	return server != nil, nil
 }
 
-func (i instances) InstanceExistsByProviderID(ctx context.Context, providerID string) (bool, error) {
-	const op = "hcloud/instances.InstanceExistsByProviderID"
+func (i *instances) InstanceShutdown(ctx context.Context, node *v1.Node) (bool, error) {
+	const op = "hcloud/instancesv2.InstanceShutdown"
 	metrics.OperationCalled.WithLabelValues(op).Inc()
 
-	id, err := providerIDToServerID(providerID)
+	server, err := i.lookupServer(ctx, node)
 	if err != nil {
-		return false, fmt.Errorf("%s: %w", op, err)
+		return false, err
 	}
 
-	server, _, err := i.client.Server.GetByID(ctx, id)
-	if err != nil {
-		return false, fmt.Errorf("%s: %w", op, err)
-	}
-	return server != nil, nil
+	return server.Status == hcloud.ServerStatusOff, nil
 }
 
-func (i instances) InstanceShutdownByProviderID(ctx context.Context, providerID string) (bool, error) {
-	const op = "hcloud/instances.InstanceShutdownByProviderID"
+func (i *instances) InstanceMetadata(ctx context.Context, node *v1.Node) (*cloudprovider.InstanceMetadata, error) {
+	const op = "hcloud/instancesv2.InstanceMetadata"
 	metrics.OperationCalled.WithLabelValues(op).Inc()
 
-	id, err := providerIDToServerID(providerID)
+	server, err := i.lookupServer(ctx, node)
 	if err != nil {
-		return false, fmt.Errorf("%s: %w", op, err)
+		return nil, err
 	}
 
-	server, _, err := i.client.Server.GetByID(ctx, id)
-	if err != nil {
-		return false, fmt.Errorf("%s: %w", op, err)
-	}
-	return server != nil && server.Status == hcloud.ServerStatusOff, nil
+	return &cloudprovider.InstanceMetadata{
+		ProviderID:    serverIDToProviderID(server.ID),
+		InstanceType:  server.ServerType.Name,
+		NodeAddresses: i.nodeAddresses(ctx, server),
+		Zone:          server.Datacenter.Name,
+		Region:        server.Datacenter.Location.Name,
+	}, nil
 }
 
 func (i *instances) nodeAddresses(ctx context.Context, server *hcloud.Server) []v1.NodeAddress {
@@ -170,7 +119,7 @@ func (i *instances) nodeAddresses(ctx context.Context, server *hcloud.Server) []
 	)
 
 	if i.addressFamily == AddressFamilyIPv4 || i.addressFamily == AddressFamilyDualStack {
-		if !server.PublicNet.IPv4.IP.IsUnspecified() {
+		if !server.PublicNet.IPv4.IsUnspecified() {
 			addresses = append(
 				addresses,
 				v1.NodeAddress{Type: v1.NodeExternalIP, Address: server.PublicNet.IPv4.IP.String()},
@@ -179,14 +128,14 @@ func (i *instances) nodeAddresses(ctx context.Context, server *hcloud.Server) []
 	}
 
 	if i.addressFamily == AddressFamilyIPv6 || i.addressFamily == AddressFamilyDualStack {
-		if !server.PublicNet.IPv6.IP.IsUnspecified() {
+		if !server.PublicNet.IPv6.IsUnspecified() {
 			// For a given IPv6 network of 2001:db8:1234::/64, the instance address is 2001:db8:1234::1
-			host_address := server.PublicNet.IPv6.IP
-			host_address[len(host_address)-1] |= 0x01
+			hostAddress := server.PublicNet.IPv6.IP
+			hostAddress[len(hostAddress)-1] |= 0x01
 
 			addresses = append(
 				addresses,
-				v1.NodeAddress{Type: v1.NodeExternalIP, Address: host_address.String()},
+				v1.NodeAddress{Type: v1.NodeExternalIP, Address: hostAddress.String()},
 			)
 		}
 	}
diff --git a/hcloud/util.go b/hcloud/util.go
index e0aef2b0..df59a74b 100644
--- a/hcloud/util.go
+++ b/hcloud/util.go
@@ -79,3 +79,7 @@ func providerIDToServerID(providerID string) (int, error) {
 	}
 	return id, nil
 }
+
+func serverIDToProviderID(serverID int) string {
+	return fmt.Sprintf("%s://%d", providerName, serverID)
+}
diff --git a/hcloud/zones.go b/hcloud/zones.go
deleted file mode 100644
index 4a072558..00000000
--- a/hcloud/zones.go
+++ /dev/null
@@ -1,83 +0,0 @@
-/*
-Copyright 2018 Hetzner Cloud GmbH.
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-    http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
-*/
-
-package hcloud
-
-import (
-	"context"
-	"fmt"
-
-	"github.com/hetznercloud/hcloud-cloud-controller-manager/internal/metrics"
-	"github.com/hetznercloud/hcloud-go/hcloud"
-	"k8s.io/apimachinery/pkg/types"
-	cloudprovider "k8s.io/cloud-provider"
-)
-
-type zones struct {
-	client   *hcloud.Client
-	nodeName string // name of the node the programm is running on
-}
-
-func newZones(client *hcloud.Client, nodeName string) *zones {
-	return &zones{client, nodeName}
-}
-
-func (z zones) GetZone(ctx context.Context) (cloudprovider.Zone, error) {
-	const op = "hcloud/zones.GetZone"
-	metrics.OperationCalled.WithLabelValues(op).Inc()
-
-	server, err := getServerByName(ctx, z.client, z.nodeName)
-	if err != nil {
-		return cloudprovider.Zone{}, fmt.Errorf("%s: %w", op, err)
-	}
-	return zoneFromServer(server), nil
-}
-
-func (z zones) GetZoneByProviderID(ctx context.Context, providerID string) (cloudprovider.Zone, error) {
-	const op = "hcloud/zones.GetZoneByProviderID"
-	metrics.OperationCalled.WithLabelValues(op).Inc()
-
-	id, err := providerIDToServerID(providerID)
-	if err != nil {
-		return cloudprovider.Zone{}, fmt.Errorf("%s: %w", op, err)
-	}
-
-	server, err := getServerByID(ctx, z.client, id)
-	if err != nil {
-		return cloudprovider.Zone{}, fmt.Errorf("%s: %w", op, err)
-	}
-
-	return zoneFromServer(server), nil
-}
-
-func (z zones) GetZoneByNodeName(ctx context.Context, nodeName types.NodeName) (cloudprovider.Zone, error) {
-	const op = "hcloud/zones.GetZoneByNodeName"
-	metrics.OperationCalled.WithLabelValues(op).Inc()
-
-	server, err := getServerByName(ctx, z.client, string(nodeName))
-	if err != nil {
-		return cloudprovider.Zone{}, fmt.Errorf("%s: %w", op, err)
-	}
-
-	return zoneFromServer(server), nil
-}
-
-func zoneFromServer(server *hcloud.Server) cloudprovider.Zone {
-	return cloudprovider.Zone{
-		Region:        server.Datacenter.Location.Name,
-		FailureDomain: server.Datacenter.Name,
-	}
-}


Test patch:
diff --git a/hcloud/cloud_test.go b/hcloud/cloud_test.go
index 5bac7520..b94ca82c 100644
--- a/hcloud/cloud_test.go
+++ b/hcloud/cloud_test.go
@@ -206,15 +206,22 @@ func TestCloud(t *testing.T) {
 
 	t.Run("Instances", func(t *testing.T) {
 		_, supported := cloud.Instances()
-		if !supported {
-			t.Error("Instances interface should be supported")
+		if supported {
+			t.Error("Instances interface should not be supported")
 		}
 	})
 
 	t.Run("Zones", func(t *testing.T) {
 		_, supported := cloud.Zones()
+		if supported {
+			t.Error("Zones interface should not be supported")
+		}
+	})
+
+	t.Run("InstancesV2", func(t *testing.T) {
+		_, supported := cloud.InstancesV2()
 		if !supported {
-			t.Error("Zones interface should be supported")
+			t.Error("InstancesV2 interface should be supported")
 		}
 	})
 
diff --git a/hcloud/instances_test.go b/hcloud/instances_test.go
index 7565f061..66e4cb2e 100644
--- a/hcloud/instances_test.go
+++ b/hcloud/instances_test.go
@@ -19,367 +19,383 @@ package hcloud
 import (
 	"context"
 	"encoding/json"
+	"net"
 	"net/http"
+	"os"
+	"reflect"
 	"testing"
 
 	"github.com/hetznercloud/hcloud-go/hcloud"
 	"github.com/hetznercloud/hcloud-go/hcloud/schema"
 	v1 "k8s.io/api/core/v1"
+	cloudprovider "k8s.io/cloud-provider"
 )
 
-func TestNodeAddressesByProviderID(t *testing.T) {
+// TestInstances_InstanceExists also tests [lookupServer]. The other tests
+// [instances] rely on these tests and only test their additional features.
+func TestInstances_InstanceExists(t *testing.T) {
 	env := newTestEnv()
 	defer env.Teardown()
 	env.Mux.HandleFunc("/servers/1", func(w http.ResponseWriter, r *http.Request) {
 		json.NewEncoder(w).Encode(schema.ServerGetResponse{
 			Server: schema.Server{
 				ID:   1,
-				Name: "node15",
-				PublicNet: schema.ServerPublicNet{
-					IPv6: schema.ServerPublicNetIPv6{
-						IP: "2001:db8:1234::/64",
-					},
-					IPv4: schema.ServerPublicNetIPv4{
-						IP: "131.232.99.1",
-					},
-				},
+				Name: "foobar",
 			},
 		})
 	})
-
-	instances := newInstances(env.Client, AddressFamilyIPv4)
-	addr, err := instances.NodeAddressesByProviderID(context.TODO(), "hcloud://1")
-	if err != nil {
-		t.Fatalf("Unexpected error: %v", err)
-	}
-	if len(addr) != 2 ||
-		addr[0].Type != v1.NodeHostName || addr[0].Address != "node15" ||
-		addr[1].Type != v1.NodeExternalIP || addr[1].Address != "131.232.99.1" {
-		t.Errorf("Unexpected node addresses: %v", addr)
-	}
-}
-
-func TestNodeAddresses(t *testing.T) {
-	env := newTestEnv()
-	defer env.Teardown()
+	env.Mux.HandleFunc("/servers/2", func(w http.ResponseWriter, r *http.Request) {
+		w.Header().Set("Content-Type", "application/json")
+		w.WriteHeader(http.StatusNotFound)
+		json.NewEncoder(w).Encode(schema.ErrorResponse{Error: schema.Error{Code: string(hcloud.ErrorCodeNotFound)}})
+	})
 	env.Mux.HandleFunc("/servers", func(w http.ResponseWriter, r *http.Request) {
-		if r.URL.RawQuery != "name=node15" {
-			t.Fatal("missing name query")
+		var servers []schema.Server
+		if r.URL.RawQuery == "name=foobar" {
+			servers = append(servers, schema.Server{ID: 1, Name: "foobar"})
 		}
-		json.NewEncoder(w).Encode(schema.ServerListResponse{
-			Servers: []schema.Server{
-				{
-					ID:   1,
-					Name: "node15",
-					PublicNet: schema.ServerPublicNet{
-						IPv6: schema.ServerPublicNetIPv6{
-							IP: "2001:db8:1234::",
-						},
-						IPv4: schema.ServerPublicNetIPv4{
-							IP: "131.232.99.1",
-						},
-					},
-				},
-			},
-		})
+		json.NewEncoder(w).Encode(schema.ServerListResponse{Servers: servers})
 	})
 
 	instances := newInstances(env.Client, AddressFamilyIPv4)
-	addr, err := instances.NodeAddresses(context.TODO(), "node15")
-	if err != nil {
-		t.Fatalf("Unexpected error: %v", err)
-	}
-	if len(addr) != 2 ||
-		addr[0].Type != v1.NodeHostName || addr[0].Address != "node15" ||
-		addr[1].Type != v1.NodeExternalIP || addr[1].Address != "131.232.99.1" {
-		t.Errorf("Unexpected node addresses: %v", addr)
-	}
-}
 
-func TestNodeAddressesIPv6(t *testing.T) {
-	env := newTestEnv()
-	defer env.Teardown()
-	env.Mux.HandleFunc("/servers", func(w http.ResponseWriter, r *http.Request) {
-		if r.URL.RawQuery != "name=node15" {
-			t.Fatal("missing name query")
-		}
-		json.NewEncoder(w).Encode(schema.ServerListResponse{
-			Servers: []schema.Server{
-				{
-					ID:   1,
-					Name: "node15",
-					PublicNet: schema.ServerPublicNet{
-						IPv6: schema.ServerPublicNetIPv6{
-							IP: "2001:db8:1234::/64",
-						},
-						IPv4: schema.ServerPublicNetIPv4{
-							IP: "131.232.99.1",
-						},
-					},
-				},
+	tests := []struct {
+		name     string
+		node     *v1.Node
+		expected bool
+	}{
+		{
+			name: "existing server by id",
+			node: &v1.Node{
+				Spec: v1.NodeSpec{ProviderID: "hcloud://1"},
 			},
-		})
-	})
-
-	instances := newInstances(env.Client, AddressFamilyIPv6)
-	addr, err := instances.NodeAddresses(context.TODO(), "node15")
-	if err != nil {
-		t.Fatalf("Unexpected error: %v", err)
-	}
-	if len(addr) != 2 ||
-		addr[0].Type != v1.NodeHostName || addr[0].Address != "node15" ||
-		addr[1].Type != v1.NodeExternalIP || addr[1].Address != "2001:db8:1234::1" {
-		t.Errorf("Unexpected node addresses: %v", addr)
+			expected: true,
+		}, {
+			name: "missing server by id",
+			node: &v1.Node{
+				Spec: v1.NodeSpec{ProviderID: "hcloud://1"},
+			},
+			expected: true,
+		}, {
+			name: "existing server by name",
+			node: &v1.Node{
+				Spec: v1.NodeSpec{ProviderID: "hcloud://1"},
+			},
+			expected: true,
+		}, {
+			name: "missing server by name",
+			node: &v1.Node{
+				Spec: v1.NodeSpec{ProviderID: "hcloud://1"},
+			},
+			expected: true,
+		},
 	}
-}
 
-func TestNodeAddressesDualStack(t *testing.T) {
-	env := newTestEnv()
-	defer env.Teardown()
-	env.Mux.HandleFunc("/servers", func(w http.ResponseWriter, r *http.Request) {
-		if r.URL.RawQuery != "name=node15" {
-			t.Fatal("missing name query")
-		}
-		json.NewEncoder(w).Encode(schema.ServerListResponse{
-			Servers: []schema.Server{
-				{
-					ID:   1,
-					Name: "node15",
-					PublicNet: schema.ServerPublicNet{
-						IPv6: schema.ServerPublicNetIPv6{
-							IP: "2001:db8:1234::/64",
-						},
-						IPv4: schema.ServerPublicNetIPv4{
-							IP: "131.232.99.1",
-						},
-					},
-				},
-			},
+	for _, test := range tests {
+		t.Run(test.name, func(t *testing.T) {
+			exists, err := instances.InstanceExists(context.TODO(), test.node)
+			if err != nil {
+				t.Fatalf("Unexpected error: %v", err)
+			}
+			if test.expected != exists {
+				t.Fatalf("Expected server to exist %v but got %v", test.expected, exists)
+			}
 		})
-	})
-
-	instances := newInstances(env.Client, AddressFamilyDualStack)
-	addr, err := instances.NodeAddresses(context.TODO(), "node15")
-	if err != nil {
-		t.Fatalf("Unexpected error: %v", err)
-	}
-	if len(addr) != 3 ||
-		addr[0].Type != v1.NodeHostName || addr[0].Address != "node15" ||
-		addr[1].Type != v1.NodeExternalIP || addr[1].Address != "131.232.99.1" ||
-		addr[2].Type != v1.NodeExternalIP || addr[2].Address != "2001:db8:1234::1" {
-		t.Errorf("Unexpected node addresses: %v", addr)
 	}
 }
 
-func TestExternalID(t *testing.T) {
+func TestInstances_InstanceShutdown(t *testing.T) {
 	env := newTestEnv()
 	defer env.Teardown()
-	env.Mux.HandleFunc("/servers", func(w http.ResponseWriter, r *http.Request) {
-		if r.URL.RawQuery != "name=node15" {
-			t.Fatal("missing name query")
-		}
-		json.NewEncoder(w).Encode(schema.ServerListResponse{
-			Servers: []schema.Server{
-				{
-					ID: 1,
-				},
+	env.Mux.HandleFunc("/servers/1", func(w http.ResponseWriter, r *http.Request) {
+		json.NewEncoder(w).Encode(schema.ServerGetResponse{
+			Server: schema.Server{
+				ID:     1,
+				Name:   "foobar",
+				Status: string(hcloud.ServerStatusRunning),
 			},
 		})
 	})
-
-	instances := newInstances(env.Client, AddressFamilyIPv4)
-	id, err := instances.ExternalID(context.TODO(), "node15")
-	if err != nil {
-		t.Fatalf("Unexpected error: %v", err)
-	}
-	if id != "1" {
-		t.Errorf("Unexpected id: %v", id)
-	}
-}
-
-func TestInstanceType(t *testing.T) {
-	env := newTestEnv()
-	defer env.Teardown()
-	env.Mux.HandleFunc("/servers", func(w http.ResponseWriter, r *http.Request) {
-		if r.URL.RawQuery != "name=node15" {
-			t.Fatal("missing name query")
-		}
-		json.NewEncoder(w).Encode(schema.ServerListResponse{
-			Servers: []schema.Server{
-				{
-					ID: 1,
-					ServerType: schema.ServerType{
-						Name: "cx11",
-					},
-				},
+	env.Mux.HandleFunc("/servers/2", func(w http.ResponseWriter, r *http.Request) {
+		json.NewEncoder(w).Encode(schema.ServerGetResponse{
+			Server: schema.Server{
+				ID:     2,
+				Name:   "barfoo",
+				Status: string(hcloud.ServerStatusOff),
 			},
 		})
 	})
 
 	instances := newInstances(env.Client, AddressFamilyIPv4)
-	serverType, err := instances.InstanceType(context.TODO(), "node15")
-	if err != nil {
-		t.Fatalf("Unexpected error: %v", err)
+
+	tests := []struct {
+		name     string
+		node     *v1.Node
+		expected bool
+	}{
+		{
+			name: "running server",
+			node: &v1.Node{
+				Spec: v1.NodeSpec{ProviderID: "hcloud://1"},
+			},
+			expected: false,
+		}, {
+			name: "shutdown server",
+			node: &v1.Node{
+				Spec: v1.NodeSpec{ProviderID: "hcloud://2"},
+			},
+			expected: true,
+		},
 	}
-	if serverType != "cx11" {
-		t.Errorf("Unexpected server type: %v", serverType)
+
+	for _, test := range tests {
+		t.Run(test.name, func(t *testing.T) {
+			exists, err := instances.InstanceShutdown(context.TODO(), test.node)
+			if err != nil {
+				t.Fatalf("Unexpected error: %v", err)
+			}
+			if test.expected != exists {
+				t.Fatalf("Expected server shutdown to be %v but got %v", test.expected, exists)
+			}
+		})
 	}
 }
 
-func TestInstanceTypeByProviderID(t *testing.T) {
+func TestInstances_InstanceMetadata(t *testing.T) {
 	env := newTestEnv()
 	defer env.Teardown()
 	env.Mux.HandleFunc("/servers/1", func(w http.ResponseWriter, r *http.Request) {
 		json.NewEncoder(w).Encode(schema.ServerGetResponse{
 			Server: schema.Server{
-				ID:   1,
-				Name: "node15",
-				ServerType: schema.ServerType{
-					Name: "cx11",
+				ID:         1,
+				Name:       "foobar",
+				ServerType: schema.ServerType{Name: "asdf11"},
+				Datacenter: schema.Datacenter{Name: "Test DC", Location: schema.Location{Name: "Test Location"}},
+				PublicNet: schema.ServerPublicNet{
+					IPv6: schema.ServerPublicNetIPv6{
+						IP: "2001:db8:1234::/64",
+					},
+					IPv4: schema.ServerPublicNetIPv4{
+						IP: "203.0.113.7",
+					},
 				},
 			},
 		})
 	})
 
 	instances := newInstances(env.Client, AddressFamilyIPv4)
-	instanceType, err := instances.InstanceTypeByProviderID(context.TODO(), "hcloud://1")
+
+	metadata, err := instances.InstanceMetadata(context.TODO(), &v1.Node{
+		Spec: v1.NodeSpec{ProviderID: "hcloud://1"},
+	})
 	if err != nil {
 		t.Fatalf("Unexpected error: %v", err)
 	}
-	if instanceType != "cx11" {
-		t.Errorf("Unexpected instance type: %v", instanceType)
+
+	expectedMetadata := &cloudprovider.InstanceMetadata{
+		ProviderID:   "hcloud://1",
+		InstanceType: "asdf11",
+		NodeAddresses: []v1.NodeAddress{
+			{Type: v1.NodeHostName, Address: "foobar"},
+			{Type: v1.NodeExternalIP, Address: "203.0.113.7"},
+		},
+		Zone:   "Test DC",
+		Region: "Test Location",
+	}
+
+	if !reflect.DeepEqual(metadata, expectedMetadata) {
+		t.Fatalf("Expected metadata %+v but got %+v", *expectedMetadata, *metadata)
 	}
 }
 
-func TestInstanceExistsByProviderID(t *testing.T) {
-	t.Run("Found", func(t *testing.T) {
-		env := newTestEnv()
-		defer env.Teardown()
-		env.Mux.HandleFunc("/servers/1", func(w http.ResponseWriter, r *http.Request) {
-			json.NewEncoder(w).Encode(schema.ServerGetResponse{
-				Server: schema.Server{
-					ID:   1,
-					Name: "node15",
-					ServerType: schema.ServerType{
-						Name: "cx11",
+func TestInstances_nodeAddresses(t *testing.T) {
+	tests := []struct {
+		name           string
+		addressFamily  addressFamily
+		server         *hcloud.Server
+		privateNetwork string
+		expected       []v1.NodeAddress
+	}{
+		{
+			name:          "hostname",
+			addressFamily: AddressFamilyIPv4,
+			server: &hcloud.Server{
+				Name: "foobar",
+			},
+			expected: []v1.NodeAddress{
+				{Type: v1.NodeHostName, Address: "foobar"},
+			},
+		},
+		{
+			name:          "public ipv4",
+			addressFamily: AddressFamilyIPv4,
+			server: &hcloud.Server{
+				Name: "foobar",
+				PublicNet: hcloud.ServerPublicNet{
+					IPv4: hcloud.ServerPublicNetIPv4{
+						IP: net.ParseIP("203.0.113.7"),
+					},
+					IPv6: hcloud.ServerPublicNetIPv6{
+						IP: net.ParseIP("2001:db8:1234::"),
 					},
 				},
-			})
-		})
-
-		instances := newInstances(env.Client, AddressFamilyIPv4)
-		exists, err := instances.InstanceExistsByProviderID(context.TODO(), "hcloud://1")
-		if err != nil {
-			t.Fatalf("Unexpected error: %v", err)
-		}
-		if !exists {
-			t.Errorf("Unexpected exist state: %v", exists)
-		}
-	})
-
-	t.Run("NotFound", func(t *testing.T) {
-		env := newTestEnv()
-		defer env.Teardown()
-		env.Mux.HandleFunc("/servers/1", func(w http.ResponseWriter, r *http.Request) {
-			w.Header().Set("Content-Type", "application/json")
-			w.WriteHeader(http.StatusNotFound)
-			json.NewEncoder(w).Encode(schema.ErrorResponse{
-				Error: schema.Error{
-					Code: string(hcloud.ErrorCodeNotFound),
+			},
+			expected: []v1.NodeAddress{
+				{Type: v1.NodeHostName, Address: "foobar"},
+				{Type: v1.NodeExternalIP, Address: "203.0.113.7"},
+			},
+		},
+		{
+			name:          "no public ipv4",
+			addressFamily: AddressFamilyIPv4,
+			server: &hcloud.Server{
+				Name: "foobar",
+				PublicNet: hcloud.ServerPublicNet{
+					IPv6: hcloud.ServerPublicNetIPv6{
+						IP: net.ParseIP("2001:db8:1234::"),
+					},
 				},
-			})
-		})
-
-		instances := newInstances(env.Client, AddressFamilyIPv4)
-		exists, err := instances.InstanceExistsByProviderID(context.TODO(), "hcloud://1")
-		if err != nil {
-			t.Fatalf("Unexpected error: %v", err)
-		}
-		if exists {
-			t.Errorf("Unexpected exist state: %v", exists)
-		}
-	})
-}
-
-func TestInstanceShutdownByProviderID(t *testing.T) {
-	t.Run("Shutdown", func(t *testing.T) {
-		env := newTestEnv()
-		defer env.Teardown()
-
-		env.Mux.HandleFunc("/servers/1", func(w http.ResponseWriter, r *http.Request) {
-			json.NewEncoder(w).Encode(schema.ServerGetResponse{
-				Server: schema.Server{
-					Status: string(hcloud.ServerStatusOff),
+			},
+			expected: []v1.NodeAddress{
+				{Type: v1.NodeHostName, Address: "foobar"},
+			},
+		},
+		{
+			name:          "public ipv6",
+			addressFamily: AddressFamilyIPv6,
+			server: &hcloud.Server{
+				Name: "foobar",
+				PublicNet: hcloud.ServerPublicNet{
+					IPv4: hcloud.ServerPublicNetIPv4{
+						IP: net.ParseIP("203.0.113.7"),
+					},
+					IPv6: hcloud.ServerPublicNetIPv6{
+						IP: net.ParseIP("2001:db8:1234::"),
+					},
 				},
-			})
-		})
-
-		instances := newInstances(env.Client, AddressFamilyIPv4)
-		isOff, err := instances.InstanceShutdownByProviderID(context.TODO(), "hcloud://1")
-		if !isOff {
-			t.Errorf("Unexpected isOff state: %v", isOff)
-		}
-		if err != nil {
-			t.Fatalf("Unexpected error: %v", err)
-		}
-	})
-
-	t.Run("NotShutdown", func(t *testing.T) {
-		env := newTestEnv()
-		defer env.Teardown()
-
-		env.Mux.HandleFunc("/servers/1", func(w http.ResponseWriter, r *http.Request) {
-			json.NewEncoder(w).Encode(schema.ServerGetResponse{
-				Server: schema.Server{
-					Status: string(hcloud.ServerStatusRunning),
+			},
+			expected: []v1.NodeAddress{
+				{Type: v1.NodeHostName, Address: "foobar"},
+				{Type: v1.NodeExternalIP, Address: "2001:db8:1234::1"},
+			},
+		},
+		{
+			name:          "no public ipv6",
+			addressFamily: AddressFamilyIPv6,
+			server: &hcloud.Server{
+				Name: "foobar",
+				PublicNet: hcloud.ServerPublicNet{
+					IPv4: hcloud.ServerPublicNetIPv4{
+						IP: net.ParseIP("203.0.113.7"),
+					},
 				},
-			})
-		})
+			},
+			expected: []v1.NodeAddress{
+				{Type: v1.NodeHostName, Address: "foobar"},
+			},
+		},
+		{
+			name:          "public dual stack",
+			addressFamily: AddressFamilyDualStack,
+			server: &hcloud.Server{
+				Name: "foobar",
+				PublicNet: hcloud.ServerPublicNet{
+					IPv4: hcloud.ServerPublicNetIPv4{
+						IP: net.ParseIP("203.0.113.7"),
+					},
+					IPv6: hcloud.ServerPublicNetIPv6{
+						IP: net.ParseIP("2001:db8:1234::"),
+					},
+				},
+			},
+			expected: []v1.NodeAddress{
+				{Type: v1.NodeHostName, Address: "foobar"},
+				{Type: v1.NodeExternalIP, Address: "203.0.113.7"},
+				{Type: v1.NodeExternalIP, Address: "2001:db8:1234::1"},
+			},
+		},
+
+		{
+			name:           "unknown private network",
+			addressFamily:  AddressFamilyIPv4,
+			privateNetwork: "unknown-network",
+			server: &hcloud.Server{
+				Name: "foobar",
+			},
+			expected: []v1.NodeAddress{
+				{Type: v1.NodeHostName, Address: "foobar"},
+			},
+		},
+		{
+			name:           "server attached to private network",
+			addressFamily:  AddressFamilyIPv4,
+			privateNetwork: "test-existing-nw",
+			server: &hcloud.Server{
+				Name: "foobar",
+				PrivateNet: []hcloud.ServerPrivateNet{
+					{
+						Network: &hcloud.Network{
+							ID:   1,
+							Name: "test-existing-nw",
+						},
+						IP: net.ParseIP("10.0.0.2"),
+					},
+				},
+			},
+			expected: []v1.NodeAddress{
+				{Type: v1.NodeHostName, Address: "foobar"},
+				{Type: v1.NodeInternalIP, Address: "10.0.0.2"},
+			},
+		},
+		{
+			name:           "server not attached to private network",
+			addressFamily:  AddressFamilyIPv4,
+			privateNetwork: "test-existing-nw",
+			server: &hcloud.Server{
+				Name: "foobar",
+				PrivateNet: []hcloud.ServerPrivateNet{
+					{
+						Network: &hcloud.Network{
+							ID:   2,
+							Name: "other-nw",
+						},
+						IP: net.ParseIP("10.0.0.2"),
+					},
+				},
+			},
+			expected: []v1.NodeAddress{
+				{Type: v1.NodeHostName, Address: "foobar"},
+			},
+		},
+	}
 
-		instances := newInstances(env.Client, AddressFamilyIPv4)
-		isOff, err := instances.InstanceShutdownByProviderID(context.TODO(), "hcloud://1")
-		if isOff {
-			t.Errorf("Unexpected isOff state: %v", isOff)
-		}
-		if err != nil {
-			t.Fatalf("Unexpected error: %v", err)
+	env := newTestEnv()
+	defer env.Teardown()
+
+	env.Mux.HandleFunc("/networks", func(w http.ResponseWriter, r *http.Request) {
+		var networks []schema.Network
+		if r.URL.RawQuery == "name=test-existing-nw" {
+			networks = append(networks, schema.Network{ID: 1, Name: "test-existing-nw"})
 		}
+		json.NewEncoder(w).Encode(schema.NetworkListResponse{Networks: networks})
 	})
 
-	t.Run("NotFound", func(t *testing.T) {
-		env := newTestEnv()
-		defer env.Teardown()
+	for _, test := range tests {
+		t.Run(test.name, func(t *testing.T) {
+			instances := newInstances(env.Client, test.addressFamily)
 
-		env.Mux.HandleFunc("/servers/1", func(w http.ResponseWriter, r *http.Request) {
-			w.Header().Set("Content-Type", "application/json")
-			w.WriteHeader(http.StatusNotFound)
-			json.NewEncoder(w).Encode(schema.ErrorResponse{
-				Error: schema.Error{
-					Code: string(hcloud.ErrorCodeNotFound),
-				},
-			})
-		})
+			// nodeAddresses reads from environment variables at the moment
+			if test.privateNetwork != "" {
+				previousValue := os.Getenv(hcloudNetworkENVVar)
+				os.Setenv(hcloudNetworkENVVar, test.privateNetwork)
+				defer os.Setenv(hcloudNetworkENVVar, previousValue)
+			}
 
-		instances := newInstances(env.Client, AddressFamilyIPv4)
-		isOff, err := instances.InstanceShutdownByProviderID(context.TODO(), "hcloud://1")
-		if isOff {
-			t.Errorf("Unexpected isOff state: %v", isOff)
-		}
-		if err != nil {
-			t.Fatalf("Unexpected error: %v", err)
-		}
-	})
-}
+			nodeAddresses := instances.nodeAddresses(context.TODO(), test.server)
 
-func TestCurrentNodeName(t *testing.T) {
-	env := newTestEnv()
-	defer env.Teardown()
-	instances := newInstances(env.Client, AddressFamilyIPv4)
-	nodeName, err := instances.CurrentNodeName(context.TODO(), "hostname")
-	if err != nil {
-		t.Fatalf("Unexpected error: %v", err)
-	}
-	if nodeName != "hostname" {
-		t.Errorf("Unexpected node name: %s", nodeName)
+			if !reflect.DeepEqual(nodeAddresses, test.expected) {
+				t.Fatalf("Expected nodeAddresses %+v but got %+v", test.expected, nodeAddresses)
+			}
+		})
 	}
 }
diff --git a/hcloud/zones_test.go b/hcloud/zones_test.go
deleted file mode 100644
index 0ee2fd47..00000000
--- a/hcloud/zones_test.go
+++ /dev/null
@@ -1,129 +0,0 @@
-/*
-Copyright 2018 Hetzner Cloud GmbH.
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-    http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
-*/
-
-package hcloud
-
-import (
-	"context"
-	"encoding/json"
-	"net/http"
-	"testing"
-
-	"github.com/hetznercloud/hcloud-go/hcloud/schema"
-)
-
-func TestGetZone(t *testing.T) {
-	env := newTestEnv()
-	defer env.Teardown()
-	env.Mux.HandleFunc("/servers", func(w http.ResponseWriter, r *http.Request) {
-		if r.URL.RawQuery != "name=node6" {
-			t.Fatal("missing name query")
-		}
-		json.NewEncoder(w).Encode(schema.ServerListResponse{
-			Servers: []schema.Server{
-				{
-					ID:   1,
-					Name: "node15",
-					Datacenter: schema.Datacenter{
-						Name: "fsn1-dc8",
-						Location: schema.Location{
-							Name: "fsn1",
-						},
-					},
-				},
-			},
-		})
-	})
-
-	zones := newZones(env.Client, "node6")
-	zone, err := zones.GetZone(context.TODO())
-	if err != nil {
-		t.Fatalf("Unexpected error: %v", err)
-	}
-	if zone.Region != "fsn1" {
-		t.Errorf("Unexpected zone.Region: %s", zone.Region)
-	}
-	if zone.FailureDomain != "fsn1-dc8" {
-		t.Errorf("Unexpected zone.FailureDomain: %s", zone.FailureDomain)
-	}
-}
-
-func TestGetZoneForServer(t *testing.T) {
-	env := newTestEnv()
-	defer env.Teardown()
-	env.Mux.HandleFunc("/servers", func(w http.ResponseWriter, r *http.Request) {
-		if r.URL.RawQuery != "name=node15" {
-			t.Fatal("missing name query")
-		}
-		json.NewEncoder(w).Encode(schema.ServerListResponse{
-			Servers: []schema.Server{
-				{
-					ID:   1,
-					Name: "node15",
-					Datacenter: schema.Datacenter{
-						Name: "fsn1-dc8",
-						Location: schema.Location{
-							Name: "fsn1",
-						},
-					},
-				},
-			},
-		})
-	})
-
-	zones := newZones(env.Client, "node6")
-	zone, err := zones.GetZoneByNodeName(context.TODO(), "node15")
-	if err != nil {
-		t.Fatalf("Unexpected error: %v", err)
-	}
-	if zone.Region != "fsn1" {
-		t.Errorf("Unexpected zone.Region: %s", zone.Region)
-	}
-	if zone.FailureDomain != "fsn1-dc8" {
-		t.Errorf("Unexpected zone.FailureDomain: %s", zone.FailureDomain)
-	}
-}
-
-func TestGetZoneByProviderID(t *testing.T) {
-	env := newTestEnv()
-	defer env.Teardown()
-	env.Mux.HandleFunc("/servers/1", func(w http.ResponseWriter, r *http.Request) {
-		json.NewEncoder(w).Encode(schema.ServerGetResponse{
-			Server: schema.Server{
-				ID:   1,
-				Name: "node15",
-				Datacenter: schema.Datacenter{
-					Name: "fsn1-dc8",
-					Location: schema.Location{
-						Name: "fsn1",
-					},
-				},
-			},
-		})
-	})
-
-	zones := newZones(env.Client, "node6")
-	zone, err := zones.GetZoneByProviderID(context.TODO(), "hcloud://1")
-	if err != nil {
-		t.Fatalf("Unexpected error: %v", err)
-	}
-	if zone.Region != "fsn1" {
-		t.Errorf("Unexpected zone.Region: %s", zone.Region)
-	}
-	if zone.FailureDomain != "fsn1-dc8" {
-		t.Errorf("Unexpected zone.FailureDomain: %s", zone.FailureDomain)
-	}
-}