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>
113 lines
2.5 KiB
Go
113 lines
2.5 KiB
Go
package prod
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"os"
|
|
"time"
|
|
|
|
"github.com/host-uk/core/pkg/cli"
|
|
"github.com/host-uk/core/pkg/infra"
|
|
"github.com/spf13/cobra"
|
|
)
|
|
|
|
var lbCmd = &cobra.Command{
|
|
Use: "lb",
|
|
Short: "Manage Hetzner load balancer",
|
|
Long: `View and manage the Hetzner Cloud managed load balancer.
|
|
|
|
Requires: HCLOUD_TOKEN`,
|
|
}
|
|
|
|
var lbStatusCmd = &cobra.Command{
|
|
Use: "status",
|
|
Short: "Show load balancer status and target health",
|
|
RunE: runLBStatus,
|
|
}
|
|
|
|
var lbCreateCmd = &cobra.Command{
|
|
Use: "create",
|
|
Short: "Create load balancer from infra.yaml",
|
|
RunE: runLBCreate,
|
|
}
|
|
|
|
func init() {
|
|
lbCmd.AddCommand(lbStatusCmd)
|
|
lbCmd.AddCommand(lbCreateCmd)
|
|
}
|
|
|
|
func getHCloudClient() (*infra.HCloudClient, error) {
|
|
token := os.Getenv("HCLOUD_TOKEN")
|
|
if token == "" {
|
|
return nil, fmt.Errorf("HCLOUD_TOKEN environment variable required")
|
|
}
|
|
return infra.NewHCloudClient(token), nil
|
|
}
|
|
|
|
func runLBStatus(cmd *cobra.Command, args []string) error {
|
|
hc, err := getHCloudClient()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
|
defer cancel()
|
|
|
|
lbs, err := hc.ListLoadBalancers(ctx)
|
|
if err != nil {
|
|
return fmt.Errorf("list load balancers: %w", err)
|
|
}
|
|
|
|
if len(lbs) == 0 {
|
|
cli.Print("No load balancers found\n")
|
|
return nil
|
|
}
|
|
|
|
for _, lb := range lbs {
|
|
cli.Print("%s %s\n", cli.BoldStyle.Render("▶"), cli.TitleStyle.Render(lb.Name))
|
|
cli.Print(" ID: %d\n", lb.ID)
|
|
cli.Print(" IP: %s\n", lb.PublicNet.IPv4.IP)
|
|
cli.Print(" Algorithm: %s\n", lb.Algorithm.Type)
|
|
cli.Print(" Location: %s\n", lb.Location.Name)
|
|
|
|
if len(lb.Services) > 0 {
|
|
cli.Print("\n Services:\n")
|
|
for _, s := range lb.Services {
|
|
cli.Print(" %s :%d -> :%d proxy_protocol=%v\n",
|
|
s.Protocol, s.ListenPort, s.DestinationPort, s.Proxyprotocol)
|
|
}
|
|
}
|
|
|
|
if len(lb.Targets) > 0 {
|
|
cli.Print("\n Targets:\n")
|
|
for _, t := range lb.Targets {
|
|
ip := ""
|
|
if t.IP != nil {
|
|
ip = t.IP.IP
|
|
}
|
|
for _, hs := range t.HealthStatus {
|
|
icon := cli.SuccessStyle.Render("●")
|
|
if hs.Status != "healthy" {
|
|
icon = cli.ErrorStyle.Render("○")
|
|
}
|
|
cli.Print(" %s %s :%d %s\n", icon, ip, hs.ListenPort, hs.Status)
|
|
}
|
|
}
|
|
}
|
|
fmt.Println()
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func runLBCreate(cmd *cobra.Command, args []string) error {
|
|
cfg, _, err := loadConfig()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
|
defer cancel()
|
|
|
|
return stepLoadBalancer(ctx, cfg)
|
|
}
|