791 lines
30 KiB
Markdown
791 lines
30 KiB
Markdown
|
|
# Custom Hosting Provider Guide
|
||
|
|
|
||
|
|
This guide walks you through creating a new hosting provider integration for Setec Manager. By the end, you will have a provider package that auto-registers with the system and can be used through the same unified API as the built-in Hostinger provider.
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Prerequisites
|
||
|
|
|
||
|
|
- Go 1.25+ (matching the project's `go.mod`)
|
||
|
|
- Familiarity with the Go `interface` pattern and HTTP client programming
|
||
|
|
- An API key or credentials for the hosting provider you are integrating
|
||
|
|
- A checkout of the `setec-manager` repository
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Project Structure
|
||
|
|
|
||
|
|
Provider implementations live under `internal/hosting/<provider_name>/`. Each provider is its own Go package.
|
||
|
|
|
||
|
|
```
|
||
|
|
internal/hosting/
|
||
|
|
provider.go -- Provider interface + model types + registry
|
||
|
|
store.go -- ProviderConfig, ProviderConfigStore
|
||
|
|
config.go -- Legacy config store
|
||
|
|
hostinger/ -- Built-in Hostinger provider
|
||
|
|
client.go -- HTTP client, auth, retry logic
|
||
|
|
dns.go -- DNS record operations
|
||
|
|
myprovider/ -- Your new provider (create this)
|
||
|
|
provider.go -- init() registration + interface methods
|
||
|
|
client.go -- HTTP client for the provider's API
|
||
|
|
dns.go -- (optional) DNS-specific logic
|
||
|
|
domains.go -- (optional) Domain-specific logic
|
||
|
|
vms.go -- (optional) VPS-specific logic
|
||
|
|
```
|
||
|
|
|
||
|
|
You can organize files however you like within the package; the only requirement is that the package calls `hosting.Register(...)` in an `init()` function.
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## The Provider Interface
|
||
|
|
|
||
|
|
The `Provider` interface is defined in `internal/hosting/provider.go`. Every provider must implement all methods. Methods that your provider does not support should return `ErrNotSupported`.
|
||
|
|
|
||
|
|
```go
|
||
|
|
type Provider interface {
|
||
|
|
// Identity
|
||
|
|
Name() string
|
||
|
|
DisplayName() string
|
||
|
|
|
||
|
|
// Configuration
|
||
|
|
Configure(config map[string]string) error
|
||
|
|
TestConnection() error
|
||
|
|
|
||
|
|
// DNS
|
||
|
|
ListDNSRecords(domain string) ([]DNSRecord, error)
|
||
|
|
CreateDNSRecord(domain string, record DNSRecord) error
|
||
|
|
UpdateDNSRecords(domain string, records []DNSRecord, overwrite bool) error
|
||
|
|
DeleteDNSRecord(domain string, recordName, recordType string) error
|
||
|
|
ResetDNSRecords(domain string) error
|
||
|
|
|
||
|
|
// Domains
|
||
|
|
ListDomains() ([]Domain, error)
|
||
|
|
GetDomain(domain string) (*Domain, error)
|
||
|
|
CheckDomainAvailability(domains []string) ([]DomainAvailability, error)
|
||
|
|
PurchaseDomain(req DomainPurchaseRequest) (*Domain, error)
|
||
|
|
SetNameservers(domain string, nameservers []string) error
|
||
|
|
EnableDomainLock(domain string) error
|
||
|
|
DisableDomainLock(domain string) error
|
||
|
|
EnablePrivacyProtection(domain string) error
|
||
|
|
DisablePrivacyProtection(domain string) error
|
||
|
|
|
||
|
|
// VMs / VPS
|
||
|
|
ListVMs() ([]VM, error)
|
||
|
|
GetVM(id string) (*VM, error)
|
||
|
|
CreateVM(req VMCreateRequest) (*VM, error)
|
||
|
|
ListDataCenters() ([]DataCenter, error)
|
||
|
|
ListSSHKeys() ([]SSHKey, error)
|
||
|
|
AddSSHKey(name, publicKey string) (*SSHKey, error)
|
||
|
|
DeleteSSHKey(id string) error
|
||
|
|
|
||
|
|
// Billing
|
||
|
|
ListSubscriptions() ([]Subscription, error)
|
||
|
|
GetCatalog() ([]CatalogItem, error)
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
### Method Reference
|
||
|
|
|
||
|
|
#### Identity Methods
|
||
|
|
|
||
|
|
| Method | Parameters | Returns | Description |
|
||
|
|
|---|---|---|---|
|
||
|
|
| `Name()` | - | `string` | Short machine-readable name (lowercase, no spaces). Used as the registry key and in API URLs. Example: `"hostinger"`, `"cloudflare"`. |
|
||
|
|
| `DisplayName()` | - | `string` | Human-readable name shown in the UI. Example: `"Hostinger"`, `"Cloudflare"`. |
|
||
|
|
|
||
|
|
#### Configuration Methods
|
||
|
|
|
||
|
|
| Method | Parameters | Returns | Description |
|
||
|
|
|---|---|---|---|
|
||
|
|
| `Configure(config)` | `map[string]string` -- key-value config pairs. Common keys: `"api_key"`, `"api_secret"`, `"base_url"`. | `error` | Called when a user saves credentials. Store them in struct fields. Validate format but do not make API calls. |
|
||
|
|
| `TestConnection()` | - | `error` | Make a lightweight API call (e.g., list domains) to verify credentials are valid. Return `nil` on success. |
|
||
|
|
|
||
|
|
#### DNS Methods
|
||
|
|
|
||
|
|
| Method | Parameters | Returns | Description |
|
||
|
|
|---|---|---|---|
|
||
|
|
| `ListDNSRecords(domain)` | `domain string` -- the FQDN | `([]DNSRecord, error)` | Return all DNS records for the zone. |
|
||
|
|
| `CreateDNSRecord(domain, record)` | `domain string`, `record DNSRecord` | `error` | Add a single record without affecting existing records. |
|
||
|
|
| `UpdateDNSRecords(domain, records, overwrite)` | `domain string`, `records []DNSRecord`, `overwrite bool` | `error` | Batch update. If `overwrite` is true, replace all records; otherwise merge. |
|
||
|
|
| `DeleteDNSRecord(domain, recordName, recordType)` | `domain string`, `recordName string` (subdomain or `@`), `recordType string` (e.g. `"A"`) | `error` | Delete matching records. |
|
||
|
|
| `ResetDNSRecords(domain)` | `domain string` | `error` | Reset the zone to provider defaults. |
|
||
|
|
|
||
|
|
#### Domain Methods
|
||
|
|
|
||
|
|
| Method | Parameters | Returns | Description |
|
||
|
|
|---|---|---|---|
|
||
|
|
| `ListDomains()` | - | `([]Domain, error)` | Return all domains on the account. |
|
||
|
|
| `GetDomain(domain)` | `domain string` | `(*Domain, error)` | Return details for a single domain. |
|
||
|
|
| `CheckDomainAvailability(domains)` | `domains []string` | `([]DomainAvailability, error)` | Check if domains are available for registration and return pricing. |
|
||
|
|
| `PurchaseDomain(req)` | `req DomainPurchaseRequest` | `(*Domain, error)` | Register a new domain. |
|
||
|
|
| `SetNameservers(domain, nameservers)` | `domain string`, `nameservers []string` | `error` | Update the authoritative nameservers. |
|
||
|
|
| `EnableDomainLock(domain)` | `domain string` | `error` | Enable registrar lock (transfer protection). |
|
||
|
|
| `DisableDomainLock(domain)` | `domain string` | `error` | Disable registrar lock. |
|
||
|
|
| `EnablePrivacyProtection(domain)` | `domain string` | `error` | Enable WHOIS privacy. |
|
||
|
|
| `DisablePrivacyProtection(domain)` | `domain string` | `error` | Disable WHOIS privacy. |
|
||
|
|
|
||
|
|
#### VM / VPS Methods
|
||
|
|
|
||
|
|
| Method | Parameters | Returns | Description |
|
||
|
|
|---|---|---|---|
|
||
|
|
| `ListVMs()` | - | `([]VM, error)` | Return all VPS instances on the account. |
|
||
|
|
| `GetVM(id)` | `id string` | `(*VM, error)` | Return details for a single VM. |
|
||
|
|
| `CreateVM(req)` | `req VMCreateRequest` | `(*VM, error)` | Provision a new VPS instance. |
|
||
|
|
| `ListDataCenters()` | - | `([]DataCenter, error)` | Return available regions/data centers. |
|
||
|
|
| `ListSSHKeys()` | - | `([]SSHKey, error)` | Return all stored SSH public keys. |
|
||
|
|
| `AddSSHKey(name, publicKey)` | `name string`, `publicKey string` | `(*SSHKey, error)` | Upload a new SSH public key. |
|
||
|
|
| `DeleteSSHKey(id)` | `id string` | `error` | Remove an SSH key. |
|
||
|
|
|
||
|
|
#### Billing Methods
|
||
|
|
|
||
|
|
| Method | Parameters | Returns | Description |
|
||
|
|
|---|---|---|---|
|
||
|
|
| `ListSubscriptions()` | - | `([]Subscription, error)` | Return all active subscriptions. |
|
||
|
|
| `GetCatalog()` | - | `([]CatalogItem, error)` | Return purchasable products and plans. |
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Type Reference
|
||
|
|
|
||
|
|
All model types are defined in `internal/hosting/provider.go`.
|
||
|
|
|
||
|
|
### DNSRecord
|
||
|
|
|
||
|
|
| Field | Type | JSON | Description |
|
||
|
|
|---|---|---|---|
|
||
|
|
| `ID` | `string` | `id` | Provider-assigned identifier. May be synthesized (e.g., `name/type/priority`). Optional on create. |
|
||
|
|
| `Type` | `string` | `type` | Record type: `A`, `AAAA`, `CNAME`, `MX`, `TXT`, `NS`, `SRV`, `CAA`. |
|
||
|
|
| `Name` | `string` | `name` | Subdomain label or `@` for the zone apex. |
|
||
|
|
| `Content` | `string` | `content` | Record value (IP address, hostname, text, etc.). |
|
||
|
|
| `TTL` | `int` | `ttl` | Time-to-live in seconds. |
|
||
|
|
| `Priority` | `int` | `priority` | Priority value for MX and SRV records. Zero for other types. |
|
||
|
|
|
||
|
|
### Domain
|
||
|
|
|
||
|
|
| Field | Type | JSON | Description |
|
||
|
|
|---|---|---|---|
|
||
|
|
| `Name` | `string` | `name` | Fully qualified domain name. |
|
||
|
|
| `Registrar` | `string` | `registrar` | Registrar name (optional). |
|
||
|
|
| `Status` | `string` | `status` | Registration status (e.g., `"active"`, `"expired"`, `"pending"`). |
|
||
|
|
| `ExpiresAt` | `time.Time` | `expires_at` | Expiration date. |
|
||
|
|
| `AutoRenew` | `bool` | `auto_renew` | Whether automatic renewal is enabled. |
|
||
|
|
| `Locked` | `bool` | `locked` | Whether transfer lock is enabled. |
|
||
|
|
| `PrivacyProtection` | `bool` | `privacy_protection` | Whether WHOIS privacy is enabled. |
|
||
|
|
| `Nameservers` | `[]string` | `nameservers` | Current authoritative nameservers. |
|
||
|
|
|
||
|
|
### DomainAvailability
|
||
|
|
|
||
|
|
| Field | Type | JSON | Description |
|
||
|
|
|---|---|---|---|
|
||
|
|
| `Domain` | `string` | `domain` | The queried domain name. |
|
||
|
|
| `Available` | `bool` | `available` | Whether the domain is available for registration. |
|
||
|
|
| `Price` | `float64` | `price` | Purchase price (zero if unavailable). |
|
||
|
|
| `Currency` | `string` | `currency` | Currency code (e.g., `"USD"`). |
|
||
|
|
|
||
|
|
### DomainPurchaseRequest
|
||
|
|
|
||
|
|
| Field | Type | JSON | Description |
|
||
|
|
|---|---|---|---|
|
||
|
|
| `Domain` | `string` | `domain` | Domain to purchase. |
|
||
|
|
| `Period` | `int` | `period` | Registration period in years. |
|
||
|
|
| `AutoRenew` | `bool` | `auto_renew` | Enable auto-renewal. |
|
||
|
|
| `Privacy` | `bool` | `privacy_protection` | Enable WHOIS privacy. |
|
||
|
|
| `PaymentID` | `string` | `payment_method_id` | Payment method identifier (optional, provider-specific). |
|
||
|
|
|
||
|
|
### VM
|
||
|
|
|
||
|
|
| Field | Type | JSON | Description |
|
||
|
|
|---|---|---|---|
|
||
|
|
| `ID` | `string` | `id` | Provider-assigned VM identifier. |
|
||
|
|
| `Name` | `string` | `name` | Human-readable VM name / hostname. |
|
||
|
|
| `Status` | `string` | `status` | Current state: `"running"`, `"stopped"`, `"creating"`, `"error"`. |
|
||
|
|
| `Plan` | `string` | `plan` | Plan/tier identifier. |
|
||
|
|
| `Region` | `string` | `region` | Data center / region identifier. |
|
||
|
|
| `IPv4` | `string` | `ipv4` | Public IPv4 address (optional). |
|
||
|
|
| `IPv6` | `string` | `ipv6` | Public IPv6 address (optional). |
|
||
|
|
| `OS` | `string` | `os` | Operating system template name (optional). |
|
||
|
|
| `CPUs` | `int` | `cpus` | Number of virtual CPUs. |
|
||
|
|
| `MemoryMB` | `int` | `memory_mb` | RAM in megabytes. |
|
||
|
|
| `DiskGB` | `int` | `disk_gb` | Disk size in gigabytes. |
|
||
|
|
| `BandwidthGB` | `int` | `bandwidth_gb` | Monthly bandwidth allowance in gigabytes. |
|
||
|
|
| `CreatedAt` | `time.Time` | `created_at` | Creation timestamp. |
|
||
|
|
| `Labels` | `map[string]string` | `labels` | Arbitrary key-value labels (optional). |
|
||
|
|
|
||
|
|
### VMCreateRequest
|
||
|
|
|
||
|
|
| Field | Type | JSON | Description |
|
||
|
|
|---|---|---|---|
|
||
|
|
| `Plan` | `string` | `plan` | Plan/tier identifier from the catalog. |
|
||
|
|
| `DataCenterID` | `string` | `data_center_id` | Target data center from `ListDataCenters()`. |
|
||
|
|
| `Template` | `string` | `template` | OS template identifier. |
|
||
|
|
| `Password` | `string` | `password` | Root/admin password for the VM. |
|
||
|
|
| `Hostname` | `string` | `hostname` | Desired hostname. |
|
||
|
|
| `SSHKeyID` | `string` | `ssh_key_id` | SSH key to install (optional). |
|
||
|
|
| `PaymentID` | `string` | `payment_method_id` | Payment method identifier (optional). |
|
||
|
|
|
||
|
|
### DataCenter
|
||
|
|
|
||
|
|
| Field | Type | JSON | Description |
|
||
|
|
|---|---|---|---|
|
||
|
|
| `ID` | `string` | `id` | Unique identifier used in `VMCreateRequest`. |
|
||
|
|
| `Name` | `string` | `name` | Short name (e.g., `"US East"`). |
|
||
|
|
| `Location` | `string` | `location` | City or locality. |
|
||
|
|
| `Country` | `string` | `country` | ISO country code. |
|
||
|
|
|
||
|
|
### SSHKey
|
||
|
|
|
||
|
|
| Field | Type | JSON | Description |
|
||
|
|
|---|---|---|---|
|
||
|
|
| `ID` | `string` | `id` | Provider-assigned key identifier. |
|
||
|
|
| `Name` | `string` | `name` | User-assigned label. |
|
||
|
|
| `Fingerprint` | `string` | `fingerprint` | Key fingerprint (e.g., `"SHA256:..."`). |
|
||
|
|
| `PublicKey` | `string` | `public_key` | Full public key string. |
|
||
|
|
|
||
|
|
### Subscription
|
||
|
|
|
||
|
|
| Field | Type | JSON | Description |
|
||
|
|
|---|---|---|---|
|
||
|
|
| `ID` | `string` | `id` | Subscription identifier. |
|
||
|
|
| `Name` | `string` | `name` | Product name. |
|
||
|
|
| `Status` | `string` | `status` | Status: `"active"`, `"cancelled"`, `"expired"`. |
|
||
|
|
| `Plan` | `string` | `plan` | Plan identifier. |
|
||
|
|
| `Price` | `float64` | `price` | Recurring price. |
|
||
|
|
| `Currency` | `string` | `currency` | Currency code. |
|
||
|
|
| `RenewsAt` | `time.Time` | `renews_at` | Next renewal date. |
|
||
|
|
| `CreatedAt` | `time.Time` | `created_at` | Subscription start date. |
|
||
|
|
|
||
|
|
### CatalogItem
|
||
|
|
|
||
|
|
| Field | Type | JSON | Description |
|
||
|
|
|---|---|---|---|
|
||
|
|
| `ID` | `string` | `id` | Product/plan identifier. |
|
||
|
|
| `Name` | `string` | `name` | Product name. |
|
||
|
|
| `Category` | `string` | `category` | Category: `"vps"`, `"hosting"`, `"domain"`, etc. |
|
||
|
|
| `PriceCents` | `int` | `price_cents` | Price in cents (e.g., 1199 = $11.99). |
|
||
|
|
| `Currency` | `string` | `currency` | Currency code. |
|
||
|
|
| `Period` | `string` | `period` | Billing period: `"monthly"`, `"yearly"`. |
|
||
|
|
| `Description` | `string` | `description` | Human-readable description (optional). |
|
||
|
|
|
||
|
|
### ProviderConfig
|
||
|
|
|
||
|
|
Stored in `internal/hosting/store.go`. This is the credential record persisted to disk.
|
||
|
|
|
||
|
|
| Field | Type | JSON | Description |
|
||
|
|
|---|---|---|---|
|
||
|
|
| `Provider` | `string` | `provider` | Provider name (must match `Provider.Name()`). |
|
||
|
|
| `APIKey` | `string` | `api_key` | Primary API key or bearer token. |
|
||
|
|
| `APISecret` | `string` | `api_secret` | Secondary secret (optional, provider-specific). |
|
||
|
|
| `Extra` | `map[string]string` | `extra` | Additional provider-specific config values. |
|
||
|
|
| `Connected` | `bool` | `connected` | Whether the last `TestConnection()` succeeded. |
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Implementing the Interface
|
||
|
|
|
||
|
|
### Step 1: Create the Package
|
||
|
|
|
||
|
|
```bash
|
||
|
|
mkdir -p internal/hosting/myprovider
|
||
|
|
```
|
||
|
|
|
||
|
|
### Step 2: Implement the Provider
|
||
|
|
|
||
|
|
Create `internal/hosting/myprovider/provider.go`:
|
||
|
|
|
||
|
|
```go
|
||
|
|
package myprovider
|
||
|
|
|
||
|
|
import (
|
||
|
|
"errors"
|
||
|
|
"fmt"
|
||
|
|
"net/http"
|
||
|
|
"time"
|
||
|
|
|
||
|
|
"setec-manager/internal/hosting"
|
||
|
|
)
|
||
|
|
|
||
|
|
// ErrNotSupported is returned by methods this provider does not implement.
|
||
|
|
var ErrNotSupported = errors.New("myprovider: operation not supported")
|
||
|
|
|
||
|
|
// Provider implements hosting.Provider for the MyProvider service.
|
||
|
|
type Provider struct {
|
||
|
|
client *http.Client
|
||
|
|
apiKey string
|
||
|
|
baseURL string
|
||
|
|
}
|
||
|
|
|
||
|
|
// init registers this provider with the hosting registry.
|
||
|
|
// This runs automatically when the package is imported.
|
||
|
|
func init() {
|
||
|
|
hosting.Register(&Provider{
|
||
|
|
client: &http.Client{
|
||
|
|
Timeout: 30 * time.Second,
|
||
|
|
},
|
||
|
|
baseURL: "https://api.myprovider.com",
|
||
|
|
})
|
||
|
|
}
|
||
|
|
|
||
|
|
// ── Identity ────────────────────────────────────────────────────────
|
||
|
|
|
||
|
|
func (p *Provider) Name() string { return "myprovider" }
|
||
|
|
func (p *Provider) DisplayName() string { return "My Provider" }
|
||
|
|
|
||
|
|
// ── Configuration ───────────────────────────────────────────────────
|
||
|
|
|
||
|
|
func (p *Provider) Configure(config map[string]string) error {
|
||
|
|
key, ok := config["api_key"]
|
||
|
|
if !ok || key == "" {
|
||
|
|
return fmt.Errorf("myprovider: api_key is required")
|
||
|
|
}
|
||
|
|
p.apiKey = key
|
||
|
|
|
||
|
|
if baseURL, ok := config["base_url"]; ok && baseURL != "" {
|
||
|
|
p.baseURL = baseURL
|
||
|
|
}
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func (p *Provider) TestConnection() error {
|
||
|
|
// Make a lightweight API call to verify credentials.
|
||
|
|
// For example, list domains or get account info.
|
||
|
|
_, err := p.ListDomains()
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
|
||
|
|
// ── DNS ─────────────────────────────────────────────────────────────
|
||
|
|
|
||
|
|
func (p *Provider) ListDNSRecords(domain string) ([]hosting.DNSRecord, error) {
|
||
|
|
// TODO: Implement API call to list DNS records
|
||
|
|
return nil, ErrNotSupported
|
||
|
|
}
|
||
|
|
|
||
|
|
func (p *Provider) CreateDNSRecord(domain string, record hosting.DNSRecord) error {
|
||
|
|
return ErrNotSupported
|
||
|
|
}
|
||
|
|
|
||
|
|
func (p *Provider) UpdateDNSRecords(domain string, records []hosting.DNSRecord, overwrite bool) error {
|
||
|
|
return ErrNotSupported
|
||
|
|
}
|
||
|
|
|
||
|
|
func (p *Provider) DeleteDNSRecord(domain string, recordName, recordType string) error {
|
||
|
|
return ErrNotSupported
|
||
|
|
}
|
||
|
|
|
||
|
|
func (p *Provider) ResetDNSRecords(domain string) error {
|
||
|
|
return ErrNotSupported
|
||
|
|
}
|
||
|
|
|
||
|
|
// ── Domains ─────────────────────────────────────────────────────────
|
||
|
|
|
||
|
|
func (p *Provider) ListDomains() ([]hosting.Domain, error) {
|
||
|
|
return nil, ErrNotSupported
|
||
|
|
}
|
||
|
|
|
||
|
|
func (p *Provider) GetDomain(domain string) (*hosting.Domain, error) {
|
||
|
|
return nil, ErrNotSupported
|
||
|
|
}
|
||
|
|
|
||
|
|
func (p *Provider) CheckDomainAvailability(domains []string) ([]hosting.DomainAvailability, error) {
|
||
|
|
return nil, ErrNotSupported
|
||
|
|
}
|
||
|
|
|
||
|
|
func (p *Provider) PurchaseDomain(req hosting.DomainPurchaseRequest) (*hosting.Domain, error) {
|
||
|
|
return nil, ErrNotSupported
|
||
|
|
}
|
||
|
|
|
||
|
|
func (p *Provider) SetNameservers(domain string, nameservers []string) error {
|
||
|
|
return ErrNotSupported
|
||
|
|
}
|
||
|
|
|
||
|
|
func (p *Provider) EnableDomainLock(domain string) error { return ErrNotSupported }
|
||
|
|
func (p *Provider) DisableDomainLock(domain string) error { return ErrNotSupported }
|
||
|
|
func (p *Provider) EnablePrivacyProtection(domain string) error { return ErrNotSupported }
|
||
|
|
func (p *Provider) DisablePrivacyProtection(domain string) error { return ErrNotSupported }
|
||
|
|
|
||
|
|
// ── VMs / VPS ───────────────────────────────────────────────────────
|
||
|
|
|
||
|
|
func (p *Provider) ListVMs() ([]hosting.VM, error) { return nil, ErrNotSupported }
|
||
|
|
func (p *Provider) GetVM(id string) (*hosting.VM, error) { return nil, ErrNotSupported }
|
||
|
|
func (p *Provider) CreateVM(req hosting.VMCreateRequest) (*hosting.VM, error) { return nil, ErrNotSupported }
|
||
|
|
func (p *Provider) ListDataCenters() ([]hosting.DataCenter, error) { return nil, ErrNotSupported }
|
||
|
|
func (p *Provider) ListSSHKeys() ([]hosting.SSHKey, error) { return nil, ErrNotSupported }
|
||
|
|
func (p *Provider) AddSSHKey(name, publicKey string) (*hosting.SSHKey, error) { return nil, ErrNotSupported }
|
||
|
|
func (p *Provider) DeleteSSHKey(id string) error { return ErrNotSupported }
|
||
|
|
|
||
|
|
// ── Billing ─────────────────────────────────────────────────────────
|
||
|
|
|
||
|
|
func (p *Provider) ListSubscriptions() ([]hosting.Subscription, error) { return nil, ErrNotSupported }
|
||
|
|
func (p *Provider) GetCatalog() ([]hosting.CatalogItem, error) { return nil, ErrNotSupported }
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Registration
|
||
|
|
|
||
|
|
Registration happens automatically via Go's `init()` mechanism. When the main binary imports the provider package (even as a side-effect import), the `init()` function runs and calls `hosting.Register()`.
|
||
|
|
|
||
|
|
In `cmd/main.go` (or wherever the binary entry point is), add a blank import:
|
||
|
|
|
||
|
|
```go
|
||
|
|
import (
|
||
|
|
// Register hosting providers
|
||
|
|
_ "setec-manager/internal/hosting/hostinger"
|
||
|
|
_ "setec-manager/internal/hosting/myprovider"
|
||
|
|
)
|
||
|
|
```
|
||
|
|
|
||
|
|
The `hosting.Register()` function stores the provider instance in a global `map[string]Provider` protected by a `sync.RWMutex`:
|
||
|
|
|
||
|
|
```go
|
||
|
|
// From internal/hosting/provider.go
|
||
|
|
func Register(p Provider) {
|
||
|
|
registryMu.Lock()
|
||
|
|
defer registryMu.Unlock()
|
||
|
|
registry[p.Name()] = p
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
After registration, the provider is accessible via `hosting.Get("myprovider")` and appears in `hosting.List()`.
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Configuration Storage
|
||
|
|
|
||
|
|
When a user configures your provider (via the UI or API), the system:
|
||
|
|
|
||
|
|
1. Calls `provider.Configure(map[string]string{"api_key": "..."})` to set credentials in memory.
|
||
|
|
2. Calls `provider.TestConnection()` to verify the credentials work.
|
||
|
|
3. Saves a `ProviderConfig` to disk via `ProviderConfigStore.Save()`.
|
||
|
|
|
||
|
|
The config file is written to `<config_dir>/<provider_name>.json` with `0600` permissions:
|
||
|
|
|
||
|
|
```json
|
||
|
|
{
|
||
|
|
"provider": "myprovider",
|
||
|
|
"api_key": "sk-abc123...",
|
||
|
|
"api_secret": "",
|
||
|
|
"extra": {
|
||
|
|
"base_url": "https://api.myprovider.com/v2"
|
||
|
|
},
|
||
|
|
"connected": true
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
On startup, `ProviderConfigStore.loadAll()` reads all JSON files from the config directory, and for each one that matches a registered provider, calls `Configure()` to restore credentials.
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Error Handling
|
||
|
|
|
||
|
|
### The ErrNotSupported Pattern
|
||
|
|
|
||
|
|
Define a sentinel error in your provider package:
|
||
|
|
|
||
|
|
```go
|
||
|
|
var ErrNotSupported = errors.New("myprovider: operation not supported")
|
||
|
|
```
|
||
|
|
|
||
|
|
Return this error from any interface method your provider does not implement. The HTTP handler layer checks for this error and returns HTTP 501 (Not Implemented) to the client.
|
||
|
|
|
||
|
|
### API Errors
|
||
|
|
|
||
|
|
For errors from the upstream provider API, return a descriptive error with context:
|
||
|
|
|
||
|
|
```go
|
||
|
|
return fmt.Errorf("myprovider: list domains: %w", err)
|
||
|
|
```
|
||
|
|
|
||
|
|
### Rate Limiting
|
||
|
|
|
||
|
|
If the provider has rate limits, handle them inside your client. See the Hostinger implementation in `internal/hosting/hostinger/client.go` for a reference pattern:
|
||
|
|
|
||
|
|
1. Check for HTTP 429 responses.
|
||
|
|
2. Read the `Retry-After` header.
|
||
|
|
3. Sleep and retry (up to a maximum number of retries).
|
||
|
|
4. Return a clear error if retries are exhausted.
|
||
|
|
|
||
|
|
```go
|
||
|
|
if resp.StatusCode == http.StatusTooManyRequests {
|
||
|
|
retryAfter := parseRetryAfter(resp.Header.Get("Retry-After"))
|
||
|
|
if attempt < maxRetries {
|
||
|
|
time.Sleep(retryAfter)
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
return fmt.Errorf("myprovider: rate limited after %d retries", maxRetries)
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Testing
|
||
|
|
|
||
|
|
### Unit Tests
|
||
|
|
|
||
|
|
Create `internal/hosting/myprovider/provider_test.go`:
|
||
|
|
|
||
|
|
```go
|
||
|
|
package myprovider
|
||
|
|
|
||
|
|
import (
|
||
|
|
"testing"
|
||
|
|
|
||
|
|
"setec-manager/internal/hosting"
|
||
|
|
)
|
||
|
|
|
||
|
|
func TestProviderImplementsInterface(t *testing.T) {
|
||
|
|
var _ hosting.Provider = (*Provider)(nil)
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestName(t *testing.T) {
|
||
|
|
p := &Provider{}
|
||
|
|
if p.Name() != "myprovider" {
|
||
|
|
t.Errorf("expected name 'myprovider', got %q", p.Name())
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestConfigure(t *testing.T) {
|
||
|
|
p := &Provider{}
|
||
|
|
err := p.Configure(map[string]string{})
|
||
|
|
if err == nil {
|
||
|
|
t.Error("expected error when api_key is missing")
|
||
|
|
}
|
||
|
|
|
||
|
|
err = p.Configure(map[string]string{"api_key": "test-key"})
|
||
|
|
if err != nil {
|
||
|
|
t.Errorf("unexpected error: %v", err)
|
||
|
|
}
|
||
|
|
if p.apiKey != "test-key" {
|
||
|
|
t.Errorf("expected apiKey 'test-key', got %q", p.apiKey)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestUnsupportedMethodsReturnError(t *testing.T) {
|
||
|
|
p := &Provider{}
|
||
|
|
|
||
|
|
_, err := p.ListVMs()
|
||
|
|
if err != ErrNotSupported {
|
||
|
|
t.Errorf("ListVMs: expected ErrNotSupported, got %v", err)
|
||
|
|
}
|
||
|
|
|
||
|
|
_, err = p.GetCatalog()
|
||
|
|
if err != ErrNotSupported {
|
||
|
|
t.Errorf("GetCatalog: expected ErrNotSupported, got %v", err)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
### Integration Tests
|
||
|
|
|
||
|
|
For integration tests against the real API, use build tags to prevent them from running in CI:
|
||
|
|
|
||
|
|
```go
|
||
|
|
//go:build integration
|
||
|
|
|
||
|
|
package myprovider
|
||
|
|
|
||
|
|
import (
|
||
|
|
"os"
|
||
|
|
"testing"
|
||
|
|
)
|
||
|
|
|
||
|
|
func TestListDomainsIntegration(t *testing.T) {
|
||
|
|
key := os.Getenv("MYPROVIDER_API_KEY")
|
||
|
|
if key == "" {
|
||
|
|
t.Skip("MYPROVIDER_API_KEY not set")
|
||
|
|
}
|
||
|
|
|
||
|
|
p := &Provider{}
|
||
|
|
p.Configure(map[string]string{"api_key": key})
|
||
|
|
|
||
|
|
domains, err := p.ListDomains()
|
||
|
|
if err != nil {
|
||
|
|
t.Fatalf("ListDomains failed: %v", err)
|
||
|
|
}
|
||
|
|
t.Logf("Found %d domains", len(domains))
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
Run integration tests:
|
||
|
|
|
||
|
|
```bash
|
||
|
|
go test -tags=integration ./internal/hosting/myprovider/ -v
|
||
|
|
```
|
||
|
|
|
||
|
|
### Registration Test
|
||
|
|
|
||
|
|
Verify that importing the package registers the provider:
|
||
|
|
|
||
|
|
```go
|
||
|
|
package myprovider_test
|
||
|
|
|
||
|
|
import (
|
||
|
|
"testing"
|
||
|
|
|
||
|
|
"setec-manager/internal/hosting"
|
||
|
|
_ "setec-manager/internal/hosting/myprovider"
|
||
|
|
)
|
||
|
|
|
||
|
|
func TestRegistration(t *testing.T) {
|
||
|
|
p, err := hosting.Get("myprovider")
|
||
|
|
if err != nil {
|
||
|
|
t.Fatalf("provider not registered: %v", err)
|
||
|
|
}
|
||
|
|
if p.DisplayName() == "" {
|
||
|
|
t.Error("DisplayName is empty")
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Example: Skeleton Provider (DNS Only)
|
||
|
|
|
||
|
|
This is a complete, minimal provider that implements only DNS management. All other methods return `ErrNotSupported`. You can copy this file and fill in the DNS methods with real API calls.
|
||
|
|
|
||
|
|
```go
|
||
|
|
package dnsonlyprovider
|
||
|
|
|
||
|
|
import (
|
||
|
|
"encoding/json"
|
||
|
|
"errors"
|
||
|
|
"fmt"
|
||
|
|
"io"
|
||
|
|
"net/http"
|
||
|
|
"time"
|
||
|
|
|
||
|
|
"setec-manager/internal/hosting"
|
||
|
|
)
|
||
|
|
|
||
|
|
var ErrNotSupported = errors.New("dnsonlyprovider: operation not supported")
|
||
|
|
|
||
|
|
type Provider struct {
|
||
|
|
client *http.Client
|
||
|
|
apiKey string
|
||
|
|
baseURL string
|
||
|
|
}
|
||
|
|
|
||
|
|
func init() {
|
||
|
|
hosting.Register(&Provider{
|
||
|
|
client: &http.Client{Timeout: 30 * time.Second},
|
||
|
|
baseURL: "https://api.dns-only.example.com/v1",
|
||
|
|
})
|
||
|
|
}
|
||
|
|
|
||
|
|
func (p *Provider) Name() string { return "dnsonlyprovider" }
|
||
|
|
func (p *Provider) DisplayName() string { return "DNS-Only Provider" }
|
||
|
|
|
||
|
|
func (p *Provider) Configure(config map[string]string) error {
|
||
|
|
key, ok := config["api_key"]
|
||
|
|
if !ok || key == "" {
|
||
|
|
return fmt.Errorf("dnsonlyprovider: api_key is required")
|
||
|
|
}
|
||
|
|
p.apiKey = key
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func (p *Provider) TestConnection() error {
|
||
|
|
// Try listing zones as a health check.
|
||
|
|
req, _ := http.NewRequest("GET", p.baseURL+"/zones", nil)
|
||
|
|
req.Header.Set("Authorization", "Bearer "+p.apiKey)
|
||
|
|
resp, err := p.client.Do(req)
|
||
|
|
if err != nil {
|
||
|
|
return fmt.Errorf("dnsonlyprovider: connection failed: %w", err)
|
||
|
|
}
|
||
|
|
defer resp.Body.Close()
|
||
|
|
if resp.StatusCode != http.StatusOK {
|
||
|
|
body, _ := io.ReadAll(resp.Body)
|
||
|
|
return fmt.Errorf("dnsonlyprovider: API returned %d: %s", resp.StatusCode, body)
|
||
|
|
}
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
|
||
|
|
// ── DNS (implemented) ───────────────────────────────────────────────
|
||
|
|
|
||
|
|
func (p *Provider) ListDNSRecords(domain string) ([]hosting.DNSRecord, error) {
|
||
|
|
req, _ := http.NewRequest("GET", fmt.Sprintf("%s/zones/%s/records", p.baseURL, domain), nil)
|
||
|
|
req.Header.Set("Authorization", "Bearer "+p.apiKey)
|
||
|
|
|
||
|
|
resp, err := p.client.Do(req)
|
||
|
|
if err != nil {
|
||
|
|
return nil, fmt.Errorf("dnsonlyprovider: list records: %w", err)
|
||
|
|
}
|
||
|
|
defer resp.Body.Close()
|
||
|
|
|
||
|
|
var records []hosting.DNSRecord
|
||
|
|
if err := json.NewDecoder(resp.Body).Decode(&records); err != nil {
|
||
|
|
return nil, fmt.Errorf("dnsonlyprovider: parse records: %w", err)
|
||
|
|
}
|
||
|
|
return records, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func (p *Provider) CreateDNSRecord(domain string, record hosting.DNSRecord) error {
|
||
|
|
// Implementation: POST to /zones/{domain}/records
|
||
|
|
return ErrNotSupported // replace with real implementation
|
||
|
|
}
|
||
|
|
|
||
|
|
func (p *Provider) UpdateDNSRecords(domain string, records []hosting.DNSRecord, overwrite bool) error {
|
||
|
|
// Implementation: PUT to /zones/{domain}/records
|
||
|
|
return ErrNotSupported // replace with real implementation
|
||
|
|
}
|
||
|
|
|
||
|
|
func (p *Provider) DeleteDNSRecord(domain string, recordName, recordType string) error {
|
||
|
|
// Implementation: DELETE /zones/{domain}/records?name=...&type=...
|
||
|
|
return ErrNotSupported // replace with real implementation
|
||
|
|
}
|
||
|
|
|
||
|
|
func (p *Provider) ResetDNSRecords(domain string) error {
|
||
|
|
return ErrNotSupported
|
||
|
|
}
|
||
|
|
|
||
|
|
// ── Everything else: not supported ──────────────────────────────────
|
||
|
|
|
||
|
|
func (p *Provider) ListDomains() ([]hosting.Domain, error) { return nil, ErrNotSupported }
|
||
|
|
func (p *Provider) GetDomain(domain string) (*hosting.Domain, error) { return nil, ErrNotSupported }
|
||
|
|
func (p *Provider) CheckDomainAvailability(domains []string) ([]hosting.DomainAvailability, error) { return nil, ErrNotSupported }
|
||
|
|
func (p *Provider) PurchaseDomain(req hosting.DomainPurchaseRequest) (*hosting.Domain, error) { return nil, ErrNotSupported }
|
||
|
|
func (p *Provider) SetNameservers(domain string, nameservers []string) error { return ErrNotSupported }
|
||
|
|
func (p *Provider) EnableDomainLock(domain string) error { return ErrNotSupported }
|
||
|
|
func (p *Provider) DisableDomainLock(domain string) error { return ErrNotSupported }
|
||
|
|
func (p *Provider) EnablePrivacyProtection(domain string) error { return ErrNotSupported }
|
||
|
|
func (p *Provider) DisablePrivacyProtection(domain string) error { return ErrNotSupported }
|
||
|
|
func (p *Provider) ListVMs() ([]hosting.VM, error) { return nil, ErrNotSupported }
|
||
|
|
func (p *Provider) GetVM(id string) (*hosting.VM, error) { return nil, ErrNotSupported }
|
||
|
|
func (p *Provider) CreateVM(req hosting.VMCreateRequest) (*hosting.VM, error) { return nil, ErrNotSupported }
|
||
|
|
func (p *Provider) ListDataCenters() ([]hosting.DataCenter, error) { return nil, ErrNotSupported }
|
||
|
|
func (p *Provider) ListSSHKeys() ([]hosting.SSHKey, error) { return nil, ErrNotSupported }
|
||
|
|
func (p *Provider) AddSSHKey(name, publicKey string) (*hosting.SSHKey, error) { return nil, ErrNotSupported }
|
||
|
|
func (p *Provider) DeleteSSHKey(id string) error { return ErrNotSupported }
|
||
|
|
func (p *Provider) ListSubscriptions() ([]hosting.Subscription, error) { return nil, ErrNotSupported }
|
||
|
|
func (p *Provider) GetCatalog() ([]hosting.CatalogItem, error) { return nil, ErrNotSupported }
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Example: Full Provider Structure
|
||
|
|
|
||
|
|
For a provider that implements all capabilities, organize the code across multiple files:
|
||
|
|
|
||
|
|
```
|
||
|
|
internal/hosting/fullprovider/
|
||
|
|
provider.go -- init(), Name(), DisplayName(), Configure(), TestConnection()
|
||
|
|
client.go -- HTTP client with auth, retry, rate-limit handling
|
||
|
|
dns.go -- ListDNSRecords, CreateDNSRecord, UpdateDNSRecords, DeleteDNSRecord, ResetDNSRecords
|
||
|
|
domains.go -- ListDomains, GetDomain, CheckDomainAvailability, PurchaseDomain, nameserver/lock/privacy methods
|
||
|
|
vms.go -- ListVMs, GetVM, CreateVM, ListDataCenters
|
||
|
|
ssh.go -- ListSSHKeys, AddSSHKey, DeleteSSHKey
|
||
|
|
billing.go -- ListSubscriptions, GetCatalog
|
||
|
|
types.go -- Provider-specific API request/response types
|
||
|
|
```
|
||
|
|
|
||
|
|
Each file focuses on a single capability area. The `client.go` file provides a shared `doRequest()` method (similar to the Hostinger client) that handles authentication headers, JSON marshaling, error parsing, and retry logic.
|
||
|
|
|
||
|
|
### Key Patterns from the Hostinger Implementation
|
||
|
|
|
||
|
|
1. **Separate API types from generic types.** Define provider-specific request/response structs (e.g., `hostingerDNSRecord`) and conversion functions (`toGenericDNSRecord`, `toHostingerDNSRecord`).
|
||
|
|
|
||
|
|
2. **Validate before mutating.** The Hostinger DNS implementation calls a `/validate` endpoint before applying updates. If your provider offers similar validation, use it.
|
||
|
|
|
||
|
|
3. **Synthesize IDs when the API does not provide them.** Hostinger does not return record IDs in zone listings, so the client synthesizes them from `name/type/priority`.
|
||
|
|
|
||
|
|
4. **Handle rate limits transparently.** The client retries on HTTP 429 with exponential back-off, capping at 60 seconds per retry and 3 retries total. This keeps rate-limit handling invisible to the caller.
|