From 785896c6c12a19a4c8959d818ffd6388d602a7db Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20K=C3=A4mmerling?= Date: Mon, 23 Aug 2021 09:42:27 +0200 Subject: [PATCH] Add Metadata Client (#184) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Lukas Kämmerling --- hcloud/metadata/client.go | 116 ++++++++++++++++++++ hcloud/metadata/client_test.go | 189 +++++++++++++++++++++++++++++++++ 2 files changed, 305 insertions(+) create mode 100644 hcloud/metadata/client.go create mode 100644 hcloud/metadata/client_test.go diff --git a/hcloud/metadata/client.go b/hcloud/metadata/client.go new file mode 100644 index 00000000..93a93440 --- /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") +} diff --git a/hcloud/metadata/client_test.go b/hcloud/metadata/client_test.go new file mode 100644 index 00000000..001e76ed --- /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) +}