Repository: hetznercloud/hcloud-go
Pull request #184 (2021-08-23 07:41:28)

Add Metadata Client
Signed-off-by: Lukas Kämmerling <lukas.kaemmerling@hetzner-cloud.de>


Patch:
diff --git a/hcloud/metadata/client.go b/hcloud/metadata/client.go
new file mode 100644
index 0000000..93a9344
--- /dev/null
+++ b/hcloud/metadata/client.go
@@ -0,0 +1,116 @@
+package metadata
+
+import (
+	"io/ioutil"
+	"net"
+	"net/http"
+	"strconv"
+	"strings"
+)
+
+const Endpoint = "http://169.254.169.254/hetzner/v1/metadata"
+
+// Client is a client for the Hetzner Cloud Server Metadata Endpoints.
+type Client struct {
+	endpoint string
+
+	httpClient *http.Client
+}
+
+// A ClientOption is used to configure a Client.
+type ClientOption func(*Client)
+
+// WithEndpoint configures a Client to use the specified Metadata API endpoint.
+func WithEndpoint(endpoint string) ClientOption {
+	return func(client *Client) {
+		client.endpoint = strings.TrimRight(endpoint, "/")
+	}
+}
+
+// WithHTTPClient configures a Client to perform HTTP requests with httpClient.
+func WithHTTPClient(httpClient *http.Client) ClientOption {
+	return func(client *Client) {
+		client.httpClient = httpClient
+	}
+}
+
+// NewClient creates a new client.
+func NewClient(options ...ClientOption) *Client {
+	client := &Client{
+		endpoint:   Endpoint,
+		httpClient: &http.Client{},
+	}
+
+	for _, option := range options {
+		option(client)
+	}
+	return client
+}
+
+// NewRequest creates an HTTP request against the API. The returned request
+// is assigned with ctx and has all necessary headers set (auth, user agent, etc.).
+func (c *Client) get(path string) (string, error) {
+	url := c.endpoint + path
+	resp, err := c.httpClient.Get(url)
+	if err != nil {
+		return "", err
+	}
+	body, err := ioutil.ReadAll(resp.Body)
+	if err != nil {
+		return "", err
+	}
+	resp.Body.Close()
+	return string(body), nil
+}
+
+// IsHcloudServer checks if the currently called server is a hcloud server by calling a metadata endpoint
+// if the endpoint answers with a non-empty value this method returns true, otherwise false
+func (c *Client) IsHcloudServer() bool {
+	hostname, err := c.Hostname()
+	if err != nil {
+		return false
+	}
+	if len(hostname) > 0 {
+		return true
+	}
+	return false
+}
+
+// Hostname returns the hostname of the server that did the request to the Metadata server
+func (c *Client) Hostname() (string, error) {
+	return c.get("/hostname")
+}
+
+// InstanceID returns the ID of the server that did the request to the Metadata server
+func (c *Client) InstanceID() (int, error) {
+	resp, err := c.get("/instance-id")
+	if err != nil {
+		return 0, err
+	}
+	return strconv.Atoi(resp)
+}
+
+// PublicIPv4 returns the Public IPv4 of the server that did the request to the Metadata server
+func (c *Client) PublicIPv4() (net.IP, error) {
+	resp, err := c.get("/public-ipv4")
+	if err != nil {
+		return nil, err
+	}
+	return net.ParseIP(resp), nil
+}
+
+// Region returns the Network Zone of the server that did the request to the Metadata server
+func (c *Client) Region() (string, error) {
+	return c.get("/region")
+}
+
+// AvailabilityZone returns the datacenter of the server that did the request to the Metadata server
+func (c *Client) AvailabilityZone() (string, error) {
+	return c.get("/availability-zone")
+}
+
+// PrivateNetworks returns details about the private networks the server is attached to
+// Returns YAML (unparsed)
+func (c *Client) PrivateNetworks() (string, error) {
+	return c.get("/private-networks")
+}


