package cli import ( "context" "database/sql" "errors" "fmt" "net/http" "os" "os/signal" "sync" "syscall" "time" "github.com/spf13/cobra" "git.cloudinit.dev/coreci/orca/internal/store" "git.cloudinit.dev/coreci/orca/internal/transport" ) var metricsAddr string var metricsCmd = &cobra.Command{ Use: "metrics", Short: "Run the orca metrics endpoint (Prometheus text exposition)", Long: `Start a standalone HTTP server exposing Prometheus text-exposition metrics at /metrics and a liveness probe at /healthz. Designed to run on the CLI host or a designated metrics host; polls cluster state periodically and updates gauges. No orca daemon required (R-001).`, RunE: func(cmd *cobra.Command, args []string) error { log := newLogger() m := transport.NewMetrics() db, closer, err := openDB() if err != nil { log.Warn("metrics: open db failed, gauges will stay 0", "err", err) db = nil closer = func() error { return nil } } defer closer() var pollWG sync.WaitGroup pollCtx, pollCancel := context.WithCancel(cmd.Context()) defer func() { pollCancel() pollWG.Wait() }() pollWG.Add(1) go func() { defer pollWG.Done() pollLoop(pollCtx, m, db, log) }() mux := http.NewServeMux() mux.HandleFunc("/metrics", func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/plain; version=0.0.4; charset=utf-8") if err := m.WritePrometheus(w); err != nil { log.Warn("metrics: write exposition failed", "err", err) } }) mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte("ok\n")) }) srv := &http.Server{ Addr: metricsAddr, Handler: mux, } errCh := make(chan error, 1) go func() { err := srv.ListenAndServe() if err != nil && !errors.Is(err, http.ErrServerClosed) { errCh <- err } }() fmt.Fprintf(cmd.OutOrStdout(), "✓ orca metrics listening on %s\n", metricsAddr) fmt.Fprintln(cmd.OutOrStdout(), " /metrics - Prometheus text exposition") fmt.Fprintln(cmd.OutOrStdout(), " /healthz - liveness probe") fmt.Fprintln(cmd.OutOrStdout(), " press Ctrl+C to stop") ctx, stop := signal.NotifyContext(cmd.Context(), os.Interrupt, syscall.SIGTERM) defer stop() select { case <-ctx.Done(): fmt.Fprintln(cmd.OutOrStdout(), "\nshutting down...") shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() return srv.Shutdown(shutdownCtx) case err := <-errCh: return fmt.Errorf("metrics server: %w", err) } }, } func pollLoop(ctx context.Context, m *transport.Metrics, db *sql.DB, log interface{ Warn(string, ...any) }) { if db == nil { return } ticker := time.NewTicker(15 * time.Second) defer ticker.Stop() refresh(ctx, m, db, log) for { select { case <-ctx.Done(): return case <-ticker.C: refresh(ctx, m, db, log) } } } func refresh(ctx context.Context, m *transport.Metrics, db *sql.DB, log interface{ Warn(string, ...any) }) { if nodes, err := store.NewNodeRepo(db).List(ctx); err != nil { log.Warn("metrics: node list failed", "err", err) } else { m.SetGauge("nodes_total", float64(len(nodes))) } if jobs, err := store.NewJobRepo(db).List(ctx); err != nil { log.Warn("metrics: job list failed", "err", err) } else { m.SetGauge("allocs_total", float64(len(jobs))) } } func init() { metricsCmd.Flags().StringVar(&metricsAddr, "addr", ":9100", "listen address for the metrics HTTP server") rootCmd.AddCommand(metricsCmd) }