package providers import ( "bytes" "context" "encoding/json" "fmt" "io" "net/http" "os" "time" "github.com/wangjia/pangolin/server/internal/provision" ) // Factory builds a CloudAdapter, reading credentials from independent secrets // (env). It returns provision.ErrNoCredentials when the required secret is unset. type Factory func() (provision.CloudAdapter, error) // builtins maps api_kind → Factory. Extend here when onboarding a vendor. var builtins = map[string]Factory{ "vultr": newVultrFromEnv, "hetzner": newHetznerFromEnv, } // Registry resolves provision.Provider rows to live adapters. It satisfies // provision.AdapterFactory and caches one adapter per api_kind. type Registry struct { factories map[string]Factory cache map[string]provision.CloudAdapter } // NewRegistry returns a Registry backed by the built-in vendor factories. func NewRegistry() *Registry { fs := make(map[string]Factory, len(builtins)) for k, v := range builtins { fs[k] = v } return &Registry{factories: fs, cache: map[string]provision.CloudAdapter{}} } // Register adds or overrides a factory for api_kind (used in tests / extension). func (r *Registry) Register(apiKind string, f Factory) { r.factories[apiKind] = f } // For resolves the adapter for a provider row. func (r *Registry) For(p *provision.Provider) (provision.CloudAdapter, error) { if a, ok := r.cache[p.APIKind]; ok { return a, nil } f, ok := r.factories[p.APIKind] if !ok { return nil, fmt.Errorf("providers: no adapter registered for api_kind %q", p.APIKind) } a, err := f() if err != nil { return nil, err } r.cache[p.APIKind] = a return a, nil } var _ provision.AdapterFactory = (*Registry)(nil) // --- shared HTTP helper --- // httpClient is a small JSON REST helper shared by the adapters. type httpClient struct { base string bearer string hc *http.Client } func newHTTPClient(base, bearer string) *httpClient { return &httpClient{base: base, bearer: bearer, hc: &http.Client{Timeout: 30 * time.Second}} } // do issues an authenticated JSON request and decodes the response into out // (out may be nil). It returns an error on any non-2xx status. func (c *httpClient) do(ctx context.Context, method, path string, body, out any) error { var rdr io.Reader if body != nil { buf, err := json.Marshal(body) if err != nil { return fmt.Errorf("providers: marshal body: %w", err) } rdr = bytes.NewReader(buf) } req, err := http.NewRequestWithContext(ctx, method, c.base+path, rdr) if err != nil { return fmt.Errorf("providers: build request: %w", err) } req.Header.Set("Authorization", "Bearer "+c.bearer) if body != nil { req.Header.Set("Content-Type", "application/json") } resp, err := c.hc.Do(req) if err != nil { return fmt.Errorf("providers: %s %s: %w", method, path, err) } defer resp.Body.Close() data, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) if resp.StatusCode < 200 || resp.StatusCode >= 300 { // Never echo credentials; only status + vendor body (no auth header). return fmt.Errorf("providers: %s %s: status %d: %s", method, path, resp.StatusCode, string(data)) } if out != nil && len(data) > 0 { if err := json.Unmarshal(data, out); err != nil { return fmt.Errorf("providers: decode response: %w", err) } } return nil } // secret reads an env-injected credential, returning ErrNoCredentials if unset. func secret(env string) (string, error) { v := os.Getenv(env) if v == "" { return "", fmt.Errorf("%w (env %s)", provision.ErrNoCredentials, env) } return v, nil }