Initial implementation of mcq — document reading queue
Single-binary service: push raw markdown via REST/gRPC API, read rendered HTML through mobile-friendly web UI. MCIAS auth on all endpoints, SQLite storage, goldmark rendering with GFM and syntax highlighting. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
23
cmd/mcq/main.go
Normal file
23
cmd/mcq/main.go
Normal file
@@ -0,0 +1,23 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var version = "dev"
|
||||
|
||||
func main() {
|
||||
root := &cobra.Command{
|
||||
Use: "mcq",
|
||||
Short: "Metacircular Document Queue",
|
||||
Version: version,
|
||||
}
|
||||
|
||||
root.AddCommand(serverCmd())
|
||||
|
||||
if err := root.Execute(); err != nil {
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
168
cmd/mcq/server.go
Normal file
168
cmd/mcq/server.go
Normal file
@@ -0,0 +1,168 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
mcdslauth "git.wntrmute.dev/mc/mcdsl/auth"
|
||||
"git.wntrmute.dev/mc/mcdsl/config"
|
||||
"git.wntrmute.dev/mc/mcdsl/httpserver"
|
||||
|
||||
"git.wntrmute.dev/mc/mcq/internal/db"
|
||||
"git.wntrmute.dev/mc/mcq/internal/grpcserver"
|
||||
"git.wntrmute.dev/mc/mcq/internal/server"
|
||||
"git.wntrmute.dev/mc/mcq/internal/webserver"
|
||||
)
|
||||
|
||||
type mcqConfig struct {
|
||||
config.Base
|
||||
}
|
||||
|
||||
func serverCmd() *cobra.Command {
|
||||
var configPath string
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "server",
|
||||
Short: "Start the MCQ server",
|
||||
RunE: func(_ *cobra.Command, _ []string) error {
|
||||
return runServer(configPath)
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringVarP(&configPath, "config", "c", "mcq.toml", "path to config file")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func runServer(configPath string) error {
|
||||
cfg, err := config.Load[mcqConfig](configPath, "MCQ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("load config: %w", err)
|
||||
}
|
||||
|
||||
logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{
|
||||
Level: parseLogLevel(cfg.Log.Level),
|
||||
}))
|
||||
|
||||
// Open and migrate the database.
|
||||
database, err := db.Open(cfg.Database.Path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("open database: %w", err)
|
||||
}
|
||||
defer database.Close()
|
||||
|
||||
if err := database.Migrate(); err != nil {
|
||||
return fmt.Errorf("migrate database: %w", err)
|
||||
}
|
||||
|
||||
// Create auth client for MCIAS integration.
|
||||
authClient, err := mcdslauth.New(cfg.MCIAS, logger)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create auth client: %w", err)
|
||||
}
|
||||
|
||||
// HTTP server — all routes on one router.
|
||||
httpSrv := httpserver.New(cfg.Server, logger)
|
||||
httpSrv.Router.Use(httpSrv.LoggingMiddleware)
|
||||
|
||||
// Register REST API routes (/v1/*).
|
||||
server.RegisterRoutes(httpSrv.Router, server.Deps{
|
||||
DB: database,
|
||||
Auth: authClient,
|
||||
Logger: logger,
|
||||
})
|
||||
|
||||
// Register web UI routes (/, /login, /d/*).
|
||||
wsCfg := webserver.Config{
|
||||
ServiceName: cfg.MCIAS.ServiceName,
|
||||
Tags: cfg.MCIAS.Tags,
|
||||
}
|
||||
webSrv, err := webserver.New(wsCfg, database, authClient, logger)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create web server: %w", err)
|
||||
}
|
||||
webSrv.RegisterRoutes(httpSrv.Router)
|
||||
|
||||
// Start gRPC server if configured.
|
||||
var grpcSrv *grpcserver.Server
|
||||
var grpcLis net.Listener
|
||||
if cfg.Server.GRPCAddr != "" {
|
||||
grpcSrv, err = grpcserver.New(cfg.Server.TLSCert, cfg.Server.TLSKey, grpcserver.Deps{
|
||||
DB: database,
|
||||
Authenticator: authClient,
|
||||
}, logger)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create gRPC server: %w", err)
|
||||
}
|
||||
grpcLis, err = net.Listen("tcp", cfg.Server.GRPCAddr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("listen gRPC on %s: %w", cfg.Server.GRPCAddr, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Graceful shutdown.
|
||||
grpcServeStarted := false
|
||||
shutdownAll := func() {
|
||||
if grpcSrv != nil {
|
||||
grpcSrv.GracefulStop()
|
||||
} else if grpcLis != nil && !grpcServeStarted {
|
||||
_ = grpcLis.Close()
|
||||
}
|
||||
shutdownTimeout := 30 * time.Second
|
||||
if cfg.Server.ShutdownTimeout.Duration > 0 {
|
||||
shutdownTimeout = cfg.Server.ShutdownTimeout.Duration
|
||||
}
|
||||
shutdownCtx, cancel := context.WithTimeout(context.Background(), shutdownTimeout)
|
||||
defer cancel()
|
||||
_ = httpSrv.Shutdown(shutdownCtx)
|
||||
}
|
||||
|
||||
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||
defer stop()
|
||||
|
||||
errCh := make(chan error, 2)
|
||||
|
||||
if grpcSrv != nil {
|
||||
grpcServeStarted = true
|
||||
go func() {
|
||||
logger.Info("gRPC server listening", "addr", grpcLis.Addr())
|
||||
errCh <- grpcSrv.Serve(grpcLis)
|
||||
}()
|
||||
}
|
||||
|
||||
go func() {
|
||||
logger.Info("mcq starting", "version", version, "addr", cfg.Server.ListenAddr)
|
||||
errCh <- httpSrv.ListenAndServeTLS()
|
||||
}()
|
||||
|
||||
select {
|
||||
case err := <-errCh:
|
||||
shutdownAll()
|
||||
return fmt.Errorf("server error: %w", err)
|
||||
case <-ctx.Done():
|
||||
logger.Info("shutting down")
|
||||
shutdownAll()
|
||||
logger.Info("mcq stopped")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func parseLogLevel(s string) slog.Level {
|
||||
switch s {
|
||||
case "debug":
|
||||
return slog.LevelDebug
|
||||
case "warn":
|
||||
return slog.LevelWarn
|
||||
case "error":
|
||||
return slog.LevelError
|
||||
default:
|
||||
return slog.LevelInfo
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user