diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index cfd77fb..f95e854 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -17,7 +17,7 @@ test:tests: stage: test - image: golang:1.17 + image: golang:1.18 script: - go test -v -race ./... except: diff --git a/hcloud/client.go b/hcloud/client.go index ae2a7ea..04ddaec 100644 --- a/hcloud/client.go +++ b/hcloud/client.go @@ -82,6 +82,7 @@ Volume VolumeClient PlacementGroup PlacementGroupClient RDNS RDNSClient + PrimaryIP PrimaryIPClient } // A ClientOption is used to configure a Client. @@ -187,6 +188,7 @@ client.Firewall = FirewallClient{client: client} client.PlacementGroup = PlacementGroupClient{client: client} client.RDNS = RDNSClient{client: client} + client.PrimaryIP = PrimaryIPClient{client: client} return client } diff --git a/hcloud/hcloud.go b/hcloud/hcloud.go index f9665d2..730c25d 100644 --- a/hcloud/hcloud.go +++ b/hcloud/hcloud.go @@ -2,4 +2,4 @@ package hcloud // Version is the library's version following Semantic Versioning. -const Version = "1.34.0" +const Version = "1.35.0" diff --git a/hcloud/pricing.go b/hcloud/pricing.go index 5d1b23b..67be21f 100644 --- a/hcloud/pricing.go +++ b/hcloud/pricing.go @@ -11,6 +11,7 @@ Image ImagePricing FloatingIP FloatingIPPricing FloatingIPs []FloatingIPTypePricing + PrimaryIPs []PrimaryIPPricing Traffic TrafficPricing ServerBackup ServerBackupPricing ServerTypes []ServerTypePricing @@ -28,6 +29,14 @@ Gross string } +// PrimaryIPPrice represents a price. Net amount and gross amount are +// specified as strings and it is the user's responsibility to convert them to +// appropriate types for calculations. +type PrimaryIPPrice struct { + Net string + Gross string +} + // ImagePricing provides pricing information for imaegs. type ImagePricing struct { PerGBMonth Price @@ -42,6 +51,20 @@ type FloatingIPTypePricing struct { Type FloatingIPType Pricings []FloatingIPTypeLocationPricing +} + +// PrimaryIPTypePricing defines the schema of pricing information for a primary IP +// type at a datacenter. +type PrimaryIPTypePricing struct { + Datacenter string + Hourly PrimaryIPPrice + Monthly PrimaryIPPrice +} + +// PrimaryIPTypePricing provides pricing information for PrimaryIPs +type PrimaryIPPricing struct { + Type string + Pricings []PrimaryIPTypePricing } // FloatingIPTypeLocationPricing provides pricing information for a Floating IP type diff --git a/hcloud/primary_ip.go b/hcloud/primary_ip.go new file mode 100644 index 0000000..c534c89 --- /dev/null +++ b/hcloud/primary_ip.go @@ -0,0 +1,399 @@ +package hcloud + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net" + "net/url" + "strconv" + "time" + + "github.com/hetznercloud/hcloud-go/hcloud/schema" +) + +// PrimaryIP defines a Primary IP +type PrimaryIP struct { + ID int + IP net.IP + Network *net.IPNet + Labels map[string]string + Name string + Type PrimaryIPType + Protection PrimaryIPProtection + DNSPtr map[string]string + AssigneeID int + AssigneeType string + AutoDelete bool + Blocked bool + Created time.Time + Datacenter *Datacenter +} + +// PrimaryIPProtection represents the protection level of a Primary IP. +type PrimaryIPProtection struct { + Delete bool +} + +// PrimaryIPDNSPTR contains reverse DNS information for a +// IPv4 or IPv6 Primary IP. +type PrimaryIPDNSPTR struct { + DNSPtr string + IP string +} + +// GetDNSPtrForIP searches for the dns assigned to the given IP address. +// It returns an error if there is no dns set for the given IP address. +func (p *PrimaryIP) GetDNSPtrForIP(ip net.IP) (string, error) { + dns, ok := p.DNSPtr[ip.String()] + if !ok { + return "", DNSNotFoundError{ip} + } + + return dns, nil +} + +// PrimaryIPType represents the type of Primary IP. +type PrimaryIPType string + +// PrimaryIPType Primary IP types. +const ( + PrimaryIPTypeIPv4 PrimaryIPType = "ipv4" + PrimaryIPTypeIPv6 PrimaryIPType = "ipv6" +) + +// PrimaryIPCreateOpts defines the request to +// create a Primary IP. +type PrimaryIPCreateOpts struct { + AssigneeID *int `json:"assignee_id,omitempty"` + AssigneeType string `json:"assignee_type"` + AutoDelete *bool `json:"auto_delete,omitempty"` + Datacenter string `json:"datacenter,omitempty"` + Labels map[string]string `json:"labels,omitempty"` + Name string `json:"name"` + Type PrimaryIPType `json:"type"` +} + +// PrimaryIPCreateResult defines the response +// when creating a Primary IP. +type PrimaryIPCreateResult struct { + PrimaryIP *PrimaryIP + Action *Action +} + +// PrimaryIPUpdateOpts defines the request to +// update a Primary IP. +type PrimaryIPUpdateOpts struct { + AutoDelete *bool `json:"auto_delete,omitempty"` + Labels *map[string]string `json:"labels,omitempty"` + Name string `json:"name,omitempty"` +} + +// PrimaryIPUpdateResult defines the response +// when updating a Primary IP. +type PrimaryIPUpdateResult struct { + PrimaryIP PrimaryIP `json:"primary_ip"` +} + +// PrimaryIPAssignOpts defines the request to +// assign a Primary IP to an assignee (usually a server). +type PrimaryIPAssignOpts struct { + ID int + AssigneeID int `json:"assignee_id"` + AssigneeType string `json:"assignee_type"` +} + +// PrimaryIPAssignResult defines the response +// when assigning a Primary IP to a assignee. +type PrimaryIPAssignResult struct { + Action schema.Action `json:"action"` +} + +// PrimaryIPChangeDNSPtrOpts defines the request to +// change a DNS PTR entry from a Primary IP +type PrimaryIPChangeDNSPtrOpts struct { + ID int + DNSPtr string `json:"dns_ptr"` + IP string `json:"ip"` +} + +// PrimaryIPChangeDNSPtrResult defines the response +// when assigning a Primary IP to a assignee. +type PrimaryIPChangeDNSPtrResult struct { + Action schema.Action `json:"action"` +} + +// PrimaryIPChangeProtectionOpts defines the request to +// change protection configuration of a Primary IP +type PrimaryIPChangeProtectionOpts struct { + ID int + Delete bool `json:"delete"` +} + +// PrimaryIPChangeProtectionResult defines the response +// when changing a protection of a PrimaryIP +type PrimaryIPChangeProtectionResult struct { + Action schema.Action `json:"action"` +} + +// PrimaryIPClient is a client for the Primary IP API +type PrimaryIPClient struct { + client *Client +} + +// GetByID retrieves a Primary IP by its ID. If the Primary IP does not exist, nil is returned. +func (c *PrimaryIPClient) GetByID(ctx context.Context, id int) (*PrimaryIP, *Response, error) { + req, err := c.client.NewRequest(ctx, "GET", fmt.Sprintf("/primary_ips/%d", id), nil) + if err != nil { + return nil, nil, err + } + + var body schema.PrimaryIPGetResult + resp, err := c.client.Do(req, &body) + if err != nil { + if IsError(err, ErrorCodeNotFound) { + return nil, resp, nil + } + return nil, nil, err + } + return PrimaryIPFromSchema(body.PrimaryIP), resp, nil +} + +// GetByIP retrieves a Primary IP by its IP Address. If the Primary IP does not exist, nil is returned. +func (c *PrimaryIPClient) GetByIP(ctx context.Context, ip string) (*PrimaryIP, *Response, error) { + if ip == "" { + return nil, nil, nil + } + primaryIPs, response, err := c.List(ctx, PrimaryIPListOpts{IP: ip}) + if len(primaryIPs) == 0 { + return nil, response, err + } + return primaryIPs[0], response, err +} + +// GetByName retrieves a Primary IP by its name. If the Primary IP does not exist, nil is returned. +func (c *PrimaryIPClient) GetByName(ctx context.Context, name string) (*PrimaryIP, *Response, error) { + if name == "" { + return nil, nil, nil + } + primaryIPs, response, err := c.List(ctx, PrimaryIPListOpts{Name: name}) + if len(primaryIPs) == 0 { + return nil, response, err + } + return primaryIPs[0], response, err +} + +// Get retrieves a Primary IP by its ID if the input can be parsed as an integer, otherwise it +// retrieves a Primary IP by its name. If the Primary IP does not exist, nil is returned. +func (c *PrimaryIPClient) Get(ctx context.Context, idOrName string) (*PrimaryIP, *Response, error) { + if id, err := strconv.Atoi(idOrName); err == nil { + return c.GetByID(ctx, int(id)) + } + return c.GetByName(ctx, idOrName) +} + +// PrimaryIPListOpts specifies options for listing Primary IPs. +type PrimaryIPListOpts struct { + ListOpts + Name string + IP string + Sort []string +} + +func (l PrimaryIPListOpts) values() url.Values { + vals := l.ListOpts.values() + if l.Name != "" { + vals.Add("name", l.Name) + } + if l.IP != "" { + vals.Add("ip", l.IP) + } + for _, sort := range l.Sort { + vals.Add("sort", sort) + } + return vals +} + +// List returns a list of Primary IPs 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 *PrimaryIPClient) List(ctx context.Context, opts PrimaryIPListOpts) ([]*PrimaryIP, *Response, error) { + path := "/primary_ips?" + opts.values().Encode() + req, err := c.client.NewRequest(ctx, "GET", path, nil) + if err != nil { + return nil, nil, err + } + + var body schema.PrimaryIPListResult + resp, err := c.client.Do(req, &body) + if err != nil { + return nil, nil, err + } + primaryIPs := make([]*PrimaryIP, 0, len(body.PrimaryIPs)) + for _, s := range body.PrimaryIPs { + primaryIPs = append(primaryIPs, PrimaryIPFromSchema(s)) + } + return primaryIPs, resp, nil +} + +// All returns all Primary IPs. +func (c *PrimaryIPClient) All(ctx context.Context) ([]*PrimaryIP, error) { + allPrimaryIPs := []*PrimaryIP{} + + opts := PrimaryIPListOpts{} + opts.PerPage = 50 + + err := c.client.all(func(page int) (*Response, error) { + opts.Page = page + primaryIPs, resp, err := c.List(ctx, opts) + if err != nil { + return resp, err + } + allPrimaryIPs = append(allPrimaryIPs, primaryIPs...) + return resp, nil + }) + if err != nil { + return nil, err + } + + return allPrimaryIPs, nil +} + +// Create creates a Primary IP. +func (c *PrimaryIPClient) Create(ctx context.Context, reqBody PrimaryIPCreateOpts) (*PrimaryIPCreateResult, *Response, error) { + reqBodyData, err := json.Marshal(reqBody) + if err != nil { + return &PrimaryIPCreateResult{}, nil, err + } + + req, err := c.client.NewRequest(ctx, "POST", "/primary_ips", bytes.NewReader(reqBodyData)) + if err != nil { + return &PrimaryIPCreateResult{}, nil, err + } + + var respBody schema.PrimaryIPCreateResponse + resp, err := c.client.Do(req, &respBody) + if err != nil { + return &PrimaryIPCreateResult{}, resp, err + } + var action *Action + if respBody.Action != nil { + action = ActionFromSchema(*respBody.Action) + } + primaryIP := PrimaryIPFromSchema(respBody.PrimaryIP) + return &PrimaryIPCreateResult{ + PrimaryIP: primaryIP, + Action: action, + }, resp, nil +} + +// Delete deletes a Primary IP. +func (c *PrimaryIPClient) Delete(ctx context.Context, primaryIP *PrimaryIP) (*Response, error) { + req, err := c.client.NewRequest(ctx, "DELETE", fmt.Sprintf("/primary_ips/%d", primaryIP.ID), nil) + if err != nil { + return nil, err + } + return c.client.Do(req, nil) +} + +// Update updates a Primary IP. +func (c *PrimaryIPClient) Update(ctx context.Context, primaryIP *PrimaryIP, reqBody PrimaryIPUpdateOpts) (*PrimaryIP, *Response, error) { + reqBodyData, err := json.Marshal(reqBody) + if err != nil { + return nil, nil, err + } + + path := fmt.Sprintf("/primary_ips/%d", primaryIP.ID) + req, err := c.client.NewRequest(ctx, "PUT", path, bytes.NewReader(reqBodyData)) + if err != nil { + return nil, nil, err + } + + respBody := PrimaryIPUpdateResult{} + resp, err := c.client.Do(req, &respBody) + if err != nil { + return nil, resp, err + } + return &respBody.PrimaryIP, resp, nil +} + +// Assign a Primary IP to a resource +func (c *PrimaryIPClient) Assign(ctx context.Context, opts PrimaryIPAssignOpts) (*Action, *Response, error) { + reqBodyData, err := json.Marshal(opts) + if err != nil { + return nil, nil, err + } + + path := fmt.Sprintf("/primary_ips/%d/actions/assign", opts.ID) + req, err := c.client.NewRequest(ctx, "POST", path, bytes.NewReader(reqBodyData)) + if err != nil { + return nil, nil, err + } + + var respBody PrimaryIPAssignResult + resp, err := c.client.Do(req, &respBody) + if err != nil { + return nil, resp, err + } + return ActionFromSchema(respBody.Action), resp, nil +} + +// Unassign a Primary IP from a resource +func (c *PrimaryIPClient) Unassign(ctx context.Context, id int) (*Action, *Response, error) { + path := fmt.Sprintf("/primary_ips/%d/actions/unassign", id) + req, err := c.client.NewRequest(ctx, "POST", path, bytes.NewReader([]byte{})) + if err != nil { + return nil, nil, err + } + + var respBody PrimaryIPAssignResult + resp, err := c.client.Do(req, &respBody) + if err != nil { + return nil, resp, err + } + return ActionFromSchema(respBody.Action), resp, nil +} + +// ChangeDNSPtr Change the reverse DNS from a Primary IP +func (c *PrimaryIPClient) ChangeDNSPtr(ctx context.Context, opts PrimaryIPChangeDNSPtrOpts) (*Action, *Response, error) { + reqBodyData, err := json.Marshal(opts) + if err != nil { + return nil, nil, err + } + + path := fmt.Sprintf("/primary_ips/%d/actions/change_dns_ptr", opts.ID) + req, err := c.client.NewRequest(ctx, "POST", path, bytes.NewReader(reqBodyData)) + if err != nil { + return nil, nil, err + } + + var respBody PrimaryIPChangeDNSPtrResult + resp, err := c.client.Do(req, &respBody) + if err != nil { + return nil, resp, err + } + return ActionFromSchema(respBody.Action), resp, nil +} + +// ChangeProtection Changes the protection configuration of a Primary IP. +func (c *PrimaryIPClient) ChangeProtection(ctx context.Context, opts PrimaryIPChangeProtectionOpts) (*Action, *Response, error) { + reqBodyData, err := json.Marshal(opts) + if err != nil { + return nil, nil, err + } + + path := fmt.Sprintf("/primary_ips/%d/actions/change_protection", opts.ID) + req, err := c.client.NewRequest(ctx, "POST", path, bytes.NewReader(reqBodyData)) + if err != nil { + return nil, nil, err + } + + var respBody PrimaryIPChangeProtectionResult + resp, err := c.client.Do(req, &respBody) + if err != nil { + return nil, resp, err + } + return ActionFromSchema(respBody.Action), resp, nil +} diff --git a/hcloud/primary_ip_test.go b/hcloud/primary_ip_test.go new file mode 100644 index 0000000..6c517ee --- /dev/null +++ b/hcloud/primary_ip_test.go @@ -0,0 +1,488 @@ +package hcloud + +import ( + "context" + "encoding/json" + "net/http" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/hetznercloud/hcloud-go/hcloud/schema" + "github.com/stretchr/testify/assert" +) + +func TestPrimaryIPClient(t *testing.T) { + t.Run("GetByID", func(t *testing.T) { + env := newTestEnv() + defer env.Teardown() + + env.Mux.HandleFunc("/primary_ips/1", func(w http.ResponseWriter, r *http.Request) { + json.NewEncoder(w).Encode(schema.PrimaryIPGetResult{ + PrimaryIP: schema.PrimaryIP{ + ID: 1, + }, + }) + }) + + ctx := context.Background() + primaryIP, _, err := env.Client.PrimaryIP.GetByID(ctx, 1) + if err != nil { + t.Fatal(err) + } + if primaryIP == nil { + t.Fatal("no primary_ip") + } + if primaryIP.ID != 1 { + t.Errorf("unexpected primary_ip ID: %v", primaryIP.ID) + } + + t.Run("via Get", func(t *testing.T) { + primaryIP, _, err := env.Client.PrimaryIP.Get(ctx, "1") + if err != nil { + t.Fatal(err) + } + if primaryIP == nil { + t.Fatal("no primary_ip") + } + if primaryIP.ID != 1 { + t.Errorf("unexpected primary_ip ID: %v", primaryIP.ID) + } + }) + }) + + t.Run("GetByID (not found)", func(t *testing.T) { + env := newTestEnv() + defer env.Teardown() + + env.Mux.HandleFunc("/primary_ips/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() + primaryIP, _, err := env.Client.PrimaryIP.GetByID(ctx, 1) + if err != nil { + t.Fatal(err) + } + if primaryIP != nil { + t.Fatal("expected no primary_ip") + } + }) + + t.Run("GetByName", func(t *testing.T) { + env := newTestEnv() + defer env.Teardown() + + env.Mux.HandleFunc("/primary_ips", func(w http.ResponseWriter, r *http.Request) { + if r.URL.RawQuery != "name=fsn1-dc8" { + t.Fatal("missing name query") + } + json.NewEncoder(w).Encode(schema.PrimaryIPListResult{ + PrimaryIPs: []schema.PrimaryIP{ + { + ID: 1, + }, + }, + }) + }) + + ctx := context.Background() + primaryIP, _, err := env.Client.PrimaryIP.GetByName(ctx, "fsn1-dc8") + if err != nil { + t.Fatal(err) + } + if primaryIP == nil { + t.Fatal("no primary_ip") + } + if primaryIP.ID != 1 { + t.Errorf("unexpected primary_ip ID: %v", primaryIP.ID) + } + + t.Run("via Get", func(t *testing.T) { + primaryIP, _, err := env.Client.PrimaryIP.Get(ctx, "fsn1-dc8") + if err != nil { + t.Fatal(err) + } + if primaryIP == nil { + t.Fatal("no primary_ip") + } + if primaryIP.ID != 1 { + t.Errorf("unexpected primary_ip ID: %v", primaryIP.ID) + } + }) + }) + + t.Run("GetByName (not found)", func(t *testing.T) { + env := newTestEnv() + defer env.Teardown() + + env.Mux.HandleFunc("/primary_ips", func(w http.ResponseWriter, r *http.Request) { + if r.URL.RawQuery != "name=fsn1-dc8" { + t.Fatal("missing name query") + } + json.NewEncoder(w).Encode(schema.PrimaryIPListResult{ + PrimaryIPs: []schema.PrimaryIP{}, + }) + }) + + ctx := context.Background() + primaryIP, _, err := env.Client.PrimaryIP.GetByName(ctx, "fsn1-dc8") + if err != nil { + t.Fatal(err) + } + if primaryIP != nil { + t.Fatal("unexpected primary_ip") + } + }) + + t.Run("GetByName (empty)", func(t *testing.T) { + env := newTestEnv() + defer env.Teardown() + + ctx := context.Background() + primaryIP, _, err := env.Client.PrimaryIP.GetByName(ctx, "") + if err != nil { + t.Fatal(err) + } + if primaryIP != nil { + t.Fatal("unexpected primary_ip") + } + }) + + t.Run("GetByIP", func(t *testing.T) { + env := newTestEnv() + defer env.Teardown() + + env.Mux.HandleFunc("/primary_ips", func(w http.ResponseWriter, r *http.Request) { + if r.URL.RawQuery != "ip=127.0.0.1" { + t.Fatal("missing name query") + } + json.NewEncoder(w).Encode(schema.PrimaryIPListResult{ + PrimaryIPs: []schema.PrimaryIP{ + { + ID: 1, + }, + }, + }) + }) + + ctx := context.Background() + primaryIP, _, err := env.Client.PrimaryIP.GetByIP(ctx, "127.0.0.1") + if err != nil { + t.Fatal(err) + } + if primaryIP == nil { + t.Fatal("no primary_ip") + } + if primaryIP.ID != 1 { + t.Errorf("unexpected primary_ip ID: %v", primaryIP.ID) + } + }) + + t.Run("List", func(t *testing.T) { + env := newTestEnv() + defer env.Teardown() + + env.Mux.HandleFunc("/primary_ips", func(w http.ResponseWriter, r *http.Request) { + if page := r.URL.Query().Get("page"); page != "2" { + t.Errorf("expected page 2; got %q", page) + } + if perPage := r.URL.Query().Get("per_page"); perPage != "50" { + t.Errorf("expected per_page 50; got %q", perPage) + } + if name := r.URL.Query().Get("name"); name != "nbg1-dc3" { + t.Errorf("expected name nbg1-dc3; got %q", name) + } + json.NewEncoder(w).Encode(schema.PrimaryIPListResult{ + PrimaryIPs: []schema.PrimaryIP{ + {ID: 1}, + {ID: 2}, + }, + }) + }) + + opts := PrimaryIPListOpts{} + opts.Page = 2 + opts.PerPage = 50 + opts.Name = "nbg1-dc3" + + ctx := context.Background() + primaryIPs, _, err := env.Client.PrimaryIP.List(ctx, opts) + if err != nil { + t.Fatal(err) + } + if len(primaryIPs) != 2 { + t.Fatal("expected 2 primary_ips") + } + }) + + t.Run("All", func(t *testing.T) { + env := newTestEnv() + defer env.Teardown() + + env.Mux.HandleFunc("/primary_ips", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(struct { + PrimaryIPs []PrimaryIP `json:"primary_ips"` + Meta schema.Meta `json:"meta"` + }{ + PrimaryIPs: []PrimaryIP{ + {ID: 1}, + {ID: 2}, + {ID: 3}, + }, + Meta: schema.Meta{ + Pagination: &schema.MetaPagination{ + Page: 1, + LastPage: 1, + PerPage: 3, + TotalEntries: 3, + }, + }, + }) + }) + + ctx := context.Background() + primaryIPs, err := env.Client.PrimaryIP.All(ctx) + if err != nil { + t.Fatalf("PrimaryIP.List failed: %s", err) + } + if len(primaryIPs) != 3 { + t.Fatalf("expected 3 primary_ips; got %d", len(primaryIPs)) + } + if primaryIPs[0].ID != 1 || primaryIPs[1].ID != 2 || primaryIPs[2].ID != 3 { + t.Errorf("unexpected primary_ips") + } + }) + t.Run("Create", func(t *testing.T) { + env := newTestEnv() + defer env.Teardown() + + env.Mux.HandleFunc("/primary_ips", func(w http.ResponseWriter, r *http.Request) { + var reqBody PrimaryIPCreateOpts + if r.Method != "POST" { + t.Error("expected POST") + } + w.Header().Set("Content-Type", "application/json") + expectedReqBody := PrimaryIPCreateOpts{ + Name: "my-primary-ip", + Type: PrimaryIPTypeIPv4, + AssigneeType: "server", + Datacenter: "fsn-dc14", + Labels: func() map[string]string { + labels := map[string]string{"key": "value"} + return labels + }(), + } + if err := json.NewDecoder(r.Body).Decode(&reqBody); err != nil { + t.Fatal(err) + } + if !cmp.Equal(expectedReqBody, reqBody) { + t.Log(cmp.Diff(expectedReqBody, reqBody)) + t.Error("unexpected request body") + } + json.NewEncoder(w).Encode(PrimaryIPCreateResult{ + PrimaryIP: &PrimaryIP{ID: 1}, + Action: &Action{ID: 14}, + }) + }) + + ctx := context.Background() + opts := PrimaryIPCreateOpts{ + Name: "my-primary-ip", + Type: PrimaryIPTypeIPv4, + AssigneeType: "server", + Labels: map[string]string{"key": "value"}, + Datacenter: "fsn-dc14", + } + + result, resp, err := env.Client.PrimaryIP.Create(ctx, opts) + assert.NoError(t, err) + assert.NotNil(t, resp, "no response returned") + assert.NotNil(t, result.PrimaryIP, "no primary IP returned") + assert.NotNil(t, result.Action, "no action returned") + }) + t.Run("Update", func(t *testing.T) { + env := newTestEnv() + defer env.Teardown() + + env.Mux.HandleFunc("/primary_ips/1", func(w http.ResponseWriter, r *http.Request) { + var reqBody PrimaryIPUpdateOpts + if r.Method != "PUT" { + t.Error("expected PUT") + } + w.Header().Set("Content-Type", "application/json") + autoDelete := true + expectedReqBody := PrimaryIPUpdateOpts{ + Name: "my-primary-ip", + AutoDelete: &autoDelete, + Labels: func() *map[string]string { + labels := map[string]string{"key": "value"} + return &labels + }(), + } + if err := json.NewDecoder(r.Body).Decode(&reqBody); err != nil { + t.Fatal(err) + } + if !cmp.Equal(expectedReqBody, reqBody) { + t.Log(cmp.Diff(expectedReqBody, reqBody)) + t.Error("unexpected request body") + } + json.NewEncoder(w).Encode(PrimaryIPUpdateResult{ + PrimaryIP: PrimaryIP{ID: 1}, + }) + }) + + ctx := context.Background() + labels := map[string]string{"key": "value"} + autoDelete := true + opts := PrimaryIPUpdateOpts{ + Name: "my-primary-ip", + AutoDelete: &autoDelete, + Labels: &labels, + } + + primaryIP := PrimaryIP{ID: 1} + result, resp, err := env.Client.PrimaryIP.Update(ctx, &primaryIP, opts) + assert.NoError(t, err) + assert.NotNil(t, resp, "no response returned") + assert.Equal(t, *result, primaryIP, "no primary IP returned") + }) + t.Run("Assign", func(t *testing.T) { + env := newTestEnv() + defer env.Teardown() + + env.Mux.HandleFunc("/primary_ips/1/actions/assign", func(w http.ResponseWriter, r *http.Request) { + var reqBody PrimaryIPAssignOpts + if r.Method != "POST" { + t.Error("expected POST") + } + w.Header().Set("Content-Type", "application/json") + expectedReqBody := PrimaryIPAssignOpts{ + AssigneeType: "server", + AssigneeID: 1, + ID: 1, + } + if err := json.NewDecoder(r.Body).Decode(&reqBody); err != nil { + t.Fatal(err) + } + if !cmp.Equal(expectedReqBody, reqBody) { + t.Log(cmp.Diff(expectedReqBody, reqBody)) + t.Error("unexpected request body") + } + json.NewEncoder(w).Encode(PrimaryIPAssignResult{ + Action: schema.Action{ID: 1}, + }) + }) + + ctx := context.Background() + opts := PrimaryIPAssignOpts{ + AssigneeType: "server", + AssigneeID: 1, + ID: 1, + } + + action, resp, err := env.Client.PrimaryIP.Assign(ctx, opts) + assert.NoError(t, err) + assert.NotNil(t, resp, "no response returned") + assert.NotNil(t, action, "no action returned") + }) + t.Run("Unassign", func(t *testing.T) { + env := newTestEnv() + defer env.Teardown() + + env.Mux.HandleFunc("/primary_ips/1/actions/unassign", func(w http.ResponseWriter, r *http.Request) { + if r.Method != "POST" { + t.Error("expected POST") + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(PrimaryIPAssignResult{ + Action: schema.Action{ID: 1}, + }) + }) + + ctx := context.Background() + + action, resp, err := env.Client.PrimaryIP.Unassign(ctx, 1) + assert.NoError(t, err) + assert.NotNil(t, resp, "no response returned") + assert.NotNil(t, action, "no action returned") + }) + t.Run("ChangeDNSPtr", func(t *testing.T) { + env := newTestEnv() + defer env.Teardown() + + env.Mux.HandleFunc("/primary_ips/1/actions/change_dns_ptr", func(w http.ResponseWriter, r *http.Request) { + var reqBody PrimaryIPChangeDNSPtrOpts + if r.Method != "POST" { + t.Error("expected POST") + } + w.Header().Set("Content-Type", "application/json") + expectedReqBody := PrimaryIPChangeDNSPtrOpts{ + ID: 1, + } + if err := json.NewDecoder(r.Body).Decode(&reqBody); err != nil { + t.Fatal(err) + } + if !cmp.Equal(expectedReqBody, reqBody) { + t.Log(cmp.Diff(expectedReqBody, reqBody)) + t.Error("unexpected request body") + } + json.NewEncoder(w).Encode(PrimaryIPChangeDNSPtrResult{ + Action: schema.Action{ID: 1}, + }) + }) + + ctx := context.Background() + opts := PrimaryIPChangeDNSPtrOpts{ + ID: 1, + } + + action, resp, err := env.Client.PrimaryIP.ChangeDNSPtr(ctx, opts) + assert.NoError(t, err) + assert.NotNil(t, resp, "no response returned") + assert.NotNil(t, action, "no action returned") + }) + t.Run("ChangeProtection", func(t *testing.T) { + env := newTestEnv() + defer env.Teardown() + + env.Mux.HandleFunc("/primary_ips/1/actions/change_protection", func(w http.ResponseWriter, r *http.Request) { + var reqBody PrimaryIPChangeProtectionOpts + if r.Method != "POST" { + t.Error("expected POST") + } + w.Header().Set("Content-Type", "application/json") + expectedReqBody := PrimaryIPChangeProtectionOpts{ + ID: 1, + Delete: true, + } + if err := json.NewDecoder(r.Body).Decode(&reqBody); err != nil { + t.Fatal(err) + } + if !cmp.Equal(expectedReqBody, reqBody) { + t.Log(cmp.Diff(expectedReqBody, reqBody)) + t.Error("unexpected request body") + } + json.NewEncoder(w).Encode(PrimaryIPChangeProtectionResult{ + Action: schema.Action{ID: 1}, + }) + }) + + ctx := context.Background() + opts := PrimaryIPChangeProtectionOpts{ + ID: 1, + Delete: true, + } + + action, resp, err := env.Client.PrimaryIP.ChangeProtection(ctx, opts) + assert.NoError(t, err) + assert.NotNil(t, resp, "no response returned") + assert.NotNil(t, action, "no action returned") + }) +} diff --git a/hcloud/schema/pricing.go b/hcloud/schema/pricing.go index 277fa1a..b97a2f8 100644 --- a/hcloud/schema/pricing.go +++ b/hcloud/schema/pricing.go @@ -7,6 +7,7 @@ Image PricingImage `json:"image"` FloatingIP PricingFloatingIP `json:"floating_ip"` FloatingIPs []PricingFloatingIPType `json:"floating_ips"` + PrimaryIPs []PricingPrimaryIP `json:"primary_ips"` Traffic PricingTraffic `json:"traffic"` ServerBackup PricingServerBackup `json:"server_backup"` ServerTypes []PricingServerType `json:"server_types"` @@ -92,3 +93,17 @@ type PricingGetResponse struct { Pricing Pricing `json:"pricing"` } + +// PricingPrimaryIPTypePrice defines the schema of pricing information for a primary IP +// type at a datacenter. +type PricingPrimaryIPTypePrice struct { + Datacenter string `json:"datacenter"` + PriceHourly Price `json:"price_hourly"` + PriceMonthly Price `json:"price_monthly"` +} + +// PricingPrimaryIP define the schema of pricing information for a primary IP at a datacenter +type PricingPrimaryIP struct { + Type string `json:"type"` + Prices []PricingPrimaryIPTypePrice `json:"prices"` +} diff --git a/hcloud/schema/primary_ip.go b/hcloud/schema/primary_ip.go new file mode 100644 index 0000000..b21e28b --- /dev/null +++ b/hcloud/schema/primary_ip.go @@ -0,0 +1,49 @@ +package schema + +import "time" + +// PrimaryIP defines a Primary IP +type PrimaryIP struct { + ID int `json:"id"` + IP string `json:"ip"` + Labels map[string]string `json:"labels"` + Name string `json:"name"` + Type string `json:"type"` + Protection PrimaryIPProtection `json:"protection"` + DNSPtr []PrimaryIPDNSPTR `json:"dns_ptr"` + AssigneeID int `json:"assignee_id"` + AssigneeType string `json:"assignee_type"` + AutoDelete bool `json:"auto_delete"` + Blocked bool `json:"blocked"` + Created time.Time `json:"created"` + Datacenter Datacenter `json:"datacenter"` +} + +// PrimaryIPProtection represents the protection level of a Primary IP. +type PrimaryIPProtection struct { + Delete bool `json:"delete"` +} + +// PrimaryIPDNSPTR contains reverse DNS information for a +// IPv4 or IPv6 Primary IP. +type PrimaryIPDNSPTR struct { + DNSPtr string `json:"dns_ptr"` + IP string `json:"ip"` +} + +// PrimaryIPCreateResponse defines the schema of the response +// when creating a Primary IP. +type PrimaryIPCreateResponse struct { + PrimaryIP PrimaryIP `json:"primary_ip"` + Action *Action `json:"action"` +} + +// PrimaryIPGetResult defines the response when retrieving a single Primary IP. +type PrimaryIPGetResult struct { + PrimaryIP PrimaryIP `json:"primary_ip"` +} + +// PrimaryIPListResult defines the response when listing Primary IPs. +type PrimaryIPListResult struct { + PrimaryIPs []PrimaryIP `json:"primary_ips"` +} diff --git a/hcloud/schema/server.go b/hcloud/schema/server.go index 229e288..616b2eb 100644 --- a/hcloud/schema/server.go +++ b/hcloud/schema/server.go @@ -45,6 +45,7 @@ // ServerPublicNetIPv4 defines the schema of a server's public // network information for an IPv4. type ServerPublicNetIPv4 struct { + ID int `json:"id"` IP string `json:"ip"` Blocked bool `json:"blocked"` DNSPtr string `json:"dns_ptr"` @@ -53,6 +54,7 @@ // ServerPublicNetIPv6 defines the schema of a server's public // network information for an IPv6. type ServerPublicNetIPv6 struct { + ID int `json:"id"` IP string `json:"ip"` Blocked bool `json:"blocked"` DNSPtr []ServerPublicNetIPv6DNSPtr `json:"dns_ptr"` @@ -109,9 +111,18 @@ 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. + PublicNet *ServerCreatePublicNet `json:"public_net,omitempty"` +} + +// ServerCreatePublicNet defines the public network configuration of a server. +type ServerCreatePublicNet struct { + EnableIPv4 bool `json:"enable_ipv4"` + EnableIPv6 bool `json:"enable_ipv6"` + IPv4ID int `json:"ipv4,omitempty"` + IPv6ID int `json:"ipv6,omitempty"` +} + +// ServerCreateFirewalls defines which Firewalls to apply when creating a Server. type ServerCreateFirewalls struct { Firewall int `json:"firewall"` } diff --git a/hcloud/schema.go b/hcloud/schema.go index 769f582..633a568 100644 --- a/hcloud/schema.go +++ b/hcloud/schema.go @@ -67,6 +67,40 @@ f.Server = &Server{ID: *s.Server} } if f.Type == FloatingIPTypeIPv4 { + f.IP = net.ParseIP(s.IP) + } else { + f.IP, f.Network, _ = net.ParseCIDR(s.IP) + } + f.DNSPtr = map[string]string{} + for _, entry := range s.DNSPtr { + f.DNSPtr[entry.IP] = entry.DNSPtr + } + f.Labels = map[string]string{} + for key, value := range s.Labels { + f.Labels[key] = value + } + return f +} + +// PrimaryIPFromSchema converts a schema.PrimaryIP to a PrimaryIP. +func PrimaryIPFromSchema(s schema.PrimaryIP) *PrimaryIP { + f := &PrimaryIP{ + ID: s.ID, + Type: PrimaryIPType(s.Type), + AutoDelete: s.AutoDelete, + + Created: s.Created, + Blocked: s.Blocked, + Protection: PrimaryIPProtection{ + Delete: s.Protection.Delete, + }, + Name: s.Name, + AssigneeType: s.AssigneeType, + AssigneeID: s.AssigneeID, + Datacenter: DatacenterFromSchema(s.Datacenter), + } + + if f.Type == PrimaryIPTypeIPv4 { f.IP = net.ParseIP(s.IP) } else { f.IP, f.Network, _ = net.ParseCIDR(s.IP) @@ -201,6 +235,7 @@ // a ServerPublicNetIPv4. func ServerPublicNetIPv4FromSchema(s schema.ServerPublicNetIPv4) ServerPublicNetIPv4 { return ServerPublicNetIPv4{ + ID: s.ID, IP: net.ParseIP(s.IP), Blocked: s.Blocked, DNSPtr: s.DNSPtr, @@ -211,6 +246,7 @@ // a ServerPublicNetIPv6. func ServerPublicNetIPv6FromSchema(s schema.ServerPublicNetIPv6) ServerPublicNetIPv6 { ipv6 := ServerPublicNetIPv6{ + ID: s.ID, Blocked: s.Blocked, DNSPtr: map[string]string{}, } @@ -680,6 +716,24 @@ pricings = append(pricings, p) } p.FloatingIPs = append(p.FloatingIPs, FloatingIPTypePricing{Type: FloatingIPType(floatingIPType.Type), Pricings: pricings}) + } + for _, primaryIPType := range s.PrimaryIPs { + var pricings []PrimaryIPTypePricing + for _, price := range primaryIPType.Prices { + p := PrimaryIPTypePricing{ + Datacenter: price.Datacenter, + Monthly: PrimaryIPPrice{ + Net: price.PriceMonthly.Net, + Gross: price.PriceMonthly.Gross, + }, + Hourly: PrimaryIPPrice{ + Net: price.PriceHourly.Net, + Gross: price.PriceHourly.Gross, + }, + } + pricings = append(pricings, p) + } + p.PrimaryIPs = append(p.PrimaryIPs, PrimaryIPPricing{Type: primaryIPType.Type, Pricings: pricings}) } for _, serverType := range s.ServerTypes { var pricings []ServerTypeLocationPricing diff --git a/hcloud/schema_test.go b/hcloud/schema_test.go index 4f2fb3b..9301d78 100644 --- a/hcloud/schema_test.go +++ b/hcloud/schema_test.go @@ -225,6 +225,203 @@ }) } +func TestPrimaryIPFromSchema(t *testing.T) { + t.Run("IPv6", func(t *testing.T) { + data := []byte(`{ + "assignee_id": 17, + "assignee_type": "server", + "auto_delete": true, + "blocked": true, + "created": "2017-08-16T17:29:14+00:00", + "datacenter": { + "description": "Falkenstein DC Park 8", + "id": 42, + "location": { + "city": "Falkenstein", + "country": "DE", + "description": "Falkenstein DC Park 1", + "id": 1, + "latitude": 50.47612, + "longitude": 12.370071, + "name": "fsn1", + "network_zone": "eu-central" + }, + "name": "fsn1-dc8", + "server_types": { + "available": [], + "available_for_migration": [], + "supported": [] + } + }, + "dns_ptr": [ + { + "dns_ptr": "server.example.com", + "ip": "fe80::" + } + ], + "id": 4711, + "ip": "fe80::/64", + "labels": { + "key": "value", + "key2": "value2" + }, + "name": "Web Frontend", + "protection": { + "delete": true + }, + "type": "ipv6" + }`) + + var s schema.PrimaryIP + if err := json.Unmarshal(data, &s); err != nil { + t.Fatal(err) + } + primaryIP := PrimaryIPFromSchema(s) + + if primaryIP.ID != 4711 { + t.Errorf("unexpected ID: %v", primaryIP.ID) + } + if !primaryIP.Blocked { + t.Errorf("unexpected value for Blocked: %v", primaryIP.Blocked) + } + if !primaryIP.AutoDelete { + t.Errorf("unexpected value for AutoDelete: %v", primaryIP.AutoDelete) + } + if primaryIP.Name != "Web Frontend" { + t.Errorf("unexpected name: %v", primaryIP.Name) + } + + if primaryIP.IP.String() != "fe80::" { + t.Errorf("unexpected IP: %v", primaryIP.IP) + } + if primaryIP.Type != PrimaryIPTypeIPv6 { + t.Errorf("unexpected Type: %v", primaryIP.Type) + } + if primaryIP.AssigneeType != "server" { + t.Errorf("unexpected AssigneeType: %v", primaryIP.AssigneeType) + } + if primaryIP.AssigneeID != 17 { + t.Errorf("unexpected AssigneeID: %v", primaryIP.AssigneeID) + } + dnsPTR, err := primaryIP.GetDNSPtrForIP(primaryIP.IP) + if err != nil { + t.Fatal(err) + } + if primaryIP.DNSPtr == nil || dnsPTR == "" { + t.Errorf("unexpected DNS ptr: %v", primaryIP.DNSPtr) + } + if primaryIP.Datacenter.Name != "fsn1-dc8" { + t.Errorf("unexpected datacenter: %v", primaryIP.Datacenter) + } + if !primaryIP.Protection.Delete { + t.Errorf("unexpected Protection.Delete: %v", primaryIP.Protection.Delete) + } + if primaryIP.Labels["key"] != "value" || primaryIP.Labels["key2"] != "value2" { + t.Errorf("unexpected Labels: %v", primaryIP.Labels) + } + if !primaryIP.Created.Equal(time.Date(2017, 8, 16, 17, 29, 14, 0, time.UTC)) { + t.Errorf("unexpected created date: %v", primaryIP.Created) + } + }) + t.Run("IPv4", func(t *testing.T) { + data := []byte(`{ + "assignee_id": 17, + "assignee_type": "server", + "auto_delete": true, + "blocked": true, + "created": "2017-08-16T17:29:14+00:00", + "datacenter": { + "description": "Falkenstein DC Park 8", + "id": 42, + "location": { + "city": "Falkenstein", + "country": "DE", + "description": "Falkenstein DC Park 1", + "id": 1, + "latitude": 50.47612, + "longitude": 12.370071, + "name": "fsn1", + "network_zone": "eu-central" + }, + "name": "fsn1-dc8", + "server_types": { + "available": [], + "available_for_migration": [], + "supported": [] + } + }, + "dns_ptr": [ + { + "dns_ptr": "server.example.com", + "ip": "127.0.0.1" + } + ], + "id": 4711, + "ip": "127.0.0.1", + "labels": { + "key": "value", + "key2": "value2" + }, + "name": "Web Frontend", + "protection": { + "delete": true + }, + "type": "ipv4" + }`) + + var s schema.PrimaryIP + if err := json.Unmarshal(data, &s); err != nil { + t.Fatal(err) + } + primaryIP := PrimaryIPFromSchema(s) + + if primaryIP.ID != 4711 { + t.Errorf("unexpected ID: %v", primaryIP.ID) + } + if !primaryIP.Blocked { + t.Errorf("unexpected value for Blocked: %v", primaryIP.Blocked) + } + if !primaryIP.AutoDelete { + t.Errorf("unexpected value for AutoDelete: %v", primaryIP.AutoDelete) + } + if primaryIP.Name != "Web Frontend" { + t.Errorf("unexpected name: %v", primaryIP.Name) + } + + if primaryIP.IP.String() != "127.0.0.1" { + t.Errorf("unexpected IP: %v", primaryIP.IP) + } + if primaryIP.Type != PrimaryIPTypeIPv4 { + t.Errorf("unexpected Type: %v", primaryIP.Type) + } + if primaryIP.AssigneeType != "server" { + t.Errorf("unexpected AssigneeType: %v", primaryIP.AssigneeType) + } + if primaryIP.AssigneeID != 17 { + t.Errorf("unexpected AssigneeID: %v", primaryIP.AssigneeID) + } + dnsPTR, err := primaryIP.GetDNSPtrForIP(primaryIP.IP) + if err != nil { + t.Fatal(err) + } + if primaryIP.DNSPtr == nil || dnsPTR == "" { + t.Errorf("unexpected DNS ptr: %v", primaryIP.DNSPtr) + } + if primaryIP.Datacenter.Name != "fsn1-dc8" { + t.Errorf("unexpected datacenter: %v", primaryIP.Datacenter) + } + if !primaryIP.Protection.Delete { + t.Errorf("unexpected Protection.Delete: %v", primaryIP.Protection.Delete) + } + if primaryIP.Labels["key"] != "value" || primaryIP.Labels["key2"] != "value2" { + t.Errorf("unexpected Labels: %v", primaryIP.Labels) + } + if !primaryIP.Created.Equal(time.Date(2017, 8, 16, 17, 29, 14, 0, time.UTC)) { + t.Errorf("unexpected created date: %v", primaryIP.Created) + } + }) +} + func TestISOFromSchema(t *testing.T) { data := []byte(`{ "id": 4711, @@ -359,11 +556,7 @@ "status": "running", "created": "2017-08-16T17:29:14+00:00", "public_net": { - "ipv4": { - "ip": "1.2.3.4", - "blocked": false, - "dns_ptr": "server01.example.com" - }, + "ipv4": null, "ipv6": { "ip": "2a01:4f8:1c11:3400::/64", "blocked": false, @@ -475,8 +668,11 @@ if !server.Created.Equal(time.Date(2017, 8, 16, 17, 29, 14, 0, time.UTC)) { t.Errorf("unexpected created date: %v", server.Created) } - if server.PublicNet.IPv4.IP.String() != "1.2.3.4" { - t.Errorf("unexpected public net IPv4 IP: %v", server.PublicNet.IPv4.IP) + if !server.PublicNet.IPv4.IsUnspecified() { + t.Errorf("unexpected public net IPv4: %v", server.PublicNet.IPv4) + } + if server.PublicNet.IPv6.IP.String() != "2a01:4f8:1c11:3400::" { + t.Errorf("unexpected public net IPv6 IP: %v", server.PublicNet.IPv6.IP) } if server.ServerType.ID != 2 { t.Errorf("unexpected server type ID: %v", server.ServerType.ID) @@ -577,11 +773,13 @@ func TestServerPublicNetFromSchema(t *testing.T) { data := []byte(`{ "ipv4": { + "id": 1, "ip": "1.2.3.4", "blocked": false, "dns_ptr": "server.example.com" }, "ipv6": { + "id": 2, "ip": "2a01:4f8:1c19:1403::/64", "blocked": false, "dns_ptr": [] @@ -600,9 +798,14 @@ t.Fatal(err) } publicNet := ServerPublicNetFromSchema(s) - + if publicNet.IPv4.ID != 1 { + t.Errorf("unexpected IPv4 ID: %v", publicNet.IPv4.ID) + } if publicNet.IPv4.IP.String() != "1.2.3.4" { t.Errorf("unexpected IPv4 IP: %v", publicNet.IPv4.IP) + } + if publicNet.IPv6.ID != 2 { + t.Errorf("unexpected IPv6 ID: %v", publicNet.IPv6.ID) } if publicNet.IPv6.Network.String() != "2a01:4f8:1c19:1403::/64" { t.Errorf("unexpected IPv6 IP: %v", publicNet.IPv6.IP) @@ -1837,6 +2040,24 @@ "type": "ipv4" } ], + "primary_ips": [ + { + "prices": [ + { + "datacenter": "fsn1-dc8", + "price_hourly": { + "gross": "1.1900000000000000", + "net": "1.0000000000" + }, + "price_monthly": { + "gross": "1.1900000000000000", + "net": "1.0000000000" + } + } + ], + "type": "ipv4" + } + ], "traffic": { "price_per_tb": { "net": "1", @@ -1949,6 +2170,35 @@ } if p.Pricings[0].Monthly.Gross != "1.19" { t.Errorf("unexpected Monthly.Gross: %v", p.Pricings[0].Monthly.Gross) + } + } + } + + if len(pricing.PrimaryIPs) != 1 { + t.Errorf("unexpected number of Primary IPs: %d", len(pricing.PrimaryIPs)) + } else { + ip := pricing.PrimaryIPs[0] + + if ip.Type != "ipv4" { + t.Errorf("unexpected .Type: %s", ip.Type) + } + if len(ip.Pricings) != 1 { + t.Errorf("unexpected number of prices: %d", len(ip.Pricings)) + } else { + if ip.Pricings[0].Datacenter != "fsn1-dc8" { + t.Errorf("unexpected Datacenter: %v", ip.Pricings[0].Datacenter) + } + if ip.Pricings[0].Monthly.Net != "1.0000000000" { + t.Errorf("unexpected Monthly.Net: %v", ip.Pricings[0].Monthly.Net) + } + if ip.Pricings[0].Monthly.Gross != "1.1900000000000000" { + t.Errorf("unexpected Monthly.Gross: %v", ip.Pricings[0].Monthly.Gross) + } + if ip.Pricings[0].Hourly.Net != "1.0000000000" { + t.Errorf("unexpected Hourly.Net: %v", ip.Pricings[0].Hourly.Net) + } + if ip.Pricings[0].Hourly.Gross != "1.1900000000000000" { + t.Errorf("unexpected Hourly.Gross: %v", ip.Pricings[0].Hourly.Gross) } } } diff --git a/hcloud/server.go b/hcloud/server.go index b5254e1..142e842 100644 --- a/hcloud/server.go +++ b/hcloud/server.go @@ -98,17 +98,27 @@ // ServerPublicNetIPv4 represents a server's public IPv4 address. type ServerPublicNetIPv4 struct { + ID int IP net.IP Blocked bool DNSPtr string } +func (n *ServerPublicNetIPv4) IsUnspecified() bool { + return n.IP == nil || n.IP.Equal(net.IPv4zero) +} + // ServerPublicNetIPv6 represents a Server's public IPv6 network and address. type ServerPublicNetIPv6 struct { + ID int IP net.IP Network *net.IPNet Blocked bool DNSPtr map[string]string +} + +func (n *ServerPublicNetIPv6) IsUnspecified() bool { + return n.IP == nil || n.IP.Equal(net.IPv6unspecified) } // ServerPrivateNet defines the schema of a Server's private network information. @@ -120,8 +130,8 @@ } // DNSPtrForIP returns the reverse dns pointer of the ip address. -func (s *ServerPublicNetIPv6) DNSPtrForIP(ip net.IP) string { - return s.DNSPtr[ip.String()] +func (n *ServerPublicNetIPv6) DNSPtrForIP(ip net.IP) string { + return n.DNSPtr[ip.String()] } // ServerFirewallStatus represents a Firewall and its status on a Server's @@ -308,6 +318,14 @@ Networks []*Network Firewalls []*ServerCreateFirewall PlacementGroup *PlacementGroup + PublicNet *ServerCreatePublicNet +} + +type ServerCreatePublicNet struct { + EnableIPv4 bool + EnableIPv6 bool + IPv4 *PrimaryIP + IPv6 *PrimaryIP } // ServerCreateFirewall defines which Firewalls to apply when creating a Server. @@ -328,6 +346,11 @@ } if o.Location != nil && o.Datacenter != nil { return errors.New("location and datacenter are mutually exclusive") + } + if o.PublicNet != nil { + if !o.PublicNet.EnableIPv4 && !o.PublicNet.EnableIPv6 && len(o.Networks) == 0 { + return errors.New("missing networks when EnableIPv4 and EnableIPv6 is false") + } } return nil } @@ -377,6 +400,19 @@ reqBody.Firewalls = append(reqBody.Firewalls, schema.ServerCreateFirewalls{ Firewall: firewall.Firewall.ID, }) + } + + if opts.PublicNet != nil { + reqBody.PublicNet = &schema.ServerCreatePublicNet{ + EnableIPv4: opts.PublicNet.EnableIPv4, + EnableIPv6: opts.PublicNet.EnableIPv6, + } + if opts.PublicNet.IPv4 != nil { + reqBody.PublicNet.IPv4ID = opts.PublicNet.IPv4.ID + } + if opts.PublicNet.IPv6 != nil { + reqBody.PublicNet.IPv6ID = opts.PublicNet.IPv6.ID + } } if opts.Location != nil { if opts.Location.ID != 0 { diff --git a/hcloud/server_test.go b/hcloud/server_test.go index a6463ff..4add328 100644 --- a/hcloud/server_test.go +++ b/hcloud/server_test.go @@ -446,6 +446,156 @@ {ID: 1}, {ID: 2}, }, + }) + if err != nil { + t.Fatal(err) + } + if result.Server == nil { + t.Fatal("no server") + } + if result.Server.ID != 1 { + t.Errorf("unexpected server ID: %v", result.Server.ID) + } + if len(result.NextActions) != 1 || result.NextActions[0].ID != 2 { + t.Errorf("unexpected next actions: %v", result.NextActions) + } +} + +func TestServersCreateWithPrivateNetworkOnly(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 len(reqBody.Networks) != 2 || reqBody.Networks[0] != 1 || reqBody.Networks[1] != 2 { + t.Errorf("unexpected Networks: %v", reqBody.Networks) + } + if reqBody.PublicNet.EnableIPv4 != false { + t.Errorf("unexpected PublicNet.EnableIPv4: %v", reqBody.PublicNet.EnableIPv4) + } + if reqBody.PublicNet.EnableIPv6 != false { + t.Errorf("unexpected PublicNet.EnableIPv6: %v", reqBody.PublicNet.EnableIPv6) + } + if reqBody.PublicNet.IPv4ID != 0 { + t.Errorf("unexpected PublicNet.IPv4: %v", reqBody.PublicNet.IPv4ID) + } + if reqBody.PublicNet.IPv6ID != 0 { + t.Errorf("unexpected PublicNet.IPv6: %v", reqBody.PublicNet.IPv6ID) + } + json.NewEncoder(w).Encode(schema.ServerCreateResponse{ + Server: schema.Server{ + ID: 1, + }, + 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}, + Networks: []*Network{ + {ID: 1}, + {ID: 2}, + }, + PublicNet: &ServerCreatePublicNet{ + EnableIPv4: false, + EnableIPv6: false, + }, + }) + if err != nil { + t.Fatal(err) + } + if result.Server == nil { + t.Fatal("no server") + } + if result.Server.ID != 1 { + t.Errorf("unexpected server ID: %v", result.Server.ID) + } + if len(result.NextActions) != 1 || result.NextActions[0].ID != 2 { + t.Errorf("unexpected next actions: %v", result.NextActions) + } +} + +func TestServersCreateWithIPv6Only(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.PublicNet.EnableIPv4 != false { + t.Errorf("unexpected PublicNet.EnableIPv4: %v", reqBody.PublicNet.EnableIPv4) + } + if reqBody.PublicNet.EnableIPv6 != true { + t.Errorf("unexpected PublicNet.EnableIPv6: %v", reqBody.PublicNet.EnableIPv6) + } + json.NewEncoder(w).Encode(schema.ServerCreateResponse{ + Server: schema.Server{ + ID: 1, + }, + 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}, + PublicNet: &ServerCreatePublicNet{EnableIPv4: false, EnableIPv6: true}, + }) + if err != nil { + t.Fatal(err) + } + if result.Server == nil { + t.Fatal("no server") + } + if result.Server.ID != 1 { + t.Errorf("unexpected server ID: %v", result.Server.ID) + } + if len(result.NextActions) != 1 || result.NextActions[0].ID != 2 { + t.Errorf("unexpected next actions: %v", result.NextActions) + } +} + +func TestServersCreateWithDefaultPublicNet(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.PublicNet != nil { + t.Errorf("unexpected PublicNet: %v", reqBody.PublicNet) + } + json.NewEncoder(w).Encode(schema.ServerCreateResponse{ + Server: schema.Server{ + ID: 1, + }, + 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}, }) if err != nil { t.Fatal(err)