Skip to content

Commit 07ef529

Browse files
committed
feat(go): add DigitalOcean, Linode, Vultr HTTP clients
Complete Go Provider implementations for all 4 providers. Each uses shared specs for base URL, status mapping, pricing. Provider-specific handling: - DigitalOcean: networks.v4 public IP extraction - Linode: base64 user_data in metadata wrapper, random root_pass, IPv6 CIDR stripping, region status filtering - Vultr: numeric os_id, compound active/power_status mapping, placeholder IP filtering (0.0.0.0/::) 22 new tests (7 DO + 7 Linode + 8 Vultr) with httptest mock servers. Total: 314 TS + 42 Go = 356 tests.
1 parent 55c0080 commit 07ef529

6 files changed

Lines changed: 1528 additions & 0 deletions

File tree

digitalocean.go

Lines changed: 241 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,241 @@
1+
package capstan
2+
3+
import (
4+
"bytes"
5+
"context"
6+
"encoding/json"
7+
"fmt"
8+
"io"
9+
"net/http"
10+
)
11+
12+
type DigitalOceanProvider struct {
13+
token string
14+
spec *ProviderSpec
15+
client *http.Client
16+
}
17+
18+
func NewDigitalOcean(token string) *DigitalOceanProvider {
19+
return &DigitalOceanProvider{
20+
token: token,
21+
spec: Spec(DigitalOcean),
22+
client: &http.Client{},
23+
}
24+
}
25+
26+
func (d *DigitalOceanProvider) Name() ProviderName { return DigitalOcean }
27+
28+
func (d *DigitalOceanProvider) Regions(ctx context.Context) ([]Region, error) {
29+
var resp struct {
30+
Regions []struct {
31+
Slug string `json:"slug"`
32+
Name string `json:"name"`
33+
Available bool `json:"available"`
34+
} `json:"regions"`
35+
}
36+
if err := d.get(ctx, "/regions?per_page=50", &resp); err != nil {
37+
return nil, err
38+
}
39+
var regions []Region
40+
for _, r := range resp.Regions {
41+
if !r.Available {
42+
continue
43+
}
44+
regions = append(regions, Region{ID: r.Slug, Name: r.Name})
45+
}
46+
return regions, nil
47+
}
48+
49+
func (d *DigitalOceanProvider) Plans(ctx context.Context, region string) ([]Plan, error) {
50+
var resp struct {
51+
Sizes []struct {
52+
Slug string `json:"slug"`
53+
Available bool `json:"available"`
54+
VCPUs int `json:"vcpus"`
55+
Memory int `json:"memory"`
56+
Disk int `json:"disk"`
57+
PriceMonthly float64 `json:"price_monthly"`
58+
Regions []string `json:"regions"`
59+
} `json:"sizes"`
60+
}
61+
if err := d.get(ctx, "/sizes?per_page=200", &resp); err != nil {
62+
return nil, err
63+
}
64+
var plans []Plan
65+
for _, s := range resp.Sizes {
66+
if !s.Available {
67+
continue
68+
}
69+
if region != "" {
70+
found := false
71+
for _, r := range s.Regions {
72+
if r == region {
73+
found = true
74+
break
75+
}
76+
}
77+
if !found {
78+
continue
79+
}
80+
}
81+
plans = append(plans, Plan{
82+
ID: s.Slug,
83+
Name: s.Slug,
84+
CPUs: s.VCPUs,
85+
MemoryMB: s.Memory,
86+
DiskGB: s.Disk,
87+
MonthlyCents: d.spec.EstimateMonthlyCost(s.Slug),
88+
PriceCurrency: d.spec.PriceCurrency,
89+
})
90+
}
91+
return plans, nil
92+
}
93+
94+
func (d *DigitalOceanProvider) Create(ctx context.Context, opts CreateOpts) (*Server, error) {
95+
body := map[string]any{
96+
"name": opts.Name,
97+
"size": opts.Plan,
98+
"region": opts.Region,
99+
"image": d.spec.ResolveImage(opts.Image),
100+
"monitoring": true,
101+
}
102+
if opts.UserData != "" {
103+
body["user_data"] = opts.UserData
104+
}
105+
106+
var resp struct {
107+
Droplet doDroplet `json:"droplet"`
108+
}
109+
if err := d.post(ctx, "/droplets", body, &resp); err != nil {
110+
return nil, err
111+
}
112+
return d.toServer(resp.Droplet), nil
113+
}
114+
115+
func (d *DigitalOceanProvider) Get(ctx context.Context, id string) (*Server, error) {
116+
var resp struct {
117+
Droplet doDroplet `json:"droplet"`
118+
}
119+
if err := d.get(ctx, "/droplets/"+id, &resp); err != nil {
120+
return nil, err
121+
}
122+
return d.toServer(resp.Droplet), nil
123+
}
124+
125+
func (d *DigitalOceanProvider) Destroy(ctx context.Context, id string) error {
126+
return d.del(ctx, "/droplets/"+id)
127+
}
128+
129+
func (d *DigitalOceanProvider) EstimateMonthlyCost(plan string) int {
130+
return d.spec.EstimateMonthlyCost(plan)
131+
}
132+
133+
type doDroplet struct {
134+
ID int `json:"id"`
135+
Name string `json:"name"`
136+
Status string `json:"status"`
137+
Networks *struct {
138+
V4 []struct {
139+
IPAddress string `json:"ip_address"`
140+
Type string `json:"type"`
141+
} `json:"v4"`
142+
V6 []struct {
143+
IPAddress string `json:"ip_address"`
144+
Type string `json:"type"`
145+
} `json:"v6"`
146+
} `json:"networks"`
147+
Size *struct{ Slug string `json:"slug"` } `json:"size"`
148+
Region *struct{ Slug string `json:"slug"` } `json:"region"`
149+
Created string `json:"created_at"`
150+
}
151+
152+
func (d *DigitalOceanProvider) toServer(s doDroplet) *Server {
153+
srv := &Server{
154+
ID: fmt.Sprintf("%d", s.ID),
155+
Name: s.Name,
156+
Status: d.spec.MapStatus(s.Status),
157+
CreatedAt: s.Created,
158+
}
159+
if s.Networks != nil {
160+
for _, n := range s.Networks.V4 {
161+
if n.Type == "public" {
162+
srv.PublicIPv4 = n.IPAddress
163+
break
164+
}
165+
}
166+
for _, n := range s.Networks.V6 {
167+
if n.Type == "public" {
168+
srv.PublicIPv6 = n.IPAddress
169+
break
170+
}
171+
}
172+
}
173+
if s.Size != nil {
174+
srv.Plan = s.Size.Slug
175+
}
176+
if s.Region != nil {
177+
srv.Region = s.Region.Slug
178+
}
179+
return srv
180+
}
181+
182+
func (d *DigitalOceanProvider) get(ctx context.Context, path string, out any) error {
183+
req, err := http.NewRequestWithContext(ctx, "GET", d.spec.BaseURL+path, nil)
184+
if err != nil {
185+
return err
186+
}
187+
req.Header.Set("Authorization", "Bearer "+d.token)
188+
req.Header.Set("Accept", "application/json")
189+
resp, err := d.client.Do(req)
190+
if err != nil {
191+
return fmt.Errorf("capstan: digitalocean GET %s: %w", path, err)
192+
}
193+
defer resp.Body.Close()
194+
body, _ := io.ReadAll(resp.Body)
195+
if resp.StatusCode >= 400 {
196+
return fmt.Errorf("capstan: digitalocean GET %s: %d %s", path, resp.StatusCode, body)
197+
}
198+
return json.Unmarshal(body, out)
199+
}
200+
201+
func (d *DigitalOceanProvider) post(ctx context.Context, path string, payload any, out any) error {
202+
data, err := json.Marshal(payload)
203+
if err != nil {
204+
return err
205+
}
206+
req, err := http.NewRequestWithContext(ctx, "POST", d.spec.BaseURL+path, bytes.NewReader(data))
207+
if err != nil {
208+
return err
209+
}
210+
req.Header.Set("Authorization", "Bearer "+d.token)
211+
req.Header.Set("Content-Type", "application/json")
212+
req.Header.Set("Accept", "application/json")
213+
resp, err := d.client.Do(req)
214+
if err != nil {
215+
return fmt.Errorf("capstan: digitalocean POST %s: %w", path, err)
216+
}
217+
defer resp.Body.Close()
218+
body, _ := io.ReadAll(resp.Body)
219+
if resp.StatusCode >= 400 {
220+
return fmt.Errorf("capstan: digitalocean POST %s: %d %s", path, resp.StatusCode, body)
221+
}
222+
return json.Unmarshal(body, out)
223+
}
224+
225+
func (d *DigitalOceanProvider) del(ctx context.Context, path string) error {
226+
req, err := http.NewRequestWithContext(ctx, "DELETE", d.spec.BaseURL+path, nil)
227+
if err != nil {
228+
return err
229+
}
230+
req.Header.Set("Authorization", "Bearer "+d.token)
231+
resp, err := d.client.Do(req)
232+
if err != nil {
233+
return fmt.Errorf("capstan: digitalocean DELETE %s: %w", path, err)
234+
}
235+
defer resp.Body.Close()
236+
if resp.StatusCode >= 400 {
237+
body, _ := io.ReadAll(resp.Body)
238+
return fmt.Errorf("capstan: digitalocean DELETE %s: %d %s", path, resp.StatusCode, body)
239+
}
240+
return nil
241+
}

0 commit comments

Comments
 (0)