Test patch:
diff --git a/hcloud/metadata/client_test.go b/hcloud/metadata/client_test.go
new file mode 100644
index 0000000..001e76e
--- /dev/null
+++ b/hcloud/metadata/client_test.go
@@ -0,0 +1,189 @@
+package metadata
+
+import (
+	"net/http"
+	"net/http/httptest"
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+)
+
+type testEnv struct {
+	Server *httptest.Server
+	Mux    *http.ServeMux
+	Client *Client
+}
+
+func (env *testEnv) Teardown() {
+	env.Server.Close()
+	env.Server = nil
+	env.Mux = nil
+	env.Client = nil
+}
+
+func newTestEnv() testEnv {
+	mux := http.NewServeMux()
+	server := httptest.NewServer(mux)
+	client := NewClient(
+		WithEndpoint(server.URL),
+	)
+	return testEnv{
+		Server: server,
+		Mux:    mux,
+		Client: client,
+	}
+}
+
+func TestClient_IsHcloudServer(t *testing.T) {
+	env := newTestEnv()
+	env.Mux.HandleFunc("/hostname", func(w http.ResponseWriter, r *http.Request) {
+		w.Write([]byte("my-server"))
+	})
+
+	isHcloudServer := env.Client.IsHcloudServer()
+
+	assert.True(t, isHcloudServer)
+}
+
+func TestClient_IsHcloudServer_EmptyReturn(t *testing.T) {
+	env := newTestEnv()
+	env.Mux.HandleFunc("/hostname", func(w http.ResponseWriter, r *http.Request) {
+		w.Write([]byte(""))
+	})
+
+	isHcloudServer := env.Client.IsHcloudServer()
+
+	assert.False(t, isHcloudServer)
+}
+
+func TestClient_IsHcloudServer_HttpError(t *testing.T) {
+	env := newTestEnv()
+	env.Mux.HandleFunc("/hostname", func(w http.ResponseWriter, r *http.Request) {
+		w.WriteHeader(404)
+	})
+	isHcloudServer := env.Client.IsHcloudServer()
+
+	assert.False(t, isHcloudServer)
+}
+func TestClient_Hostname(t *testing.T) {
+	env := newTestEnv()
+	env.Mux.HandleFunc("/hostname", func(w http.ResponseWriter, r *http.Request) {
+		w.Write([]byte("my-server"))
+	})
+
+	hostname, err := env.Client.Hostname()
+	if err != nil {
+		t.Fatal(err)
+	}
+	if hostname != "my-server" {
+		t.Fatalf("Unexpected hostname %s", hostname)
+	}
+}
+
+func TestClient_InstanceID(t *testing.T) {
+	env := newTestEnv()
+	env.Mux.HandleFunc("/instance-id", func(w http.ResponseWriter, r *http.Request) {
+		w.Write([]byte("123456"))
+	})
+
+	instanceID, err := env.Client.InstanceID()
+	if err != nil {
+		t.Fatal(err)
+	}
+	if instanceID != 123456 {
+		t.Fatalf("Unexpected instanceID %d", instanceID)
+	}
+}
+
+func TestClient_PublicIPv4(t *testing.T) {
+	env := newTestEnv()
+	env.Mux.HandleFunc("/public-ipv4", func(w http.ResponseWriter, r *http.Request) {
+		w.Write([]byte("127.0.0.1"))
+	})
+
+	publicIPv4, err := env.Client.PublicIPv4()
+	if err != nil {
+		t.Fatal(err)
+	}
+	if publicIPv4.String() != "127.0.0.1" {
+		t.Fatalf("Unexpected PublicIPv4 %s", publicIPv4.String())
+	}
+}
+
+func TestClient_Region(t *testing.T) {
+	env := newTestEnv()
+	env.Mux.HandleFunc("/region", func(w http.ResponseWriter, r *http.Request) {
+		w.Write([]byte("eu-central"))
+	})
+
+	region, err := env.Client.Region()
+	if err != nil {
+		t.Fatal(err)
+	}
+	if region != "eu-central" {
+		t.Fatalf("Unexpected region %s", region)
+	}
+}
+
+func TestClient_AvailabilityZone(t *testing.T) {
+	env := newTestEnv()
+	env.Mux.HandleFunc("/availability-zone", func(w http.ResponseWriter, r *http.Request) {
+		w.Write([]byte("fsn1-dc14"))
+	})
+
+	availabilityZone, err := env.Client.AvailabilityZone()
+	if err != nil {
+		t.Fatal(err)
+	}
+	if availabilityZone != "fsn1-dc14" {
+		t.Fatalf("Unexpected availabilityZone %s", availabilityZone)
+	}
+}
+
+func TestClient_PrivateNetworks(t *testing.T) {
+	env := newTestEnv()
+	env.Mux.HandleFunc("/private-networks", func(w http.ResponseWriter, r *http.Request) {
+		w.Write([]byte(`- ip: 10.0.0.2
+  alias_ips: [10.0.0.3, 10.0.0.4]
+  interface_num: 1
+  mac_address: 86:00:00:2a:7d:e0
+  network_id: 1234
+  network_name: nw-test1
+  network: 10.0.0.0/8
+  subnet: 10.0.0.0/24
+  gateway: 10.0.0.1
+- ip: 192.168.0.2
+  alias_ips: []
+  interface_num: 2
+  mac_address: 86:00:00:2a:7d:e1
+  network_id: 4321
+  network_name: nw-test2
+  network: 192.168.0.0/16
+  subnet: 192.168.0.0/24
+  gateway: 192.168.0.1`))
+	})
+
+	privateNetworks, err := env.Client.PrivateNetworks()
+	if err != nil {
+		t.Fatal(err)
+	}
+	expectedNetworks := `- ip: 10.0.0.2
+  alias_ips: [10.0.0.3, 10.0.0.4]
+  interface_num: 1
+  mac_address: 86:00:00:2a:7d:e0
+  network_id: 1234
+  network_name: nw-test1
+  network: 10.0.0.0/8
+  subnet: 10.0.0.0/24
+  gateway: 10.0.0.1
+- ip: 192.168.0.2
+  alias_ips: []
+  interface_num: 2
+  mac_address: 86:00:00:2a:7d:e1
+  network_id: 4321
+  network_name: nw-test2
+  network: 192.168.0.0/16
+  subnet: 192.168.0.0/24
+  gateway: 192.168.0.1`
+	assert.Equal(t, privateNetworks, expectedNetworks)
+}