diff --git a/hcloud/client.go b/hcloud/client.go index 25b713be..a4bb3371 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 00000000..d8df1952 --- /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/placement_group_test.go b/hcloud/placement_group_test.go new file mode 100644 index 00000000..334f5914 --- /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.go b/hcloud/schema.go index 8523084c..96a9b6cd 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 00000000..6bee4390 --- /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 29dd020a..229e2889 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/schema_test.go b/hcloud/schema_test.go index cea77dcf..af73486f 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.go b/hcloud/server.go index 8d593c06..c09cecb5 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 +} diff --git a/hcloud/server_test.go b/hcloud/server_test.go index 06b42044..f22e500a 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) + } +}