package providers import ( "context" "os" "strconv" "strings" "github.com/wangjia/pangolin/server/internal/provision" ) // hetznerAdapter implements provision.CloudAdapter against the Hetzner Cloud // API v1. Premium pool: stable vendor, good native IPs, floating-IP capable. // // Credentials: PROVISION_HETZNER_API_TOKEN (env, independent secret). // Default image: PROVISION_HETZNER_IMAGE (defaults to "debian-12"). type hetznerAdapter struct { c *httpClient image string } func newHetznerFromEnv() (provision.CloudAdapter, error) { token, err := secret("PROVISION_HETZNER_API_TOKEN") if err != nil { return nil, err } image := os.Getenv("PROVISION_HETZNER_IMAGE") if image == "" { image = "debian-12" } return &hetznerAdapter{ c: newHTTPClient("https://api.hetzner.cloud/v1", token), image: image, }, nil } func (a *hetznerAdapter) Kind() string { return "hetzner" } func (a *hetznerAdapter) SupportsElasticIP() bool { return true } // floating IPs func (a *hetznerAdapter) CreateInstance(ctx context.Context, in provision.CreateInput) (*provision.Instance, error) { body := map[string]any{ "name": sanitizeName(in.Label), "server_type": in.Plan, "image": a.image, "location": in.Region, "user_data": in.UserData, // Hetzner accepts raw cloud-init "start_after_create": true, "labels": labelsFromTags(in.Tags), } if len(in.SSHKeyIDs) > 0 { body["ssh_keys"] = in.SSHKeyIDs } var resp struct { Server struct { ID int64 `json:"id"` PublicNet struct { IPv4 struct { IP string `json:"ip"` } `json:"ipv4"` } `json:"public_net"` Datacenter struct { Location struct { Name string `json:"name"` } `json:"location"` } `json:"datacenter"` } `json:"server"` } if err := a.c.do(ctx, "POST", "/servers", body, &resp); err != nil { return nil, err } return &provision.Instance{ ID: strconv.FormatInt(resp.Server.ID, 10), IP: resp.Server.PublicNet.IPv4.IP, Region: resp.Server.Datacenter.Location.Name, }, nil } func (a *hetznerAdapter) DestroyInstance(ctx context.Context, instanceID string) error { err := a.c.do(ctx, "DELETE", "/servers/"+instanceID, nil, nil) if err != nil && isNotFound(err) { return nil // idempotent } return err } func (a *hetznerAdapter) AttachIP(ctx context.Context, instanceID string) (string, error) { id, err := strconv.ParseInt(instanceID, 10, 64) if err != nil { return "", err } var resp struct { FloatingIP struct { ID int64 `json:"id"` IP string `json:"ip"` } `json:"floating_ip"` } if err := a.c.do(ctx, "POST", "/floating_ips", map[string]any{ "type": "ipv4", "server": id, "description": "rotate-" + instanceID, }, &resp); err != nil { return "", err } return resp.FloatingIP.IP, nil } func (a *hetznerAdapter) ListRegions(ctx context.Context) ([]provision.Region, error) { var resp struct { Locations []struct { Name string `json:"name"` Country string `json:"country"` City string `json:"city"` } `json:"locations"` } if err := a.c.do(ctx, "GET", "/locations", nil, &resp); err != nil { return nil, err } out := make([]provision.Region, 0, len(resp.Locations)) for _, l := range resp.Locations { out = append(out, provision.Region{ID: l.Name, Country: l.Country, City: l.City}) } return out, nil } // --- helpers shared with vultr --- func atoiDefault(s string, def int) int { if s == "" { return def } n, err := strconv.Atoi(s) if err != nil { return def } return n } func isNotFound(err error) bool { return err != nil && (strings.Contains(err.Error(), "status 404") || strings.Contains(err.Error(), "not_found")) } // sanitizeName makes a UUID acceptable as a Hetzner server name (RFC1123-ish). func sanitizeName(s string) string { s = strings.ToLower(s) return "node-" + s } func labelsFromTags(tags []string) map[string]string { m := map[string]string{} for i, t := range tags { m["tag"+strconv.Itoa(i)] = t } return m }