Repository: hetznercloud/hcloud-go
Pull request #179 (2021-08-16 08:29:28)

Placement groups


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/schema.go b/hcloud/schema.go
index 8523084..96a9b6c 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
 }
 
@@ -765,6 +768,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/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..af73486 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) {
@@ -2797,3 +2813,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)
+	}
+}