Repository: hetznercloud/hcloud-go
Pull request #180 (2021-08-16 08:36:52)

Add new Floating IP Pricing structure
Signed-off-by: Lukas Kämmerling <lukas.kaemmerling@hetzner-cloud.de>


Patch:
diff --git a/hcloud/client.go b/hcloud/client.go
index 25b713b..a4bb337 100644
--- a/hcloud/client.go
+++ b/hcloud/client.go
@@ -74,6 +74,7 @@ type Client struct {
 	ServerType       ServerTypeClient
 	SSHKey           SSHKeyClient
 	Volume           VolumeClient
+	PlacementGroup   PlacementGroupClient
 }
 
 // A ClientOption is used to configure a Client.
@@ -164,6 +165,7 @@ func NewClient(options ...ClientOption) *Client {
 	client.LoadBalancerType = LoadBalancerTypeClient{client: client}
 	client.Certificate = CertificateClient{client: client}
 	client.Firewall = FirewallClient{client: client}
+	client.PlacementGroup = PlacementGroupClient{client: client}
 
 	return client
 }
diff --git a/hcloud/placement_group.go b/hcloud/placement_group.go
new file mode 100644
index 0000000..d8df195
--- /dev/null
+++ b/hcloud/placement_group.go
@@ -0,0 +1,243 @@
+package hcloud
+
+import (
+	"bytes"
+	"context"
+	"encoding/json"
+	"errors"
+	"fmt"
+	"net/url"
+	"strconv"
+	"time"
+
+	"github.com/hetznercloud/hcloud-go/hcloud/schema"
+)
+
+// PlacementGroup represents a Placement Group in the Hetzner Cloud.
+type PlacementGroup struct {
+	ID      int
+	Name    string
+	Labels  map[string]string
+	Created time.Time
+	Servers []int
+	Type    PlacementGroupType
+}
+
+// PlacementGroupType specifies the type of a Placement Group
+type PlacementGroupType string
+
+const (
+	// PlacementGroupTypeSpread spreads all servers in the group on different vhosts
+	PlacementGroupTypeSpread PlacementGroupType = "spread"
+)
+
+// PlacementGroupClient is a client for the Placement Groups API.
+type PlacementGroupClient struct {
+	client *Client
+}
+
+// GetByID retrieves a PlacementGroup by its ID. If the PlacementGroup does not exist, nil is returned.
+func (c *PlacementGroupClient) GetByID(ctx context.Context, id int) (*PlacementGroup, *Response, error) {
+	req, err := c.client.NewRequest(ctx, "GET", fmt.Sprintf("/placement_groups/%d", id), nil)
+	if err != nil {
+		return nil, nil, err
+	}
+
+	var body schema.PlacementGroupGetResponse
+	resp, err := c.client.Do(req, &body)
+	if err != nil {
+		if IsError(err, ErrorCodeNotFound) {
+			return nil, resp, nil
+		}
+		return nil, nil, err
+	}
+	return PlacementGroupFromSchema(body.PlacementGroup), resp, nil
+}
+
+// GetByName retrieves a PlacementGroup by its name. If the PlacementGroup does not exist, nil is returned.
+func (c *PlacementGroupClient) GetByName(ctx context.Context, name string) (*PlacementGroup, *Response, error) {
+	if name == "" {
+		return nil, nil, nil
+	}
+	placementGroups, response, err := c.List(ctx, PlacementGroupListOpts{Name: name})
+	if len(placementGroups) == 0 {
+		return nil, response, err
+	}
+	return placementGroups[0], response, err
+}
+
+// Get retrieves a PlacementGroup by its ID if the input can be parsed as an integer, otherwise it
+// retrieves a PlacementGroup by its name. If the PlacementGroup does not exist, nil is returned.
+func (c *PlacementGroupClient) Get(ctx context.Context, idOrName string) (*PlacementGroup, *Response, error) {
+	if id, err := strconv.Atoi(idOrName); err == nil {
+		return c.GetByID(ctx, int(id))
+	}
+	return c.GetByName(ctx, idOrName)
+}
+
+// PlacementGroupListOpts specifies options for listing PlacementGroup.
+type PlacementGroupListOpts struct {
+	ListOpts
+	Name string
+	Type PlacementGroupType
+}
+
+func (l PlacementGroupListOpts) values() url.Values {
+	vals := l.ListOpts.values()
+	if l.Name != "" {
+		vals.Add("name", l.Name)
+	}
+	if l.Type != "" {
+		vals.Add("type", string(l.Type))
+	}
+	return vals
+}
+
+// List returns a list of PlacementGroups for a specific page.
+//
+// Please note that filters specified in opts are not taken into account
+// when their value corresponds to their zero value or when they are empty.
+func (c *PlacementGroupClient) List(ctx context.Context, opts PlacementGroupListOpts) ([]*PlacementGroup, *Response, error) {
+	path := "/placement_groups?" + opts.values().Encode()
+	req, err := c.client.NewRequest(ctx, "GET", path, nil)
+	if err != nil {
+		return nil, nil, err
+	}
+
+	var body schema.PlacementGroupListResponse
+	resp, err := c.client.Do(req, &body)
+	if err != nil {
+		return nil, nil, err
+	}
+	placementGroups := make([]*PlacementGroup, 0, len(body.PlacementGroups))
+	for _, g := range body.PlacementGroups {
+		placementGroups = append(placementGroups, PlacementGroupFromSchema(g))
+	}
+	return placementGroups, resp, nil
+}
+
+// All returns all PlacementGroups.
+func (c *PlacementGroupClient) All(ctx context.Context) ([]*PlacementGroup, error) {
+	opts := PlacementGroupListOpts{
+		ListOpts: ListOpts{
+			PerPage: 50,
+		},
+	}
+
+	return c.AllWithOpts(ctx, opts)
+}
+
+// AllWithOpts returns all PlacementGroups for the given options.
+func (c *PlacementGroupClient) AllWithOpts(ctx context.Context, opts PlacementGroupListOpts) ([]*PlacementGroup, error) {
+	var allPlacementGroups []*PlacementGroup
+
+	err := c.client.all(func(page int) (*Response, error) {
+		opts.Page = page
+		placementGroups, resp, err := c.List(ctx, opts)
+		if err != nil {
+			return resp, err
+		}
+		allPlacementGroups = append(allPlacementGroups, placementGroups...)
+		return resp, nil
+	})
+	if err != nil {
+		return nil, err
+	}
+
+	return allPlacementGroups, nil
+}
+
+// PlacementGroupCreateOpts specifies options for creating a new PlacementGroup.
+type PlacementGroupCreateOpts struct {
+	Name   string
+	Labels map[string]string
+	Type   PlacementGroupType
+}
+
+// Validate checks if options are valid
+func (o PlacementGroupCreateOpts) Validate() error {
+	if o.Name == "" {
+		return errors.New("missing name")
+	}
+	return nil
+}
+
+// PlacementGroupCreateResult is the result of a create PlacementGroup call.
+type PlacementGroupCreateResult struct {
+	PlacementGroup *PlacementGroup
+	Action         *Action
+}
+
+// Create creates a new PlacementGroup
+func (c *PlacementGroupClient) Create(ctx context.Context, opts PlacementGroupCreateOpts) (PlacementGroupCreateResult, *Response, error) {
+	if err := opts.Validate(); err != nil {
+		return PlacementGroupCreateResult{}, nil, err
+	}
+	reqBody := placementGroupCreateOptsToSchema(opts)
+	reqBodyData, err := json.Marshal(reqBody)
+	if err != nil {
+		return PlacementGroupCreateResult{}, nil, err
+	}
+	req, err := c.client.NewRequest(ctx, "POST", "/placement_groups", bytes.NewReader(reqBodyData))
+	if err != nil {
+		return PlacementGroupCreateResult{}, nil, err
+	}
+
+	respBody := schema.PlacementGroupCreateResponse{}
+	resp, err := c.client.Do(req, &respBody)
+	if err != nil {
+		return PlacementGroupCreateResult{}, nil, err
+	}
+	result := PlacementGroupCreateResult{
+		PlacementGroup: PlacementGroupFromSchema(respBody.PlacementGroup),
+	}
+	if respBody.Action != nil {
+		result.Action = ActionFromSchema(*respBody.Action)
+	}
+
+	return result, resp, nil
+}
+
+// PlacementGroupUpdateOpts specifies options for updating a PlacementGroup.
+type PlacementGroupUpdateOpts struct {
+	Name   string
+	Labels map[string]string
+}
+
+// Update updates a PlacementGroup.
+func (c *PlacementGroupClient) Update(ctx context.Context, placementGroup *PlacementGroup, opts PlacementGroupUpdateOpts) (*PlacementGroup, *Response, error) {
+	reqBody := schema.PlacementGroupUpdateRequest{}
+	if opts.Name != "" {
+		reqBody.Name = &opts.Name
+	}
+	if opts.Labels != nil {
+		reqBody.Labels = &opts.Labels
+	}
+	reqBodyData, err := json.Marshal(reqBody)
+	if err != nil {
+		return nil, nil, err
+	}
+
+	path := fmt.Sprintf("/placement_groups/%d", placementGroup.ID)
+	req, err := c.client.NewRequest(ctx, "PUT", path, bytes.NewReader(reqBodyData))
+	if err != nil {
+		return nil, nil, err
+	}
+
+	respBody := schema.PlacementGroupUpdateResponse{}
+	resp, err := c.client.Do(req, &respBody)
+	if err != nil {
+		return nil, resp, err
+	}
+
+	return PlacementGroupFromSchema(respBody.PlacementGroup), resp, nil
+}
+
+// Delete deletes a PlacementGroup.
+func (c *PlacementGroupClient) Delete(ctx context.Context, placementGroup *PlacementGroup) (*Response, error) {
+	req, err := c.client.NewRequest(ctx, "DELETE", fmt.Sprintf("/placement_groups/%d", placementGroup.ID), nil)
+	if err != nil {
+		return nil, err
+	}
+	return c.client.Do(req, nil)
+}
diff --git a/hcloud/pricing.go b/hcloud/pricing.go
index ca2cb42..5d1b23b 100644
--- a/hcloud/pricing.go
+++ b/hcloud/pricing.go
@@ -10,6 +10,7 @@ import (
 type Pricing struct {
 	Image             ImagePricing
 	FloatingIP        FloatingIPPricing
+	FloatingIPs       []FloatingIPTypePricing
 	Traffic           TrafficPricing
 	ServerBackup      ServerBackupPricing
 	ServerTypes       []ServerTypePricing
@@ -37,6 +38,19 @@ type FloatingIPPricing struct {
 	Monthly Price
 }
 
+// FloatingIPTypePricing provides pricing information for Floating IPs per Type.
+type FloatingIPTypePricing struct {
+	Type     FloatingIPType
+	Pricings []FloatingIPTypeLocationPricing
+}
+
+// FloatingIPTypeLocationPricing provides pricing information for a Floating IP type
+// at a location.
+type FloatingIPTypeLocationPricing struct {
+	Location *Location
+	Monthly  Price
+}
+
 // TrafficPricing provides pricing information for traffic.
 type TrafficPricing struct {
 	PerTB Price
diff --git a/hcloud/schema.go b/hcloud/schema.go
index 8523084..1645d1a 100644
--- a/hcloud/schema.go
+++ b/hcloud/schema.go
@@ -172,6 +172,9 @@ func ServerFromSchema(s schema.Server) *Server {
 	for _, privNet := range s.PrivateNet {
 		server.PrivateNet = append(server.PrivateNet, ServerPrivateNetFromSchema(privNet))
 	}
+	if s.PlacementGroup != nil {
+		server.PlacementGroup = PlacementGroupFromSchema(*s.PlacementGroup)
+	}
 	return server
 }
 
@@ -660,6 +663,22 @@ func PricingFromSchema(s schema.Pricing) Pricing {
 			},
 		},
 	}
+	for _, floatingIPType := range s.FloatingIPs {
+		var pricings []FloatingIPTypeLocationPricing
+		for _, price := range floatingIPType.Prices {
+			p := FloatingIPTypeLocationPricing{
+				Location: &Location{Name: price.Location},
+				Monthly: Price{
+					Currency: s.Currency,
+					VATRate:  s.VATRate,
+					Net:      price.PriceMonthly.Net,
+					Gross:    price.PriceMonthly.Gross,
+				},
+			}
+			pricings = append(pricings, p)
+		}
+		p.FloatingIPs = append(p.FloatingIPs, FloatingIPTypePricing{Type: FloatingIPType(floatingIPType.Type), Pricings: pricings})
+	}
 	for _, serverType := range s.ServerTypes {
 		var pricings []ServerTypeLocationPricing
 		for _, price := range serverType.Prices {
@@ -765,6 +784,30 @@ func FirewallFromSchema(s schema.Firewall) *Firewall {
 	return f
 }
 
+// PlacementGroupFromSchema converts a schema.PlacementGroup to a PlacementGroup.
+func PlacementGroupFromSchema(s schema.PlacementGroup) *PlacementGroup {
+	g := &PlacementGroup{
+		ID:      s.ID,
+		Name:    s.Name,
+		Labels:  s.Labels,
+		Created: s.Created,
+		Servers: s.Servers,
+		Type:    PlacementGroupType(s.Type),
+	}
+	return g
+}
+
+func placementGroupCreateOptsToSchema(opts PlacementGroupCreateOpts) schema.PlacementGroupCreateRequest {
+	req := schema.PlacementGroupCreateRequest{
+		Name: opts.Name,
+		Type: string(opts.Type),
+	}
+	if opts.Labels != nil {
+		req.Labels = &opts.Labels
+	}
+	return req
+}
+
 func loadBalancerCreateOptsToSchema(opts LoadBalancerCreateOpts) schema.LoadBalancerCreateRequest {
 	req := schema.LoadBalancerCreateRequest{
 		Name:            opts.Name,
diff --git a/hcloud/schema/placement_group.go b/hcloud/schema/placement_group.go
new file mode 100644
index 0000000..6bee439
--- /dev/null
+++ b/hcloud/schema/placement_group.go
@@ -0,0 +1,40 @@
+package schema
+
+import "time"
+
+type PlacementGroup struct {
+	ID      int               `json:"id"`
+	Name    string            `json:"name"`
+	Labels  map[string]string `json:"labels"`
+	Created time.Time         `json:"created"`
+	Servers []int             `json:"servers"`
+	Type    string            `json:"type"`
+}
+
+type PlacementGroupListResponse struct {
+	PlacementGroups []PlacementGroup `json:"placement_groups"`
+}
+
+type PlacementGroupGetResponse struct {
+	PlacementGroup PlacementGroup `json:"placement_group"`
+}
+
+type PlacementGroupCreateRequest struct {
+	Name   string             `json:"name"`
+	Labels *map[string]string `json:"labels,omitempty"`
+	Type   string             `json:"type"`
+}
+
+type PlacementGroupCreateResponse struct {
+	PlacementGroup PlacementGroup `json:"placement_group"`
+	Action         *Action        `json:"action"`
+}
+
+type PlacementGroupUpdateRequest struct {
+	Name   *string            `json:"name,omitempty"`
+	Labels *map[string]string `json:"labels,omitempty"`
+}
+
+type PlacementGroupUpdateResponse struct {
+	PlacementGroup PlacementGroup `json:"placement_group"`
+}
diff --git a/hcloud/schema/pricing.go b/hcloud/schema/pricing.go
index 5126be5..277fa1a 100644
--- a/hcloud/schema/pricing.go
+++ b/hcloud/schema/pricing.go
@@ -6,6 +6,7 @@ type Pricing struct {
 	VATRate           string                    `json:"vat_rate"`
 	Image             PricingImage              `json:"image"`
 	FloatingIP        PricingFloatingIP         `json:"floating_ip"`
+	FloatingIPs       []PricingFloatingIPType   `json:"floating_ips"`
 	Traffic           PricingTraffic            `json:"traffic"`
 	ServerBackup      PricingServerBackup       `json:"server_backup"`
 	ServerTypes       []PricingServerType       `json:"server_types"`
@@ -29,6 +30,19 @@ type PricingFloatingIP struct {
 	PriceMonthly Price `json:"price_monthly"`
 }
 
+// PricingFloatingIPType defines the schema of pricing information for a Floating IP per type.
+type PricingFloatingIPType struct {
+	Type   string                       `json:"type"`
+	Prices []PricingFloatingIPTypePrice `json:"prices"`
+}
+
+// PricingFloatingIPTypePrice defines the schema of pricing information for a Floating IP
+// type at a location.
+type PricingFloatingIPTypePrice struct {
+	Location     string `json:"location"`
+	PriceMonthly Price  `json:"price_monthly"`
+}
+
 // PricingTraffic defines the schema of pricing information for traffic.
 type PricingTraffic struct {
 	PricePerTB Price `json:"price_per_tb"`
diff --git a/hcloud/schema/server.go b/hcloud/schema/server.go
index 29dd020..229e288 100644
--- a/hcloud/schema/server.go
+++ b/hcloud/schema/server.go
@@ -24,6 +24,7 @@ type Server struct {
 	Labels          map[string]string  `json:"labels"`
 	Volumes         []int              `json:"volumes"`
 	PrimaryDiskSize int                `json:"primary_disk_size"`
+	PlacementGroup  *PlacementGroup    `json:"placement_group"`
 }
 
 // ServerProtection defines the schema of a server's resource protection.
@@ -107,6 +108,7 @@ type ServerCreateRequest struct {
 	Volumes          []int                   `json:"volumes,omitempty"`
 	Networks         []int                   `json:"networks,omitempty"`
 	Firewalls        []ServerCreateFirewalls `json:"firewalls,omitempty"`
+	PlacementGroup   int                     `json:"placement_group,omitempty"`
 }
 
 // ServerCreateFirewall defines which Firewalls to apply when creating a Server.
@@ -395,3 +397,21 @@ type ServerGetMetricsResponse struct {
 type ServerTimeSeriesVals struct {
 	Values []interface{} `json:"values"`
 }
+
+// ServerActionAddToPlacementGroupRequest defines the schema for the request to
+// add a server to a placement group.
+type ServerActionAddToPlacementGroupRequest struct {
+	PlacementGroup int `json:"placement_group"`
+}
+
+// ServerActionAddToPlacementGroupResponse defines the schema of the response when
+// creating an add_to_placement_group server action.
+type ServerActionAddToPlacementGroupResponse struct {
+	Action Action `json:"action"`
+}
+
+// ServerActionRemoveFromPlacementGroupResponse defines the schema of the response when
+// creating a remove_from_placement_group server action.
+type ServerActionRemoveFromPlacementGroupResponse struct {
+	Action Action `json:"action"`
+}
diff --git a/hcloud/server.go b/hcloud/server.go
index 8d593c0..c09cecb 100644
--- a/hcloud/server.go
+++ b/hcloud/server.go
@@ -37,6 +37,7 @@ type Server struct {
 	Labels          map[string]string
 	Volumes         []*Volume
 	PrimaryDiskSize int
+	PlacementGroup  *PlacementGroup
 }
 
 // ServerProtection represents the protection level of a server.
@@ -265,6 +266,7 @@ type ServerCreateOpts struct {
 	Volumes          []*Volume
 	Networks         []*Network
 	Firewalls        []*ServerCreateFirewall
+	PlacementGroup   *PlacementGroup
 }
 
 // ServerCreateFirewall defines which Firewalls to apply when creating a Server.
@@ -349,6 +351,9 @@ func (c *ServerClient) Create(ctx context.Context, opts ServerCreateOpts) (Serve
 			reqBody.Datacenter = opts.Datacenter.Name
 		}
 	}
+	if opts.PlacementGroup != nil {
+		reqBody.PlacementGroup = opts.PlacementGroup.ID
+	}
 	reqBodyData, err := json.Marshal(reqBody)
 	if err != nil {
 		return ServerCreateResult{}, nil, err
@@ -1070,3 +1075,40 @@ func (c *ServerClient) GetMetrics(ctx context.Context, server *Server, opts Serv
 	}
 	return ms, resp, nil
 }
+
+func (c *ServerClient) AddToPlacementGroup(ctx context.Context, server *Server, placementGroup *PlacementGroup) (*Action, *Response, error) {
+	reqBody := schema.ServerActionAddToPlacementGroupRequest{
+		PlacementGroup: placementGroup.ID,
+	}
+	reqBodyData, err := json.Marshal(reqBody)
+	if err != nil {
+		return nil, nil, err
+	}
+	path := fmt.Sprintf("/servers/%d/actions/add_to_placement_group", server.ID)
+	req, err := c.client.NewRequest(ctx, "POST", path, bytes.NewReader(reqBodyData))
+	if err != nil {
+		return nil, nil, err
+	}
+
+	respBody := schema.ServerActionAddToPlacementGroupResponse{}
+	resp, err := c.client.Do(req, &respBody)
+	if err != nil {
+		return nil, resp, err
+	}
+	return ActionFromSchema(respBody.Action), resp, err
+}
+
+func (c *ServerClient) RemoveFromPlacementGroup(ctx context.Context, server *Server) (*Action, *Response, error) {
+	path := fmt.Sprintf("/servers/%d/actions/remove_from_placement_group", server.ID)
+	req, err := c.client.NewRequest(ctx, "POST", path, nil)
+	if err != nil {
+		return nil, nil, err
+	}
+
+	respBody := schema.ServerActionRemoveFromPlacementGroupResponse{}
+	resp, err := c.client.Do(req, &respBody)
+	if err != nil {
+		return nil, resp, err
+	}
+	return ActionFromSchema(respBody.Action), resp, err
+}


Test patch:
diff --git a/hcloud/placement_group_test.go b/hcloud/placement_group_test.go
new file mode 100644
index 0000000..334f591
--- /dev/null
+++ b/hcloud/placement_group_test.go
@@ -0,0 +1,288 @@
+package hcloud
+
+import (
+	"context"
+	"encoding/json"
+	"fmt"
+	"net/http"
+	"testing"
+
+	"github.com/google/go-cmp/cmp"
+	"github.com/hetznercloud/hcloud-go/hcloud/schema"
+)
+
+func TestPlacementGroupClientGetByID(t *testing.T) {
+	env := newTestEnv()
+	defer env.Teardown()
+
+	const id = 1
+
+	env.Mux.HandleFunc(fmt.Sprintf("/placement_groups/%d", id), func(w http.ResponseWriter, r *http.Request) {
+		json.NewEncoder(w).Encode(schema.PlacementGroupGetResponse{
+			PlacementGroup: schema.PlacementGroup{
+				ID: id,
+			},
+		})
+	})
+
+	checkError := func(t *testing.T, placementGroup *PlacementGroup, err error) {
+		if err != nil {
+			t.Fatal(err)
+		}
+		if placementGroup == nil {
+			t.Fatal("no placement group")
+		}
+		if placementGroup.ID != id {
+			t.Errorf("unexpected placement group ID: %v", placementGroup.ID)
+		}
+	}
+
+	ctx := context.Background()
+
+	t.Run("called via GetByID", func(t *testing.T) {
+		placementGroup, _, err := env.Client.PlacementGroup.GetByID(ctx, 1)
+		checkError(t, placementGroup, err)
+	})
+
+	t.Run("called via Get", func(t *testing.T) {
+		placementGroup, _, err := env.Client.PlacementGroup.Get(ctx, "1")
+		checkError(t, placementGroup, err)
+	})
+}
+
+func TestPlacementGroupClientGetByIDNotFound(t *testing.T) {
+	env := newTestEnv()
+	defer env.Teardown()
+
+	env.Mux.HandleFunc("/placement_groups/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(ErrorCodeNotFound),
+			},
+		})
+	})
+
+	ctx := context.Background()
+
+	placementGroup, _, err := env.Client.PlacementGroup.GetByID(ctx, 1)
+	if err != nil {
+		t.Fatal(err)
+	}
+	if placementGroup != nil {
+		t.Fatal("expected no placement_group")
+	}
+}
+
+func TestPlacementGroupClientGetByName(t *testing.T) {
+	env := newTestEnv()
+	defer env.Teardown()
+
+	const (
+		id   = 1
+		name = "my_placement_group"
+	)
+
+	env.Mux.HandleFunc("/placement_groups", func(w http.ResponseWriter, r *http.Request) {
+		if r.URL.RawQuery != fmt.Sprintf("name=%s", name) {
+			t.Fatal("missing name query")
+		}
+		json.NewEncoder(w).Encode(schema.PlacementGroupListResponse{
+			PlacementGroups: []schema.PlacementGroup{
+				{
+					ID:   id,
+					Name: name,
+				},
+			},
+		})
+	})
+
+	checkError := func(t *testing.T, placementGroup *PlacementGroup, err error) {
+		if err != nil {
+			t.Fatal(err)
+		}
+		if placementGroup == nil {
+			t.Fatal("no placement group")
+		}
+		if placementGroup.ID != id {
+			t.Errorf("unexpected placement group ID: %v", placementGroup.ID)
+		}
+		if placementGroup.Name != name {
+			t.Errorf("unexpected placement group Name: %v", placementGroup.Name)
+		}
+	}
+
+	ctx := context.Background()
+
+	t.Run("called via GetByID", func(t *testing.T) {
+		placementGroup, _, err := env.Client.PlacementGroup.GetByName(ctx, name)
+		checkError(t, placementGroup, err)
+	})
+
+	t.Run("called via Get", func(t *testing.T) {
+		placementGroup, _, err := env.Client.PlacementGroup.Get(ctx, name)
+		checkError(t, placementGroup, err)
+	})
+}
+
+func TestPlacementGroupClientGetByNameNotFound(t *testing.T) {
+	env := newTestEnv()
+	defer env.Teardown()
+
+	const name = "my_placement_group"
+
+	env.Mux.HandleFunc("/placement_groups", func(w http.ResponseWriter, r *http.Request) {
+		if r.URL.RawQuery != fmt.Sprintf("name=%s", name) {
+			t.Fatal("missing name query")
+		}
+		json.NewEncoder(w).Encode(schema.PlacementGroupListResponse{
+			PlacementGroups: []schema.PlacementGroup{},
+		})
+	})
+
+	ctx := context.Background()
+
+	placementGroup, _, err := env.Client.PlacementGroup.GetByName(ctx, name)
+	if err != nil {
+		t.Fatal(err)
+	}
+	if placementGroup != nil {
+		t.Fatal("expected no placement_group")
+	}
+}
+
+func TestPlacementGroupClientGetByNameEmpty(t *testing.T) {
+	env := newTestEnv()
+	defer env.Teardown()
+
+	ctx := context.Background()
+
+	placementGroup, _, err := env.Client.PlacementGroup.GetByName(ctx, "")
+	if err != nil {
+		t.Fatal(err)
+	}
+	if placementGroup != nil {
+		t.Fatal("expected no placement_group")
+	}
+}
+
+func TestPlacementGroupCreate(t *testing.T) {
+	env := newTestEnv()
+	defer env.Teardown()
+
+	const id = 1
+
+	var (
+		ctx  = context.Background()
+		opts = PlacementGroupCreateOpts{
+			Name:   "test",
+			Labels: map[string]string{"key": "value"},
+			Type:   PlacementGroupTypeSpread,
+		}
+	)
+
+	env.Mux.HandleFunc("/placement_groups", func(w http.ResponseWriter, r *http.Request) {
+		if r.Method != "POST" {
+			t.Error("expected POST")
+		}
+		var reqBody schema.PlacementGroupCreateRequest
+		if err := json.NewDecoder(r.Body).Decode(&reqBody); err != nil {
+			t.Fatal(err)
+		}
+		expectedReqBody := schema.PlacementGroupCreateRequest{
+			Name:   opts.Name,
+			Labels: &opts.Labels,
+			Type:   string(opts.Type),
+		}
+		if !cmp.Equal(expectedReqBody, reqBody) {
+			t.Log(cmp.Diff(expectedReqBody, reqBody))
+			t.Error("unexpected request body")
+		}
+		json.NewEncoder(w).Encode(schema.PlacementGroupCreateResponse{
+			PlacementGroup: schema.PlacementGroup{
+				ID: id,
+			},
+		})
+	})
+
+	createdPlacementGroup, _, err := env.Client.PlacementGroup.Create(ctx, opts)
+	if err != nil {
+		t.Fatal(err)
+	}
+	if createdPlacementGroup.PlacementGroup == nil {
+		t.Fatal("no placement group")
+	}
+	if createdPlacementGroup.PlacementGroup.ID != id {
+		t.Errorf("unexpected placement group ID: %v", createdPlacementGroup.PlacementGroup.ID)
+	}
+}
+
+func TestPlacementGroupDelete(t *testing.T) {
+	env := newTestEnv()
+	defer env.Teardown()
+
+	const id = 1
+
+	env.Mux.HandleFunc(fmt.Sprintf("/placement_groups/%d", id), func(w http.ResponseWriter, r *http.Request) {})
+
+	var (
+		ctx            = context.Background()
+		placementGroup = &PlacementGroup{ID: id}
+	)
+
+	_, err := env.Client.PlacementGroup.Delete(ctx, placementGroup)
+	if err != nil {
+		t.Fatal(err)
+	}
+}
+
+func TestPlacementGroupUpdate(t *testing.T) {
+	env := newTestEnv()
+	defer env.Teardown()
+
+	const id = 1
+
+	var (
+		ctx            = context.Background()
+		placementGroup = &PlacementGroup{ID: id}
+		opts           = PlacementGroupUpdateOpts{
+			Name:   "test",
+			Labels: map[string]string{"key": "value"},
+		}
+	)
+
+	env.Mux.HandleFunc("/placement_groups/1", func(w http.ResponseWriter, r *http.Request) {
+		if r.Method != "PUT" {
+			t.Error("expected PUT")
+		}
+		var reqBody schema.PlacementGroupUpdateRequest
+		if err := json.NewDecoder(r.Body).Decode(&reqBody); err != nil {
+			t.Fatal(err)
+		}
+		expectedReqBody := schema.PlacementGroupUpdateRequest{
+			Name:   &opts.Name,
+			Labels: &opts.Labels,
+		}
+		if !cmp.Equal(expectedReqBody, reqBody) {
+			t.Log(cmp.Diff(expectedReqBody, reqBody))
+			t.Error("unexpected request body")
+		}
+		json.NewEncoder(w).Encode(schema.PlacementGroupUpdateResponse{
+			PlacementGroup: schema.PlacementGroup{
+				ID: id,
+			},
+		})
+	})
+
+	updatedPlacementGroup, _, err := env.Client.PlacementGroup.Update(ctx, placementGroup, opts)
+	if err != nil {
+		t.Fatal(err)
+	}
+	if updatedPlacementGroup == nil {
+		t.Fatal("no placement group")
+	}
+	if updatedPlacementGroup.ID != id {
+		t.Errorf("unexpected placement group ID: %v", updatedPlacementGroup.ID)
+	}
+}
diff --git a/hcloud/schema_test.go b/hcloud/schema_test.go
index cea77dc..adff95d 100644
--- a/hcloud/schema_test.go
+++ b/hcloud/schema_test.go
@@ -441,7 +441,20 @@ func TestServerFromSchema(t *testing.T) {
 			"key": "value",
 			"key2": "value2"
 		},
-		"volumes": [123, 456, 789]
+		"volumes": [123, 456, 789],
+		"placement_group": {
+			"created": "2019-01-08T12:10:00+00:00",
+			"id": 897,
+			"labels": {
+			  "key": "value"
+			},
+			"name": "my Placement Group",
+			"servers": [
+			  4711,
+			  4712
+			],
+			"type": "spread"
+		}
 	}`)
 
 	var s schema.Server
@@ -519,6 +532,9 @@ func TestServerFromSchema(t *testing.T) {
 	if server.PrivateNet[0].Network.ID != 4711 {
 		t.Errorf("unexpected first private net: %v", server.PrivateNet[0])
 	}
+	if server.PlacementGroup.ID != 897 {
+		t.Errorf("unexpected placement group: %d", server.PlacementGroup.ID)
+	}
 }
 
 func TestServerFromSchemaNoTraffic(t *testing.T) {
@@ -1799,6 +1815,20 @@ func TestPricingFromSchema(t *testing.T) {
 				"gross": "1.19"
 			}
 		},
+		"floating_ips": [
+			  {
+				"prices": [
+				  {
+					"location": "fsn1",
+					"price_monthly": {
+					  "gross": "1.19",
+					  "net": "1"
+					}
+				  }
+				],
+				"type": "ipv4"
+			  }
+			],
 		"traffic": {
 			"price_per_tb": {
 				"net": "1",
@@ -1886,6 +1916,35 @@ func TestPricingFromSchema(t *testing.T) {
 		t.Errorf("unexpected FloatingIP.Monthly.Gross: %v", pricing.FloatingIP.Monthly.Gross)
 	}
 
+	if len(pricing.FloatingIPs) != 1 {
+		t.Errorf("unexpected number of Floating IPs: %d", len(pricing.FloatingIPs))
+	} else {
+		p := pricing.FloatingIPs[0]
+
+		if p.Type != FloatingIPTypeIPv4 {
+			t.Errorf("unexpected .Type: %s", p.Type)
+		}
+		if len(p.Pricings) != 1 {
+			t.Errorf("unexpected number of prices: %d", len(p.Pricings))
+		} else {
+			if p.Pricings[0].Location.Name != "fsn1" {
+				t.Errorf("unexpected Location.Name: %v", p.Pricings[0].Location.Name)
+			}
+			if p.Pricings[0].Monthly.Currency != "EUR" {
+				t.Errorf("unexpected Monthly.Currency: %v", p.Pricings[0].Monthly.Currency)
+			}
+			if p.Pricings[0].Monthly.VATRate != "19.00" {
+				t.Errorf("unexpected Monthly.VATRate: %v", p.Pricings[0].Monthly.VATRate)
+			}
+			if p.Pricings[0].Monthly.Net != "1" {
+				t.Errorf("unexpected Monthly.Net: %v", p.Pricings[0].Monthly.Net)
+			}
+			if p.Pricings[0].Monthly.Gross != "1.19" {
+				t.Errorf("unexpected Monthly.Gross: %v", p.Pricings[0].Monthly.Gross)
+			}
+		}
+	}
+
 	if pricing.Volume.PerGBMonthly.Currency != "EUR" {
 		t.Errorf("unexpected Traffic.PerTB.Currency: %v", pricing.Volume.PerGBMonthly.Currency)
 	}
@@ -2797,3 +2856,44 @@ func TestFirewallFromSchema(t *testing.T) {
 		t.Errorf("unexpected UsedBy Label Selector: %s", firewall.AppliedTo[1].LabelSelector.Selector)
 	}
 }
+
+func TestPlacementGroupFromSchema(t *testing.T) {
+	data := []byte(`{
+		"created": "2019-01-08T12:10:00+00:00",
+		"id": 897,
+		"labels": {
+		  "key": "value"
+		},
+		"name": "my Placement Group",
+		"servers": [
+		  4711,
+		  4712
+		],
+		"type": "spread"
+	}
+`)
+
+	var g schema.PlacementGroup
+	if err := json.Unmarshal(data, &g); err != nil {
+		t.Fatal(err)
+	}
+	placementGroup := PlacementGroupFromSchema(g)
+	if placementGroup.ID != 897 {
+		t.Errorf("unexpected ID %d", placementGroup.ID)
+	}
+	if placementGroup.Name != "my Placement Group" {
+		t.Errorf("unexpected Name %s", placementGroup.Name)
+	}
+	if placementGroup.Labels["key"] != "value" {
+		t.Errorf("unexpected Labels: %v", placementGroup.Labels)
+	}
+	if !placementGroup.Created.Equal(time.Date(2019, 01, 8, 12, 10, 00, 0, time.UTC)) {
+		t.Errorf("unexpected Created date: %v", placementGroup.Created)
+	}
+	if len(placementGroup.Servers) != 2 {
+		t.Errorf("unexpected Servers %v", placementGroup.Servers)
+	}
+	if placementGroup.Type != PlacementGroupTypeSpread {
+		t.Errorf("unexpected Type %s", placementGroup.Type)
+	}
+}
diff --git a/hcloud/server_test.go b/hcloud/server_test.go
index 06b4204..f22e500 100644
--- a/hcloud/server_test.go
+++ b/hcloud/server_test.go
@@ -752,6 +752,55 @@ func TestServersCreateWithoutStarting(t *testing.T) {
 	}
 }
 
+func TestServerCreateWithPlacementGroup(t *testing.T) {
+	env := newTestEnv()
+	defer env.Teardown()
+
+	env.Mux.HandleFunc("/servers", func(w http.ResponseWriter, r *http.Request) {
+		var reqBody schema.ServerCreateRequest
+		if err := json.NewDecoder(r.Body).Decode(&reqBody); err != nil {
+			t.Fatal(err)
+		}
+		if reqBody.PlacementGroup != 123 {
+			t.Errorf("unexpected placement group id %d", reqBody.PlacementGroup)
+		}
+		json.NewEncoder(w).Encode(schema.ServerCreateResponse{
+			Server: schema.Server{
+				ID: 1,
+				PlacementGroup: &schema.PlacementGroup{
+					ID: 123,
+				},
+			},
+			NextActions: []schema.Action{
+				{ID: 2},
+			},
+		})
+	})
+
+	ctx := context.Background()
+	result, _, err := env.Client.Server.Create(ctx, ServerCreateOpts{
+		Name:           "test",
+		ServerType:     &ServerType{ID: 1},
+		Image:          &Image{ID: 2},
+		PlacementGroup: &PlacementGroup{ID: 123},
+	})
+	if err != nil {
+		t.Fatal(err)
+	}
+	if result.Server == nil {
+		t.Fatal("no server")
+	}
+	if result.Server.ID != 1 {
+		t.Errorf("unexpected server ID: %d", result.Server.ID)
+	}
+	if len(result.NextActions) != 1 || result.NextActions[0].ID != 2 {
+		t.Errorf("unexpected next actions: %v", result.NextActions)
+	}
+	if result.Server.PlacementGroup.ID != 123 {
+		t.Errorf("unexpected placement group ID: %d", result.Server.PlacementGroup.ID)
+	}
+}
+
 func TestServersDelete(t *testing.T) {
 	env := newTestEnv()
 	defer env.Teardown()
@@ -2062,3 +2111,80 @@ func serverMetricsOptsFromURL(t *testing.T, u *url.URL) ServerGetMetricsOpts {
 
 	return opts
 }
+
+func TestServerAddToPlacementGroup(t *testing.T) {
+	env := newTestEnv()
+	defer env.Teardown()
+
+	const (
+		serverID         = 1
+		actionID         = 42
+		placementGroupID = 123
+	)
+
+	env.Mux.HandleFunc(fmt.Sprintf("/servers/%d/actions/add_to_placement_group", serverID), func(w http.ResponseWriter, r *http.Request) {
+		if r.Method != "POST" {
+			t.Error("expected POST")
+		}
+		var reqBody schema.ServerActionAddToPlacementGroupRequest
+		if err := json.NewDecoder(r.Body).Decode(&reqBody); err != nil {
+			t.Fatal(err)
+		}
+		if reqBody.PlacementGroup != placementGroupID {
+			t.Errorf("unexpected PlacementGroup: %v", reqBody.PlacementGroup)
+		}
+		json.NewEncoder(w).Encode(schema.ServerActionAddToPlacementGroupResponse{
+			Action: schema.Action{
+				ID: actionID,
+			},
+		})
+	})
+
+	var (
+		ctx            = context.Background()
+		server         = &Server{ID: serverID}
+		placementGroup = &PlacementGroup{ID: placementGroupID}
+	)
+
+	action, _, err := env.Client.Server.AddToPlacementGroup(ctx, server, placementGroup)
+	if err != nil {
+		t.Fatal(err)
+	}
+	if action.ID != actionID {
+		t.Errorf("unexpected action ID: %v", action.ID)
+	}
+}
+
+func TestServerRemoveFromPlacementGroup(t *testing.T) {
+	env := newTestEnv()
+	defer env.Teardown()
+
+	const (
+		serverID = 1
+		actionID = 42
+	)
+
+	env.Mux.HandleFunc(fmt.Sprintf("/servers/%d/actions/remove_from_placement_group", serverID), func(w http.ResponseWriter, r *http.Request) {
+		if r.Method != "POST" {
+			t.Error("expected POST")
+		}
+		json.NewEncoder(w).Encode(schema.ServerActionRemoveFromPlacementGroupResponse{
+			Action: schema.Action{
+				ID: actionID,
+			},
+		})
+	})
+
+	var (
+		ctx    = context.Background()
+		server = &Server{ID: serverID}
+	)
+
+	action, _, err := env.Client.Server.RemoveFromPlacementGroup(ctx, server)
+	if err != nil {
+		t.Fatal(err)
+	}
+	if action.ID != actionID {
+		t.Errorf("unexpected action ID: %v", action.ID)
+	}
+}