crabbox/internal/cli/pool.go
2026-05-01 11:12:23 +01:00

214 lines
5.7 KiB
Go

package cli
import (
"context"
"encoding/json"
"fmt"
"strings"
"time"
)
func (a App) pool(ctx context.Context, args []string) error {
if len(args) == 0 || args[0] != "list" {
return exit(2, "usage: crabbox pool list [--json]")
}
return a.list(ctx, args[1:])
}
func (a App) list(ctx context.Context, args []string) error {
fs := newFlagSet("list", a.Stderr)
provider := fs.String("provider", defaultConfig().Provider, "provider: hetzner, aws, or blacksmith-testbox")
jsonOut := fs.Bool("json", false, "print JSON")
if err := parseFlags(fs, args); err != nil {
return err
}
cfg, err := loadConfig()
if err != nil {
return err
}
cfg.Provider = *provider
if isBlacksmithProvider(cfg.Provider) {
return a.blacksmithList(ctx, cfg, *jsonOut)
}
if coord, ok, err := newCoordinatorClient(cfg); err != nil {
return err
} else if ok {
machines, err := coord.Pool(ctx, cfg)
if err != nil {
return err
}
if *jsonOut {
return json.NewEncoder(a.Stdout).Encode(machines)
}
for _, s := range machines {
fmt.Fprintf(a.Stdout, "%-20s %-28s %-12s %-14s %-15s lease=%s slug=%s keep=%s\n",
s.ID, s.Name, s.Status, s.ServerType, s.Host, s.Labels["lease"], blank(s.Labels["slug"], "-"), s.Labels["keep"])
}
return nil
}
if cfg.Provider == "aws" {
client, err := newAWSClient(ctx, cfg)
if err != nil {
return err
}
servers, err := client.ListCrabboxServers(ctx)
if err != nil {
return err
}
if *jsonOut {
return json.NewEncoder(a.Stdout).Encode(servers)
}
for _, s := range servers {
fmt.Fprintf(a.Stdout, "%-20s %-28s %-12s %-14s %-15s lease=%s slug=%s keep=%s\n",
s.DisplayID(), s.Name, s.Status, s.ServerType.Name, s.PublicNet.IPv4.IP, s.Labels["lease"], blank(serverSlug(s), "-"), s.Labels["keep"])
}
return nil
}
client, err := newHetznerClient()
if err != nil {
return err
}
servers, err := client.ListCrabboxServers(ctx)
if err != nil {
return err
}
if *jsonOut {
return json.NewEncoder(a.Stdout).Encode(servers)
}
for _, s := range servers {
fmt.Fprintf(a.Stdout, "%-20s %-28s %-12s %-14s %-15s lease=%s slug=%s keep=%s\n",
s.DisplayID(), s.Name, s.Status, s.ServerType.Name, s.PublicNet.IPv4.IP, s.Labels["lease"], blank(serverSlug(s), "-"), s.Labels["keep"])
}
return nil
}
func (a App) machine(ctx context.Context, args []string) error {
if len(args) == 0 {
return exit(2, "usage: crabbox machine cleanup [--dry-run]")
}
switch args[0] {
case "cleanup":
return a.cleanup(ctx, args[1:])
default:
return exit(2, "unknown machine command %q", args[0])
}
}
func (a App) cleanup(ctx context.Context, args []string) error {
fs := newFlagSet("machine cleanup", a.Stderr)
provider := fs.String("provider", defaultConfig().Provider, "provider: hetzner or aws")
dryRun := fs.Bool("dry-run", false, "only print")
if err := parseFlags(fs, args); err != nil {
return err
}
cfg, err := loadConfig()
if err != nil {
return err
}
cfg.Provider = *provider
if _, ok, err := newCoordinatorClient(cfg); err != nil {
return err
} else if ok {
return exit(2, "machine cleanup is disabled when a coordinator is configured; coordinator TTL alarms own brokered cleanup")
}
if cfg.Provider == "aws" {
awsClient, err := newAWSClient(ctx, cfg)
if err != nil {
return err
}
servers, err := awsClient.ListCrabboxServers(ctx)
if err != nil {
return err
}
for _, s := range servers {
shouldDelete, reason := shouldCleanupServer(s, time.Now().UTC())
if !shouldDelete {
fmt.Fprintf(a.Stderr, "skip server id=%s name=%s reason=%s\n", s.DisplayID(), s.Name, reason)
continue
}
fmt.Fprintf(a.Stderr, "delete server id=%s name=%s\n", s.DisplayID(), s.Name)
if !*dryRun {
if err := deleteServer(ctx, cfg, s); err != nil {
return err
}
}
}
return nil
}
client, err := newHetznerClient()
if err != nil {
return err
}
servers, err := client.ListCrabboxServers(ctx)
if err != nil {
return err
}
for _, s := range servers {
shouldDelete, reason := shouldCleanupServer(s, time.Now().UTC())
if !shouldDelete {
fmt.Fprintf(a.Stderr, "skip server id=%s name=%s reason=%s\n", s.DisplayID(), s.Name, reason)
continue
}
fmt.Fprintf(a.Stderr, "delete server id=%s name=%s\n", s.DisplayID(), s.Name)
if !*dryRun {
if err := deleteServer(ctx, cfg, s); err != nil {
return err
}
}
}
return nil
}
func shouldCleanupServer(server Server, now time.Time) (bool, string) {
labels := server.Labels
if labels == nil {
return false, "missing labels"
}
if strings.EqualFold(labels["keep"], "true") {
return false, "keep=true"
}
state := strings.ToLower(labels["state"])
switch state {
case "running", "provisioning":
expiresAt, ok := cleanupExpiry(labels)
if ok && now.After(expiresAt.Add(12*time.Hour)) {
return true, "stale state=" + state
}
return false, "state=" + state
case "leased", "ready", "active":
expiresAt, ok := cleanupExpiry(labels)
if ok && now.After(expiresAt) {
return true, "expired state=" + state
}
return false, "state=" + state
}
if state == "failed" || state == "released" || state == "expired" {
return true, "state=" + state
}
expiresAt, ok := cleanupExpiry(labels)
if !ok {
return false, "missing expires_at"
}
if now.Before(expiresAt) {
return false, "not expired"
}
return true, "expired"
}
func cleanupExpiry(labels map[string]string) (time.Time, bool) {
for _, key := range []string{"expires_at", "ttl"} {
value := strings.TrimSpace(labels[key])
if value == "" {
continue
}
if parsed, ok := parseLeaseLabelTime(value); ok {
return parsed, true
}
}
return time.Time{}, false
}
func directLeaseExpiresAt(now time.Time, cfg Config) time.Time {
return directLeaseExpiresAtFrom(now, now, cfg.TTL, cfg.IdleTimeout)
}