1
0
Fork 1
mirror of https://github.com/git-pkgs/proxy.git synced 2026-06-02 16:48:16 -04:00
pkg-proxy/cmd/proxy/main.go

691 lines
20 KiB
Go
Raw Permalink Normal View History

2026-01-20 21:52:44 +00:00
// Command proxy runs the git-pkgs package registry proxy server.
//
2026-03-11 17:18:29 +00:00
// @title git-pkgs proxy API
// @version 0.1.0
// @description HTTP API for package enrichment, vulnerability lookup, cache stats, and source browsing.
// @BasePath /
//
2026-01-20 21:52:44 +00:00
// The proxy caches package artifacts from upstream registries (npm, cargo, etc.)
// providing faster, more reliable access for development teams.
//
// Usage:
//
// proxy [command] [flags]
//
// Commands:
//
// serve Start the proxy server (default if no command given)
// stats Show cache statistics
// mirror Pre-populate cache from PURLs, SBOMs, or registries
2026-01-20 21:52:44 +00:00
//
// Serve Flags:
//
// -config string
// Path to configuration file (YAML or JSON)
// -listen string
// Address to listen on (default ":8080")
// -base-url string
// Public URL of this proxy (default "http://localhost:8080")
// -storage-url string
// Storage URL (file:// or s3://)
// -storage-path string
// Path to artifact storage directory (deprecated, use -storage-url)
// -database-driver string
// Database driver: sqlite or postgres (default "sqlite")
// -database-path string
2026-01-20 21:52:44 +00:00
// Path to SQLite database file (default "./cache/proxy.db")
// -database-url string
// PostgreSQL connection URL
2026-01-20 21:52:44 +00:00
// -log-level string
// Log level: debug, info, warn, error (default "info")
// -log-format string
// Log format: text, json (default "text")
//
// Stats Flags:
//
// -database-driver string
// Database driver: sqlite or postgres (default "sqlite")
// -database-path string
2026-01-20 21:52:44 +00:00
// Path to SQLite database file (default "./cache/proxy.db")
// -database-url string
// PostgreSQL connection URL
2026-01-20 21:52:44 +00:00
// -json
// Output as JSON
// -popular int
// Show top N most popular packages (default 10)
// -recent int
// Show N recently cached packages (default 10)
//
// Global Flags:
//
// -version
// Print version and exit
//
// Environment Variables:
//
// PROXY_LISTEN - Listen address
// PROXY_BASE_URL - Public URL
// PROXY_STORAGE_URL - Storage URL (file:// or s3://)
// PROXY_STORAGE_PATH - Storage directory (deprecated)
// PROXY_DATABASE_DRIVER - Database driver (sqlite or postgres)
// PROXY_DATABASE_PATH - SQLite database file path
// PROXY_DATABASE_URL - PostgreSQL connection URL
// PROXY_LOG_LEVEL - Log level
// PROXY_LOG_FORMAT - Log format
// PROXY_UPSTREAM_MAVEN - Maven repository upstream URL
// PROXY_UPSTREAM_GRADLE_PLUGIN_PORTAL - Gradle Plugin Portal upstream URL
// PROXY_GRADLE_BUILD_CACHE_READ_ONLY - Disable Gradle PUT uploads
// PROXY_GRADLE_BUILD_CACHE_MAX_UPLOAD_SIZE - Max Gradle PUT request body size
// PROXY_GRADLE_BUILD_CACHE_MAX_AGE - Gradle cache max age eviction
// PROXY_GRADLE_BUILD_CACHE_MAX_SIZE - Gradle cache max total size
// PROXY_GRADLE_BUILD_CACHE_SWEEP_INTERVAL - Gradle cache eviction sweep interval
Add storage backend probe to /health (closes #73) (#119) * config: add Health.StorageProbeInterval * metrics: add proxy_health_probe_failures_total counter * server: add storageProbe with happy-path test * server: add storageProbe failure-mode tests * server: add healthCache with TTL, single-flight, transition logging * server: wire storage probe into /health * server: update TestHealthEndpoint for JSON; wire healthCache into newTestServer Also fix Windows file-locking issue in storageProbe: close the reader explicitly before Delete so the file handle is released prior to os.Remove. * server: clean up stale comment in storageProbe * docs: document storage health probe and new metric * docs: regenerate Swagger for /health JSON response * server: simplify rc.Close error handling in storageProbe * server: defer probe cleanup so size/open/read/verify failures don't leak objects Previously, storageProbe only called Delete on the success path. Any failure between Store and the final Delete (size mismatch, Open error, mid-stream read failure, content mismatch) left the probe object orphaned in the storage backend. With caching disabled and Kubernetes-rate probing, the leak could accumulate noticeably on backends like S3. Use a named return + defer to attempt Delete after every successful Store. The earlier-step failure remains the primary error; Delete failure only surfaces as step="delete" when nothing else went wrong. Add a table-driven test that asserts cleanup runs for each non-delete failure path. Reported by Copilot on #119. * config: validate health.storage_probe_interval in Config.Validate The new duration field was only validated at use time in newHealthCache. The existing codebase already validates other duration fields (MetadataTTL, DirectServeTTL, Gradle.MaxAge, Gradle.SweepInterval) in Config.Validate() so misconfiguration fails fast at startup with a config-key-specific error. Match that pattern. The parse-at-use code in newHealthCache stays as a safety net, mirroring the MetadataTTL precedent. Reported by Copilot on #119. * docs: lowercase "counter" in metrics table for consistency Other rows in the table use lowercase type names (counter/gauge/histogram). Match that style. Reported by Copilot on #119. * docs: include size-check step in /health probe description The probe is write → size-check → read → verify → delete; the architecture note was missing the size-check step. Reported by Copilot on #119. * server: address andrew's review on #119 - Drop unused callerCtx parameter from healthCache.Check (Check is now parameter-less; the comment-only "accepted for symmetry" justification wasn't carrying its weight). - Emit "storage": {"status": "skipped"} on DB short-circuit instead of omitting the key, so monitors expecting a fixed key set keep working. - Reject negative storage_probe_interval at config validation time (previously parsed and silently behaved like "0"). - Extract HealthConfig.Validate to keep Config.Validate under the gocognit threshold and match the existing GradleBuildCacheConfig pattern. - README Health Check section: note that /health is intended as a readiness probe rather than a liveness probe (Check holds a mutex for up to the 10s probe timeout). - cmd/proxy/main.go godoc: column-align the new env var with the surrounding Gradle entries. Reported by andrew on #119.
2026-05-22 14:14:01 +03:00
// PROXY_HEALTH_STORAGE_PROBE_INTERVAL - Storage health probe cache interval (default "30s")
2026-01-20 21:52:44 +00:00
//
// Example:
//
// # Start with defaults
// proxy
//
// # Start with custom settings
// proxy serve -listen :3000 -base-url https://proxy.example.com
//
// # Show cache statistics
// proxy stats
//
// # Show stats as JSON
// proxy stats -json
package main
import (
"context"
"encoding/json"
"flag"
"fmt"
"log/slog"
"os"
"os/signal"
"strings"
"syscall"
"github.com/git-pkgs/proxy/internal/config"
"github.com/git-pkgs/proxy/internal/database"
"github.com/git-pkgs/proxy/internal/handler"
"github.com/git-pkgs/proxy/internal/mirror"
2026-01-20 21:52:44 +00:00
"github.com/git-pkgs/proxy/internal/server"
"github.com/git-pkgs/proxy/internal/storage"
"github.com/git-pkgs/registries/fetch"
2026-01-20 21:52:44 +00:00
)
const defaultTopN = 10
2026-01-20 21:52:44 +00:00
var (
// Version is set at build time.
Version = "dev"
// Commit is set at build time.
Commit = "unknown"
)
func main() {
if len(os.Args) > 1 {
switch os.Args[1] {
case "serve":
os.Args = append(os.Args[:1], os.Args[2:]...)
runServe()
return
case "stats":
os.Args = append(os.Args[:1], os.Args[2:]...)
runStats()
return
case "mirror":
os.Args = append(os.Args[:1], os.Args[2:]...)
runMirror()
return
2026-01-20 21:52:44 +00:00
case "-version", "--version":
fmt.Printf("proxy %s (%s)\n", Version, Commit)
os.Exit(0)
case "-h", "-help", "--help":
printUsage()
os.Exit(0)
}
}
// Default to serve
runServe()
}
func printUsage() {
fmt.Fprintf(os.Stderr, `git-pkgs proxy - Package registry caching proxy
Usage: proxy [command] [flags]
Commands:
serve Start the proxy server (default)
stats Show cache statistics
mirror Pre-populate cache from PURLs, SBOMs, or registries
2026-01-20 21:52:44 +00:00
Run 'proxy <command> -help' for more information on a command.
Global Flags:
-version Print version and exit
-help Show this help message
`)
}
func runServe() {
fs := flag.NewFlagSet("serve", flag.ExitOnError)
configPath := fs.String("config", "", "Path to configuration file (YAML or JSON)")
listen := fs.String("listen", "", "Address to listen on")
baseURL := fs.String("base-url", "", "Public URL of this proxy")
storageURL := fs.String("storage-url", "", "Storage URL (file:// or s3://)")
storagePath := fs.String("storage-path", "", "Path to artifact storage directory (deprecated, use -storage-url)")
databaseDriver := fs.String("database-driver", "", "Database driver: sqlite or postgres")
databasePath := fs.String("database-path", "", "Path to SQLite database file")
databaseURL := fs.String("database-url", "", "PostgreSQL connection URL")
2026-01-20 21:52:44 +00:00
logLevel := fs.String("log-level", "", "Log level: debug, info, warn, error")
logFormat := fs.String("log-format", "", "Log format: text, json")
version := fs.Bool("version", false, "Print version and exit")
fs.Usage = func() {
fmt.Fprintf(os.Stderr, "git-pkgs proxy - Package registry caching proxy\n\n")
fmt.Fprintf(os.Stderr, "Usage: proxy serve [flags]\n\n")
fmt.Fprintf(os.Stderr, "Flags:\n")
fs.PrintDefaults()
fmt.Fprintf(os.Stderr, "\nEnvironment Variables:\n")
fmt.Fprintf(os.Stderr, " PROXY_LISTEN Listen address\n")
fmt.Fprintf(os.Stderr, " PROXY_BASE_URL Public URL\n")
fmt.Fprintf(os.Stderr, " PROXY_STORAGE_URL Storage URL (file:// or s3://)\n")
fmt.Fprintf(os.Stderr, " PROXY_STORAGE_PATH Storage directory (deprecated)\n")
fmt.Fprintf(os.Stderr, " PROXY_DATABASE_DRIVER Database driver (sqlite or postgres)\n")
fmt.Fprintf(os.Stderr, " PROXY_DATABASE_PATH SQLite database file\n")
fmt.Fprintf(os.Stderr, " PROXY_DATABASE_URL PostgreSQL connection URL\n")
fmt.Fprintf(os.Stderr, " PROXY_LOG_LEVEL Log level\n")
fmt.Fprintf(os.Stderr, " PROXY_LOG_FORMAT Log format\n")
fmt.Fprintf(os.Stderr, " PROXY_UPSTREAM_MAVEN Maven repository upstream URL\n")
fmt.Fprintf(os.Stderr, " PROXY_UPSTREAM_GRADLE_PLUGIN_PORTAL Gradle Plugin Portal upstream URL\n")
fmt.Fprintf(os.Stderr, " PROXY_GRADLE_BUILD_CACHE_READ_ONLY Disable Gradle PUT uploads\n")
fmt.Fprintf(os.Stderr, " PROXY_GRADLE_BUILD_CACHE_MAX_UPLOAD_SIZE Max Gradle PUT request body size\n")
fmt.Fprintf(os.Stderr, " PROXY_GRADLE_BUILD_CACHE_MAX_AGE Gradle cache max age eviction\n")
fmt.Fprintf(os.Stderr, " PROXY_GRADLE_BUILD_CACHE_MAX_SIZE Gradle cache max total size\n")
fmt.Fprintf(os.Stderr, " PROXY_GRADLE_BUILD_CACHE_SWEEP_INTERVAL Gradle cache eviction sweep interval\n")
Add storage backend probe to /health (closes #73) (#119) * config: add Health.StorageProbeInterval * metrics: add proxy_health_probe_failures_total counter * server: add storageProbe with happy-path test * server: add storageProbe failure-mode tests * server: add healthCache with TTL, single-flight, transition logging * server: wire storage probe into /health * server: update TestHealthEndpoint for JSON; wire healthCache into newTestServer Also fix Windows file-locking issue in storageProbe: close the reader explicitly before Delete so the file handle is released prior to os.Remove. * server: clean up stale comment in storageProbe * docs: document storage health probe and new metric * docs: regenerate Swagger for /health JSON response * server: simplify rc.Close error handling in storageProbe * server: defer probe cleanup so size/open/read/verify failures don't leak objects Previously, storageProbe only called Delete on the success path. Any failure between Store and the final Delete (size mismatch, Open error, mid-stream read failure, content mismatch) left the probe object orphaned in the storage backend. With caching disabled and Kubernetes-rate probing, the leak could accumulate noticeably on backends like S3. Use a named return + defer to attempt Delete after every successful Store. The earlier-step failure remains the primary error; Delete failure only surfaces as step="delete" when nothing else went wrong. Add a table-driven test that asserts cleanup runs for each non-delete failure path. Reported by Copilot on #119. * config: validate health.storage_probe_interval in Config.Validate The new duration field was only validated at use time in newHealthCache. The existing codebase already validates other duration fields (MetadataTTL, DirectServeTTL, Gradle.MaxAge, Gradle.SweepInterval) in Config.Validate() so misconfiguration fails fast at startup with a config-key-specific error. Match that pattern. The parse-at-use code in newHealthCache stays as a safety net, mirroring the MetadataTTL precedent. Reported by Copilot on #119. * docs: lowercase "counter" in metrics table for consistency Other rows in the table use lowercase type names (counter/gauge/histogram). Match that style. Reported by Copilot on #119. * docs: include size-check step in /health probe description The probe is write → size-check → read → verify → delete; the architecture note was missing the size-check step. Reported by Copilot on #119. * server: address andrew's review on #119 - Drop unused callerCtx parameter from healthCache.Check (Check is now parameter-less; the comment-only "accepted for symmetry" justification wasn't carrying its weight). - Emit "storage": {"status": "skipped"} on DB short-circuit instead of omitting the key, so monitors expecting a fixed key set keep working. - Reject negative storage_probe_interval at config validation time (previously parsed and silently behaved like "0"). - Extract HealthConfig.Validate to keep Config.Validate under the gocognit threshold and match the existing GradleBuildCacheConfig pattern. - README Health Check section: note that /health is intended as a readiness probe rather than a liveness probe (Check holds a mutex for up to the 10s probe timeout). - cmd/proxy/main.go godoc: column-align the new env var with the surrounding Gradle entries. Reported by andrew on #119.
2026-05-22 14:14:01 +03:00
fmt.Fprintf(os.Stderr, " PROXY_HEALTH_STORAGE_PROBE_INTERVAL Storage health probe cache interval\n")
2026-01-20 21:52:44 +00:00
}
_ = fs.Parse(os.Args[1:])
if *version {
fmt.Printf("proxy %s (%s)\n", Version, Commit)
os.Exit(0)
}
// Load configuration
cfg, err := loadConfig(*configPath)
if err != nil {
fmt.Fprintf(os.Stderr, "error loading config: %v\n", err)
os.Exit(1)
}
// Apply environment variables
cfg.LoadFromEnv()
// Apply command line flags (highest priority)
if *listen != "" {
cfg.Listen = *listen
}
if *baseURL != "" {
cfg.BaseURL = *baseURL
}
if *storageURL != "" {
cfg.Storage.URL = *storageURL
}
2026-01-20 21:52:44 +00:00
if *storagePath != "" {
cfg.Storage.Path = *storagePath //nolint:staticcheck // backwards compat
2026-01-20 21:52:44 +00:00
}
if *databaseDriver != "" {
cfg.Database.Driver = *databaseDriver
}
2026-01-20 21:52:44 +00:00
if *databasePath != "" {
cfg.Database.Path = *databasePath
}
if *databaseURL != "" {
cfg.Database.URL = *databaseURL
}
2026-01-20 21:52:44 +00:00
if *logLevel != "" {
cfg.Log.Level = *logLevel
}
if *logFormat != "" {
cfg.Log.Format = *logFormat
}
// Validate configuration
if err := cfg.Validate(); err != nil {
fmt.Fprintf(os.Stderr, "invalid configuration: %v\n", err)
os.Exit(1)
}
// Setup logger
logger := setupLogger(cfg.Log.Level, cfg.Log.Format)
// Create and start server
srv, err := server.New(cfg, logger)
if err != nil {
logger.Error("failed to create server", "error", err)
os.Exit(1)
}
// Handle shutdown signals
ctx, cancel := context.WithCancel(context.Background())
go func() {
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
<-sigCh
logger.Info("received shutdown signal")
cancel()
}()
// Start server in goroutine
errCh := make(chan error, 1)
go func() {
errCh <- srv.Start()
}()
// Wait for shutdown or error
select {
case <-ctx.Done():
cancel()
2026-01-20 21:52:44 +00:00
if err := srv.Shutdown(context.Background()); err != nil {
logger.Error("shutdown error", "error", err)
}
case err := <-errCh:
cancel()
2026-01-20 21:52:44 +00:00
if err != nil {
logger.Error("server error", "error", err)
os.Exit(1)
}
}
}
func runStats() {
fs := flag.NewFlagSet("stats", flag.ExitOnError)
databaseDriver := fs.String("database-driver", "sqlite", "Database driver: sqlite or postgres")
databasePath := fs.String("database-path", "./cache/proxy.db", "Path to SQLite database file")
databaseURL := fs.String("database-url", "", "PostgreSQL connection URL")
2026-01-20 21:52:44 +00:00
asJSON := fs.Bool("json", false, "Output as JSON")
popular := fs.Int("popular", defaultTopN, "Show top N most popular packages")
recent := fs.Int("recent", defaultTopN, "Show N recently cached packages")
2026-01-20 21:52:44 +00:00
fs.Usage = func() {
fmt.Fprintf(os.Stderr, "git-pkgs proxy - Show cache statistics\n\n")
fmt.Fprintf(os.Stderr, "Usage: proxy stats [flags]\n\n")
fmt.Fprintf(os.Stderr, "Flags:\n")
fs.PrintDefaults()
}
_ = fs.Parse(os.Args[1:])
// Apply environment overrides
if v := os.Getenv("PROXY_DATABASE_DRIVER"); v != "" {
*databaseDriver = v
}
if v := os.Getenv("PROXY_DATABASE_PATH"); v != "" {
*databasePath = v
}
if v := os.Getenv("PROXY_DATABASE_URL"); v != "" {
*databaseURL = v
2026-01-20 21:52:44 +00:00
}
// Open database
var db *database.DB
var err error
switch *databaseDriver {
case "postgres":
if *databaseURL == "" {
fmt.Fprintf(os.Stderr, "database-url is required for postgres driver\n")
os.Exit(1)
}
db, err = database.OpenPostgres(*databaseURL)
default:
if _, statErr := os.Stat(*databasePath); os.IsNotExist(statErr) {
fmt.Fprintf(os.Stderr, "database not found: %s\n", *databasePath)
fmt.Fprintf(os.Stderr, "run 'proxy serve' first to create the database\n")
os.Exit(1)
}
db, err = database.Open(*databasePath)
}
2026-01-20 21:52:44 +00:00
if err != nil {
fmt.Fprintf(os.Stderr, "error opening database: %v\n", err)
os.Exit(1)
}
if err := printStats(db, *popular, *recent, *asJSON); err != nil {
fmt.Fprintf(os.Stderr, "%v\n", err)
os.Exit(1)
}
}
func runMirror() {
fs := flag.NewFlagSet("mirror", flag.ExitOnError)
configPath := fs.String("config", "", "Path to configuration file")
storageURL := fs.String("storage-url", "", "Storage URL (file:// or s3://)")
databaseDriver := fs.String("database-driver", "", "Database driver: sqlite or postgres")
databasePath := fs.String("database-path", "", "Path to SQLite database file")
databaseURL := fs.String("database-url", "", "PostgreSQL connection URL")
sbomPath := fs.String("sbom", "", "Path to CycloneDX or SPDX SBOM file")
concurrency := fs.Int("concurrency", 4, "Number of parallel downloads") //nolint:mnd // default concurrency
dryRun := fs.Bool("dry-run", false, "Show what would be mirrored without downloading")
fs.Usage = func() {
fmt.Fprintf(os.Stderr, "git-pkgs proxy - Pre-populate cache\n\n")
fmt.Fprintf(os.Stderr, "Usage: proxy mirror [flags] [purl...]\n\n")
fmt.Fprintf(os.Stderr, "Examples:\n")
fmt.Fprintf(os.Stderr, " proxy mirror pkg:npm/lodash@4.17.21\n")
fmt.Fprintf(os.Stderr, " proxy mirror --sbom sbom.cdx.json\n")
fmt.Fprintf(os.Stderr, " proxy mirror pkg:npm/lodash # all versions\n\n")
fmt.Fprintf(os.Stderr, "Flags:\n")
fs.PrintDefaults()
}
_ = fs.Parse(os.Args[1:])
purls := fs.Args()
// Determine source
var source mirror.Source
switch {
case *sbomPath != "":
source = &mirror.SBOMSource{Path: *sbomPath}
case len(purls) > 0:
source = &mirror.PURLSource{PURLs: purls}
default:
fmt.Fprintf(os.Stderr, "error: provide PURLs or --sbom\n")
fs.Usage()
os.Exit(1)
}
// Load config
cfg, err := loadConfig(*configPath)
if err != nil {
fmt.Fprintf(os.Stderr, "error loading config: %v\n", err)
os.Exit(1)
}
cfg.LoadFromEnv()
if *storageURL != "" {
cfg.Storage.URL = *storageURL
}
if *databaseDriver != "" {
cfg.Database.Driver = *databaseDriver
}
if *databasePath != "" {
cfg.Database.Path = *databasePath
}
if *databaseURL != "" {
cfg.Database.URL = *databaseURL
}
if err := cfg.Validate(); err != nil {
fmt.Fprintf(os.Stderr, "invalid configuration: %v\n", err)
os.Exit(1)
}
logger := setupLogger("info", "text")
// Open database
var db *database.DB
switch cfg.Database.Driver {
case "postgres":
db, err = database.OpenPostgresOrCreate(cfg.Database.URL)
default:
db, err = database.OpenOrCreate(cfg.Database.Path)
}
if err != nil {
fmt.Fprintf(os.Stderr, "error opening database: %v\n", err)
os.Exit(1)
}
defer func() { _ = db.Close() }()
if err := db.MigrateSchema(); err != nil {
_ = db.Close()
fmt.Fprintf(os.Stderr, "error migrating schema: %v\n", err)
os.Exit(1) //nolint:gocritic // db closed above
}
// Open storage
sURL := cfg.Storage.URL
if sURL == "" {
sURL = "file://" + cfg.Storage.Path //nolint:staticcheck // backwards compat
}
store, err := storage.OpenBucket(context.Background(), sURL)
if err != nil {
_ = db.Close()
fmt.Fprintf(os.Stderr, "error opening storage: %v\n", err)
os.Exit(1) //nolint:gocritic // db closed above
}
// Build proxy (reuses same pipeline as serve)
fetcher := fetch.NewFetcher()
resolver := fetch.NewResolver()
proxy := handler.NewProxy(db, store, fetcher, resolver, logger)
proxy.CacheMetadata = true // mirror always caches metadata
proxy.MetadataTTL = cfg.ParseMetadataTTL()
m := mirror.New(proxy, db, store, logger, *concurrency)
ctx, cancel := context.WithCancel(context.Background())
go func() {
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
<-sigCh
cancel()
}()
if *dryRun {
items, err := m.RunDryRun(ctx, source)
if err != nil {
fmt.Fprintf(os.Stderr, "error: %v\n", err)
os.Exit(1)
}
fmt.Printf("Would mirror %d package versions:\n", len(items))
for _, item := range items {
fmt.Printf(" %s\n", item)
}
return
}
progress, err := m.Run(ctx, source)
if err != nil {
fmt.Fprintf(os.Stderr, "error: %v\n", err)
os.Exit(1)
}
fmt.Printf("Mirror complete: %d downloaded, %d skipped (cached), %d failed, %s total\n",
progress.Completed, progress.Skipped, progress.Failed, formatSize(progress.Bytes))
if len(progress.Errors) > 0 {
fmt.Fprintf(os.Stderr, "\nErrors:\n")
for _, e := range progress.Errors {
fmt.Fprintf(os.Stderr, " %s/%s@%s: %s\n", e.Ecosystem, e.Name, e.Version, e.Error)
}
}
}
func printStats(db *database.DB, popular, recent int, asJSON bool) error {
2026-01-20 21:52:44 +00:00
defer func() { _ = db.Close() }()
stats, err := db.GetCacheStats()
if err != nil {
return fmt.Errorf("error getting stats: %w", err)
2026-01-20 21:52:44 +00:00
}
popularPkgs, err := db.GetMostPopularPackages(popular)
2026-01-20 21:52:44 +00:00
if err != nil {
return fmt.Errorf("error getting popular packages: %w", err)
2026-01-20 21:52:44 +00:00
}
recentPkgs, err := db.GetRecentlyCachedPackages(recent)
2026-01-20 21:52:44 +00:00
if err != nil {
return fmt.Errorf("error getting recent packages: %w", err)
2026-01-20 21:52:44 +00:00
}
if asJSON {
2026-01-20 21:52:44 +00:00
outputJSON(stats, popularPkgs, recentPkgs)
} else {
outputText(stats, popularPkgs, recentPkgs)
}
return nil
2026-01-20 21:52:44 +00:00
}
type jsonOutput struct {
Packages int64 `json:"packages"`
Versions int64 `json:"versions"`
Artifacts int64 `json:"artifacts"`
TotalSize int64 `json:"total_size_bytes"`
TotalHits int64 `json:"total_hits"`
Ecosystems map[string]int64 `json:"ecosystems"`
Popular []jsonPopular `json:"popular"`
Recent []jsonRecent `json:"recent"`
}
type jsonPopular struct {
Ecosystem string `json:"ecosystem"`
Name string `json:"name"`
Hits int64 `json:"hits"`
Size int64 `json:"size_bytes"`
}
type jsonRecent struct {
Ecosystem string `json:"ecosystem"`
Name string `json:"name"`
Version string `json:"version"`
CachedAt string `json:"cached_at"`
Size int64 `json:"size_bytes"`
}
func outputJSON(stats *database.CacheStats, popular []database.PopularPackage, recent []database.RecentPackage) {
out := jsonOutput{
Packages: stats.TotalPackages,
Versions: stats.TotalVersions,
Artifacts: stats.TotalArtifacts,
TotalSize: stats.TotalSize,
TotalHits: stats.TotalHits,
Ecosystems: stats.EcosystemCounts,
Popular: make([]jsonPopular, len(popular)),
Recent: make([]jsonRecent, len(recent)),
}
for i, p := range popular {
out.Popular[i] = jsonPopular{
Ecosystem: p.Ecosystem,
Name: p.Name,
Hits: p.Hits,
Size: p.Size,
}
}
for i, r := range recent {
out.Recent[i] = jsonRecent{
Ecosystem: r.Ecosystem,
Name: r.Name,
Version: r.Version,
CachedAt: r.CachedAt.Format("2006-01-02 15:04:05"),
Size: r.Size,
}
}
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
_ = enc.Encode(out)
}
func outputText(stats *database.CacheStats, popular []database.PopularPackage, recent []database.RecentPackage) {
fmt.Printf("Cache Statistics\n")
fmt.Printf("================\n\n")
fmt.Printf("Packages: %d\n", stats.TotalPackages)
fmt.Printf("Versions: %d\n", stats.TotalVersions)
fmt.Printf("Artifacts: %d\n", stats.TotalArtifacts)
fmt.Printf("Total size: %s\n", formatSize(stats.TotalSize))
fmt.Printf("Total hits: %d\n", stats.TotalHits)
if len(stats.EcosystemCounts) > 0 {
fmt.Printf("\nPackages by ecosystem:\n")
for eco, count := range stats.EcosystemCounts {
fmt.Printf(" %-10s %d\n", eco, count)
}
}
if len(popular) > 0 {
fmt.Printf("\nMost popular packages:\n")
for i, p := range popular {
fmt.Printf(" %2d. %s/%s (%d hits, %s)\n", i+1, p.Ecosystem, p.Name, p.Hits, formatSize(p.Size))
}
}
if len(recent) > 0 {
fmt.Printf("\nRecently cached:\n")
for _, r := range recent {
fmt.Printf(" %s/%s@%s (%s, %s)\n", r.Ecosystem, r.Name, r.Version, r.CachedAt.Format("2006-01-02 15:04"), formatSize(r.Size))
}
}
}
func formatSize(bytes int64) string {
const (
KB = 1024
MB = KB * 1024
GB = MB * 1024
TB = GB * 1024
)
switch {
case bytes >= TB:
return fmt.Sprintf("%.1f TB", float64(bytes)/TB)
case bytes >= GB:
return fmt.Sprintf("%.1f GB", float64(bytes)/GB)
case bytes >= MB:
return fmt.Sprintf("%.1f MB", float64(bytes)/MB)
case bytes >= KB:
return fmt.Sprintf("%.1f KB", float64(bytes)/KB)
default:
return fmt.Sprintf("%d B", bytes)
}
}
func loadConfig(path string) (*config.Config, error) {
if path != "" {
return config.Load(path)
}
return config.Default(), nil
}
func setupLogger(level, format string) *slog.Logger {
var handler slog.Handler
opts := &slog.HandlerOptions{
Level: parseLogLevel(level),
}
switch strings.ToLower(format) {
case "json":
handler = slog.NewJSONHandler(os.Stderr, opts)
default:
handler = slog.NewTextHandler(os.Stderr, opts)
}
return slog.New(handler)
}
func parseLogLevel(level string) slog.Level {
switch strings.ToLower(level) {
case "debug":
return slog.LevelDebug
case "warn":
return slog.LevelWarn
case "error":
return slog.LevelError
default:
return slog.LevelInfo
}
}