serve.go

v1.5.0
Doc Versions Source
1
package main
2
3
import (
4
	"context"
5
	"fmt"
6
	"log"
7
	"net/http"
8
	"time"
9
10
	"github.com/prometheus/client_golang/prometheus/promhttp"
11
	"github.com/spf13/cobra"
12
	"golang.org/x/mod/sumdb"
13
14
	"go.bigb.es/curator/internal/admin"
15
	"go.bigb.es/curator/internal/config"
16
	"go.bigb.es/curator/internal/dochtml"
17
	"go.bigb.es/curator/internal/git"
18
	"go.bigb.es/curator/internal/mcpserver"
19
	"go.bigb.es/curator/internal/metrics"
20
	"go.bigb.es/curator/internal/oidcauth"
21
	"go.bigb.es/curator/internal/server"
22
	"go.bigb.es/curator/internal/storage"
23
	"go.bigb.es/curator/internal/store"
24
	isumdb "go.bigb.es/curator/internal/sumdb"
25
)
26
27
func serveCmd() *cobra.Command {
28
	var configPath string
29
30
	cmd := &cobra.Command{
31
		Use:   "serve",
32
		Short: "Start the curator HTTP server",
33
		RunE: func(cmd *cobra.Command, args []string) error {
34
			return runServe(configPath)
35
		},
36
	}
37
38
	cmd.Flags().StringVarP(&configPath, "config", "c", "", "path to config file (optional, env vars always apply)")
39
40
	return cmd
41
}
42
43
func runServe(configPath string) error {
44
	cfg, err := config.Load(configPath)
45
	if err != nil {
46
		return err
47
	}
48
49
	log.Printf("configuration:\n%s", cfg.Dump())
50
51
	// Open module store.
52
	moduleStore, err := store.Open(cfg.Database.Type, cfg.Database.DSN)
53
	if err != nil {
54
		return fmt.Errorf("open store: %w", err)
55
	}
56
	defer moduleStore.Close()
57
58
	docRenderer, err := dochtml.NewRenderer()
59
	if err != nil {
60
		return fmt.Errorf("init doc renderer: %w", err)
61
	}
62
63
	srv := &server.Server{
64
		Cfg:          cfg,
65
		Resolver:     moduleStore,
66
		Credentials:  moduleStore,
67
		ModuleLister: moduleStore,
68
		Git:          git.NewCache(cfg.Cache),
69
		HTTPClient:   &http.Client{Timeout: 30 * time.Second},
70
		DocRenderer:  docRenderer,
71
	}
72
73
	// Load auth tokens if configured.
74
	if cfg.AuthTokens != "" {
75
		tokens, err := server.ParseAuthTokens(cfg.AuthTokens)
76
		if err != nil {
77
			return fmt.Errorf("load auth tokens: %w", err)
78
		}
79
80
		srv.AuthTokens = tokens
81
		log.Printf("loaded %d auth tokens", len(tokens))
82
	}
83
84
	// Initialize storage if configured.
85
	if cfg.Storage != nil {
86
		st, err := initStorage(context.Background(), cfg.Storage)
87
		if err != nil {
88
			return fmt.Errorf("init storage: %w", err)
89
		}
90
91
		srv.Store = st
92
		storageType := cfg.Storage.Type
93
		if storageType == "" {
94
			storageType = "s3"
95
		}
96
		log.Printf("storage enabled: type=%s", storageType)
97
	}
98
99
	// Initialize sumdb if configured.
100
	var sumdbOps *isumdb.Ops
101
102
	if cfg.Sumdb != nil && cfg.Sumdb.Enabled {
103
		signer, err := config.ResolveValue(cfg.Sumdb.Key)
104
		if err != nil {
105
			return fmt.Errorf("read sumdb key: %w", err)
106
		}
107
		sumdbOps = isumdb.NewOps(cfg.Host, moduleStore, moduleStore, srv.Git, moduleStore, signer)
108
		log.Printf("sumdb enabled with %d records", sumdbOps.RecordCount())
109
	}
110
111
	// Metrics.
112
	metrics.Register()
113
114
	ctx, cancel := context.WithCancel(context.Background())
115
	defer cancel()
116
117
	var bucketStats metrics.BucketStatsFunc
118
	if srv.Store != nil {
119
		if sp, ok := srv.Store.(storage.StatsProvider); ok {
120
			bucketStats = sp.BucketStats
121
		}
122
	}
123
124
	var recordCount metrics.RecordCountFunc
125
	if sumdbOps != nil {
126
		recordCount = sumdbOps.RecordCount
127
	}
128
129
	moduleCount := func() int { return moduleStore.ModuleCount() }
130
	metrics.StartCollector(ctx, cfg.Cache, moduleCount, bucketStats, recordCount, 30*time.Second)
131
132
	// Instrumentation server (metrics + health) on a separate port.
133
	if inst := cfg.Instrumentation; inst != nil && inst.Listen != "" {
134
		instrMux := http.NewServeMux()
135
136
		metricsPath := inst.MetricsPath
137
		if metricsPath == "" {
138
			metricsPath = "/metrics"
139
		}
140
		instrMux.Handle(metricsPath, promhttp.Handler())
141
142
		healthPath := inst.HealthPath
143
		if healthPath == "" {
144
			healthPath = "/healthz"
145
		}
146
		instrMux.HandleFunc(healthPath, healthHandler)
147
148
		go func() {
149
			log.Printf("instrumentation serving on %s (metrics: %s, health: %s)",
150
				inst.Listen, metricsPath, healthPath)
151
			if err := http.ListenAndServe(inst.Listen, instrMux); err != nil {
152
				log.Fatalf("instrumentation server: %v", err)
153
			}
154
		}()
155
	}
156
157
	// Main HTTP mux.
158
	mux := http.NewServeMux()
159
	mux.Handle("/-/static/", http.StripPrefix("/-/static/", http.FileServer(dochtml.StaticFS())))
160
	mux.HandleFunc("/-/logout", srv.ServeLogout)
161
162
	// Admin UI and API.
163
	adminHandler, err := admin.New(moduleStore, version)
164
	if err != nil {
165
		return fmt.Errorf("init admin: %w", err)
166
	}
167
168
	apiHandler := admin.NewAPI(moduleStore)
169
170
	adminToken := cfg.AdminToken
171
	if adminToken != "" {
172
		parsed, err := server.ParseAdminToken(adminToken)
173
		if err != nil {
174
			return fmt.Errorf("parse admin token: %w", err)
175
		}
176
		adminToken = parsed
177
	}
178
179
	// Initialize OIDC if configured.
180
	var oidcProvider *oidcauth.Provider
181
	if cfg.OIDC != nil && cfg.OIDC.Issuer != "" {
182
		secret, err := config.ResolveValue(cfg.OIDC.ClientSecret)
183
		if err != nil {
184
			return fmt.Errorf("resolve oidc client_secret: %w", err)
185
		}
186
		oidcProvider, err = oidcauth.New(context.Background(), cfg.OIDC, secret)
187
		if err != nil {
188
			return fmt.Errorf("init oidc: %w", err)
189
		}
190
		mux.HandleFunc("/-/oidc/login", oidcProvider.LoginHandler)
191
		mux.HandleFunc("/-/oidc/callback", oidcProvider.CallbackHandler)
192
		mux.HandleFunc("/-/oidc/logout", oidcProvider.LogoutHandler)
193
		srv.OIDCProvider = oidcProvider
194
		log.Printf("OIDC enabled: issuer=%s client_id=%s", cfg.OIDC.Issuer, cfg.OIDC.ClientID)
195
	}
196
197
	mux.Handle("/-/admin/", admin.AuthMiddleware(adminToken, oidcProvider, adminHandler))
198
	mux.Handle("/-/api/", admin.AuthMiddleware(adminToken, oidcProvider, apiHandler))
199
200
	// MCP server for agent-driven module management, behind the same admin auth.
201
	mcpHandler := mcpserver.HTTPHandler(moduleStore, version)
202
	mux.Handle("/-/mcp", admin.AuthMiddleware(adminToken, oidcProvider, mcpHandler))
203
	mux.Handle("/-/mcp/", admin.AuthMiddleware(adminToken, oidcProvider, mcpHandler))
204
	log.Printf("MCP server serving at /-/mcp")
205
206
	if sumdbOps != nil {
207
		sumdbSrv := sumdb.NewServer(sumdbOps)
208
		prefix := "/sumdb/" + cfg.Host
209
210
		var upstream string
211
		if cfg.Sumdb.Upstream != "" {
212
			upstream = cfg.Sumdb.Upstream
213
			log.Printf("sumdb upstream proxy: %s", upstream)
214
		}
215
216
		handler := srv.SumdbHandler(
217
			http.StripPrefix(prefix, sumdbSrv),
218
			upstream, prefix,
219
		)
220
		mux.Handle(prefix+"/", srv.SumdbAuthMiddleware(handler))
221
		log.Printf("sumdb serving at %s/", prefix)
222
	}
223
224
	mux.Handle("/", srv)
225
226
	log.Printf("curator serving on %s (host: %s, db: %s/%s, modules: %d)",
227
		cfg.Listen, cfg.Host, cfg.Database.Type, cfg.Database.DSN, moduleStore.ModuleCount())
228
229
	return http.ListenAndServe(cfg.Listen, mux)
230
}
231
232
func initStorage(ctx context.Context, cfg *config.StorageConfig) (storage.Storage, error) {
233
	switch cfg.Type {
234
	case "s3", "":
235
		return storage.NewS3Storage(ctx, cfg.S3())
236
	case "disk":
237
		return storage.NewDiskStorage(cfg.Root)
238
	case "memory":
239
		return storage.NewMemoryStorage(cfg.MaxBytes), nil
240
	default:
241
		return nil, fmt.Errorf("unknown storage type: %q", cfg.Type)
242
	}
243
}
244
245
func healthHandler(w http.ResponseWriter, r *http.Request) {
246
	w.Header().Set("Content-Type", "application/json")
247
	w.Write([]byte(`{"status":"ok"}` + "\n"))
248
}
249

Source Files