go/pkg/infra/hetzner.go
Snider 349e8daa0b feat(prod): add production infrastructure management
Add `core prod` command with full production infrastructure tooling:

- `core prod status` — parallel SSH health checks across all hosts,
  Galera cluster state, Redis sentinel, Docker, LB health
- `core prod setup` — Phase 1 foundation: Hetzner topology discovery,
  managed LB creation, CloudNS DNS record management
- `core prod dns` — CloudNS record CRUD with idempotent EnsureRecord
- `core prod lb` — Hetzner Cloud LB status and creation
- `core prod ssh <host>` — SSH into hosts defined in infra.yaml

New packages:
- pkg/infra: config parsing, Hetzner Cloud/Robot API, CloudNS DNS API
- infra.yaml: declarative production topology (hosts, LB, DNS, SSL,
  Galera, Redis, containers, S3, CDN, CI/CD, monitoring, backups)

Docker:
- Dockerfile.app (PHP 8.3-FPM, multi-stage)
- Dockerfile.web (Nginx + security headers)
- docker-compose.prod.yml (app, web, horizon, scheduler, mcp, redis, galera)

Ansible playbooks (runnable via `core deploy ansible`):
- galera-deploy.yml, redis-deploy.yml, galera-backup.yml
- inventory.yml with all production hosts

CI/CD:
- .forgejo/workflows/deploy.yml for Forgejo Actions pipeline

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-06 03:03:29 +00:00

381 lines
11 KiB
Go

