// Package dbcli implements control-plane PostgreSQL maintenance CLI (HTTP or local DSN). package dbcli import ( "bytes" "context" "encoding/json" "flag" "fmt" "io" "net/http" "os" "strings" "time" "evobgp/internal/db" "evobgp/internal/httpclient" "evobgp/internal/pgmonitor" ) // Run executes db subcommands; args exclude program name and "db". func Run(args []string) int { if len(args) == 0 { printUsage() return 2 } switch args[0] { case "report": return cmdReport(args[1:]) case "vacuum": return cmdMaint(args[1:], "vacuum", "/v1/postgres/vacuum") case "analyze": return cmdMaint(args[1:], "analyze", "/v1/postgres/analyze") case "cleanup": return cmdCleanup(args[1:]) default: fmt.Fprintf(os.Stderr, "dbcli: unknown command %q\n", args[0]) printUsage() return 2 } } func printUsage() { fmt.Fprintln(os.Stderr, `usage: evobgp-api db report [--api-url URL] [--token TOKEN] [--format json] evobgp-api db vacuum [--table NAME] [--dry-run] [--api-url URL] [--token TOKEN] evobgp-api db analyze [--table NAME] [--dry-run] [--api-url URL] [--token TOKEN] evobgp-api db cleanup --policy NAME [--dry-run] [--limit N] [--api-url URL] [--token TOKEN] Local break-glass: set EVOBGP_DATABASE_URL (report only uses direct SQL).`) } func cmdReport(args []string) int { fs := flag.NewFlagSet("report", flag.ExitOnError) apiURL := fs.String("api-url", "", "control plane base URL") token := fs.String("token", "", "Bearer token (operator)") format := fs.String("format", "json", "output format (json)") _ = fs.Parse(args) if dsn := strings.TrimSpace(os.Getenv("EVOBGP_DATABASE_URL")); dsn != "" && *apiURL == "" { ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() pool, err := db.OpenPostgresPool(ctx, dsn) if err != nil { fmt.Fprintln(os.Stderr, err) return 1 } defer pool.Close() svc := pgmonitor.NewService(pool) ov, err := svc.Overview(ctx) if err != nil { fmt.Fprintln(os.Stderr, err) return 1 } return writeJSONStdout(ov, *format) } if *apiURL == "" || *token == "" { fmt.Fprintln(os.Stderr, "report: --api-url and --token required without EVOBGP_DATABASE_URL") return 2 } body, err := apiGET(*apiURL, *token, "/v1/monitoring/postgres/overview") if err != nil { fmt.Fprintln(os.Stderr, err) return 1 } var pretty any if err := json.Unmarshal(body, &pretty); err != nil { fmt.Fprintln(os.Stderr, err) return 1 } return writeJSONStdout(pretty, *format) } func cmdMaint(args []string, _ string, path string) int { fs := flag.NewFlagSet("maint", flag.ExitOnError) table := fs.String("table", "", "table name") dryRun := fs.Bool("dry-run", false, "dry run only") apiURL := fs.String("api-url", "", "control plane base URL") token := fs.String("token", "", "Bearer token (operator)") _ = fs.Parse(args) if *apiURL == "" || *token == "" { fmt.Fprintln(os.Stderr, "maintenance: --api-url and --token are required") return 2 } payload := map[string]any{"dry_run": *dryRun} if *table != "" { payload["table"] = *table } body, err := apiPOST(*apiURL, *token, path, payload) if err != nil { fmt.Fprintln(os.Stderr, err) return 1 } return writeRawJSON(body) } func cmdCleanup(args []string) int { fs := flag.NewFlagSet("cleanup", flag.ExitOnError) policy := fs.String("policy", "", "cleanup policy name") dryRun := fs.Bool("dry-run", true, "dry run") limit := fs.Int("limit", 10000, "max rows") apiURL := fs.String("api-url", "", "control plane base URL") token := fs.String("token", "", "Bearer token (operator)") _ = fs.Parse(args) if *policy == "" { fmt.Fprintln(os.Stderr, "cleanup: --policy is required") return 2 } if *apiURL == "" || *token == "" { fmt.Fprintln(os.Stderr, "cleanup: --api-url and --token are required") return 2 } payload := map[string]any{"policy": *policy, "dry_run": *dryRun, "limit": *limit} body, err := apiPOST(*apiURL, *token, "/v1/postgres/cleanup", payload) if err != nil { fmt.Fprintln(os.Stderr, err) return 1 } return writeRawJSON(body) } func apiGET(base, token, path string) ([]byte, error) { u := strings.TrimRight(base, "/") + path req, err := http.NewRequest(http.MethodGet, u, nil) if err != nil { return nil, err } req.Header.Set("Authorization", "Bearer "+token) ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) defer cancel() resp, err := httpclient.DoWithRetry(ctx, httpclient.New(60*time.Second), req, 3) if err != nil { return nil, err } defer func() { _ = resp.Body.Close() }() b, _ := io.ReadAll(resp.Body) if resp.StatusCode >= 300 { return nil, fmt.Errorf("dbcli: GET %s: %s: %s", path, resp.Status, strings.TrimSpace(string(b))) } return b, nil } func apiPOST(base, token, path string, payload map[string]any) ([]byte, error) { b, err := json.Marshal(payload) if err != nil { return nil, err } u := strings.TrimRight(base, "/") + path req, err := http.NewRequest(http.MethodPost, u, bytes.NewReader(b)) if err != nil { return nil, err } req.Header.Set("Authorization", "Bearer "+token) req.Header.Set("Content-Type", "application/json") ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) defer cancel() resp, err := httpclient.DoWithRetry(ctx, httpclient.New(60*time.Second), req, 3) if err != nil { return nil, err } defer func() { _ = resp.Body.Close() }() out, _ := io.ReadAll(resp.Body) if resp.StatusCode != http.StatusAccepted && resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("dbcli: POST %s: %s: %s", path, resp.Status, strings.TrimSpace(string(out))) } return out, nil } func writeJSONStdout(v any, format string) int { if format != "json" { fmt.Fprintln(os.Stderr, "only json format supported") return 2 } enc := json.NewEncoder(os.Stdout) enc.SetIndent("", " ") if err := enc.Encode(v); err != nil { fmt.Fprintln(os.Stderr, err) return 1 } return 0 } func writeRawJSON(b []byte) int { var v any if err := json.Unmarshal(b, &v); err != nil { _, _ = os.Stdout.Write(b) return 0 } return writeJSONStdout(v, "json") }