package infra
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
)
const (
hcloudBaseURL = "https://api.hetzner.cloud/v1"
hrobotBaseURL = "https://robot-ws.your-server.de"
)
// HCloudClient is an HTTP client for the Hetzner Cloud API.
type HCloudClient struct {
token string
client *http.Client
}
// NewHCloudClient creates a new Hetzner Cloud API client.
func NewHCloudClient(token string) *HCloudClient {
return &HCloudClient{
token: token,
client: &http.Client{
Timeout: 30 * time.Second,
},
}
}
// HCloudServer represents a Hetzner Cloud server.
type HCloudServer struct {
ID int `json:"id"`
Name string `json:"name"`
Status string `json:"status"`
PublicNet HCloudPublicNet `json:"public_net"`
PrivateNet []HCloudPrivateNet `json:"private_net"`
ServerType HCloudServerType `json:"server_type"`
Datacenter HCloudDatacenter `json:"datacenter"`
Labels map[string]string `json:"labels"`
}
// HCloudPublicNet holds public network info.
type HCloudPublicNet struct {
IPv4 HCloudIPv4 `json:"ipv4"`
}
// HCloudIPv4 holds an IPv4 address.
type HCloudIPv4 struct {
IP string `json:"ip"`
}
// HCloudPrivateNet holds private network info.
type HCloudPrivateNet struct {
IP string `json:"ip"`
Network int `json:"network"`
}
// HCloudServerType holds server type info.
type HCloudServerType struct {
Name string `json:"name"`
Description string `json:"description"`
Cores int `json:"cores"`
Memory float64 `json:"memory"`
Disk int `json:"disk"`
}
// HCloudDatacenter holds datacenter info.
type HCloudDatacenter struct {
Name string `json:"name"`
Description string `json:"description"`
}
// HCloudLoadBalancer represents a Hetzner Cloud load balancer.
type HCloudLoadBalancer struct {
ID int `json:"id"`
Name string `json:"name"`
PublicNet HCloudLBPublicNet `json:"public_net"`
Algorithm HCloudLBAlgorithm `json:"algorithm"`
Services []HCloudLBService `json:"services"`
Targets []HCloudLBTarget `json:"targets"`
Location HCloudDatacenter `json:"location"`
Labels map[string]string `json:"labels"`
}
// HCloudLBPublicNet holds LB public network info.
type HCloudLBPublicNet struct {
Enabled bool `json:"enabled"`
IPv4 HCloudIPv4 `json:"ipv4"`
}
// HCloudLBAlgorithm holds the LB algorithm.
type HCloudLBAlgorithm struct {
Type string `json:"type"`
}
// HCloudLBService describes an LB listener.
type HCloudLBService struct {
Protocol string `json:"protocol"`
ListenPort int `json:"listen_port"`
DestinationPort int `json:"destination_port"`
Proxyprotocol bool `json:"proxyprotocol"`
HTTP *HCloudLBHTTP `json:"http,omitempty"`
HealthCheck *HCloudLBHealthCheck `json:"health_check,omitempty"`
}
// HCloudLBHTTP holds HTTP-specific LB options.
type HCloudLBHTTP struct {
RedirectHTTP bool `json:"redirect_http"`
}
// HCloudLBHealthCheck holds LB health check config.
type HCloudLBHealthCheck struct {
Protocol string `json:"protocol"`
Port int `json:"port"`
Interval int `json:"interval"`
Timeout int `json:"timeout"`
Retries int `json:"retries"`
HTTP *HCloudLBHCHTTP `json:"http,omitempty"`
}
// HCloudLBHCHTTP holds HTTP health check options.
type HCloudLBHCHTTP struct {
Path string `json:"path"`
StatusCode string `json:"status_codes"`
}
// HCloudLBTarget is a load balancer backend target.
type HCloudLBTarget struct {
Type string `json:"type"`
IP *HCloudLBTargetIP `json:"ip,omitempty"`
Server *HCloudLBTargetServer `json:"server,omitempty"`
HealthStatus []HCloudLBHealthStatus `json:"health_status"`
}
// HCloudLBTargetIP is an IP-based LB target.
type HCloudLBTargetIP struct {
IP string `json:"ip"`
}
// HCloudLBTargetServer is a server-based LB target.
type HCloudLBTargetServer struct {
ID int `json:"id"`
}
// HCloudLBHealthStatus holds target health info.
type HCloudLBHealthStatus struct {
ListenPort int `json:"listen_port"`
Status string `json:"status"`
}
// HCloudLBCreateRequest holds load balancer creation params.
type HCloudLBCreateRequest struct {
Name string `json:"name"`
LoadBalancerType string `json:"load_balancer_type"`
Location string `json:"location"`
Algorithm HCloudLBAlgorithm `json:"algorithm"`
Services []HCloudLBService `json:"services"`
Targets []HCloudLBCreateTarget `json:"targets"`
Labels map[string]string `json:"labels"`
}
// HCloudLBCreateTarget is a target for LB creation.
type HCloudLBCreateTarget struct {
Type string `json:"type"`
IP *HCloudLBTargetIP `json:"ip,omitempty"`
}
// ListServers returns all Hetzner Cloud servers.
func (c *HCloudClient) ListServers(ctx context.Context) ([]HCloudServer, error) {
var result struct {
Servers []HCloudServer `json:"servers"`
}
if err := c.get(ctx, "/servers", &result); err != nil {
return nil, err
}
return result.Servers, nil
}
// ListLoadBalancers returns all load balancers.
func (c *HCloudClient) ListLoadBalancers(ctx context.Context) ([]HCloudLoadBalancer, error) {
var result struct {
LoadBalancers []HCloudLoadBalancer `json:"load_balancers"`
}
if err := c.get(ctx, "/load_balancers", &result); err != nil {
return nil, err
}
return result.LoadBalancers, nil
}
// GetLoadBalancer returns a load balancer by ID.
func (c *HCloudClient) GetLoadBalancer(ctx context.Context, id int) (*HCloudLoadBalancer, error) {
var result struct {
LoadBalancer HCloudLoadBalancer `json:"load_balancer"`
}
if err := c.get(ctx, fmt.Sprintf("/load_balancers/%d", id), &result); err != nil {
return nil, err
}
return &result.LoadBalancer, nil
}
// CreateLoadBalancer creates a new load balancer.
func (c *HCloudClient) CreateLoadBalancer(ctx context.Context, req HCloudLBCreateRequest) (*HCloudLoadBalancer, error) {
body, err := json.Marshal(req)
if err != nil {
return nil, fmt.Errorf("marshal request: %w", err)
}
var result struct {
LoadBalancer HCloudLoadBalancer `json:"load_balancer"`
}
if err := c.post(ctx, "/load_balancers", body, &result); err != nil {
return nil, err
}
return &result.LoadBalancer, nil
}
// DeleteLoadBalancer deletes a load balancer by ID.
func (c *HCloudClient) DeleteLoadBalancer(ctx context.Context, id int) error {
return c.delete(ctx, fmt.Sprintf("/load_balancers/%d", id))
}
// CreateSnapshot creates a server snapshot.
func (c *HCloudClient) CreateSnapshot(ctx context.Context, serverID int, description string) error {
body, _ := json.Marshal(map[string]string{
"description": description,
"type": "snapshot",
})
return c.post(ctx, fmt.Sprintf("/servers/%d/actions/create_image", serverID), body, nil)
}
func (c *HCloudClient) get(ctx context.Context, path string, result any) error {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, hcloudBaseURL+path, nil)
if err != nil {
return err
}
return c.do(req, result)
}
func (c *HCloudClient) post(ctx context.Context, path string, body []byte, result any) error {
req, err := http.NewRequestWithContext(ctx, http.MethodPost, hcloudBaseURL+path, strings.NewReader(string(body)))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
return c.do(req, result)
}
func (c *HCloudClient) delete(ctx context.Context, path string) error {
req, err := http.NewRequestWithContext(ctx, http.MethodDelete, hcloudBaseURL+path, nil)
if err != nil {
return err
}
return c.do(req, nil)
}
func (c *HCloudClient) do(req *http.Request, result any) error {
req.Header.Set("Authorization", "Bearer "+c.token)
resp, err := c.client.Do(req)
if err != nil {
return fmt.Errorf("hcloud API: %w", err)
}
defer func() { _ = resp.Body.Close() }()
data, err := io.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("read response: %w", err)
}
if resp.StatusCode >= 400 {
var apiErr struct {
Error struct {
Code string `json:"code"`
Message string `json:"message"`
} `json:"error"`
}
if json.Unmarshal(data, &apiErr) == nil && apiErr.Error.Message != "" {
return fmt.Errorf("hcloud API %d: %s — %s", resp.StatusCode, apiErr.Error.Code, apiErr.Error.Message)
}
return fmt.Errorf("hcloud API %d: %s", resp.StatusCode, string(data))
}
if result != nil {
if err := json.Unmarshal(data, result); err != nil {
return fmt.Errorf("decode response: %w", err)
}
}
return nil
}
// --- Hetzner Robot API ---
// HRobotClient is an HTTP client for the Hetzner Robot API.
type HRobotClient struct {
user string
password string
client *http.Client
}
// NewHRobotClient creates a new Hetzner Robot API client.
func NewHRobotClient(user, password string) *HRobotClient {
return &HRobotClient{
user: user,
password: password,
client: &http.Client{
Timeout: 30 * time.Second,
},
}
}
// HRobotServer represents a Hetzner Robot dedicated server.
type HRobotServer struct {
ServerIP string `json:"server_ip"`
ServerName string `json:"server_name"`
Product string `json:"product"`
Datacenter string `json:"dc"`
Status string `json:"status"`
Cancelled bool `json:"cancelled"`
PaidUntil string `json:"paid_until"`
}
// ListServers returns all Robot dedicated servers.
func (c *HRobotClient) ListServers(ctx context.Context) ([]HRobotServer, error) {
var raw []struct {
Server HRobotServer `json:"server"`
}
if err := c.get(ctx, "/server", &raw); err != nil {
return nil, err
}
servers := make([]HRobotServer, len(raw))
for i, s := range raw {
servers[i] = s.Server
}
return servers, nil
}
// GetServer returns a Robot server by IP.
func (c *HRobotClient) GetServer(ctx context.Context, ip string) (*HRobotServer, error) {
var raw struct {
Server HRobotServer `json:"server"`
}
if err := c.get(ctx, "/server/"+ip, &raw); err != nil {
return nil, err
}
return &raw.Server, nil
}
func (c *HRobotClient) get(ctx context.Context, path string, result any) error {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, hrobotBaseURL+path, nil)
if err != nil {
return err
}
req.SetBasicAuth(c.user, c.password)
resp, err := c.client.Do(req)
if err != nil {
return fmt.Errorf("hrobot API: %w", err)
}
defer func() { _ = resp.Body.Close() }()
data, err := io.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("read response: %w", err)
}
if resp.StatusCode >= 400 {
return fmt.Errorf("hrobot API %d: %s", resp.StatusCode, string(data))
}
if result != nil {
if err := json.Unmarshal(data, result); err != nil {
return fmt.Errorf("decode response: %w", err)
}
}
return nil
}