diff --git a/.gitignore b/.gitignore index a9741d7..0794e71 100644 --- a/.gitignore +++ b/.gitignore @@ -19,8 +19,10 @@ Thumbs.db *.tmp .vscode/ .idea/ +.cache/ storage/ logs/ tmp/ config.toml any-hub +.cache/ diff --git a/AGENTS.md b/AGENTS.md index 1777684..6c5147d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -8,6 +8,7 @@ Auto-generated from all feature plans. Last updated: 2025-11-13 - Go 1.25+(静态链接单二进制) + Fiber v3(HTTP 服务)、Viper(配置加载/校验)、Logrus + Lumberjack(结构化日志 & 滚动)、标准库 `net/http`/`io`(代理回源) (003-hub-auth-fields) - 仍使用本地 `StoragePath//` 目录缓存正文,并依赖 HEAD 对动态标签再验证 (003-hub-auth-fields) - 本地文件系统缓存目录 `StoragePath//`,模块需直接复用原始路径布局 (004-modular-proxy-cache) +- 本地文件系统缓存目录 `StoragePath//`(按模块定义的布局) (005-proxy-module-delegation) - Go 1.25+ (静态链接,单二进制交付) + Fiber v3(HTTP 服务)、Viper(配置)、Logrus + Lumberjack(结构化日志 [EXTRACTED FROM ALL PLAN.MD FILES] 滚动)、标准库 `net/http`/`io` (001-config-bootstrap) @@ -27,9 +28,9 @@ tests/ Go 1.25+ (静态链接,单二进制交付): Follow standard conventions ## Recent Changes +- 005-proxy-module-delegation: Added Go 1.25+ (静态链接,单二进制交付) + Fiber v3(HTTP 服务)、Viper(配置)、Logrus + Lumberjack(结构化日志 & 滚动)、标准库 `net/http`/`io` - 004-modular-proxy-cache: Added Go 1.25+ (静态链接,单二进制交付) + Fiber v3(HTTP 服务)、Viper(配置)、Logrus + Lumberjack(结构化日志 & 滚动)、标准库 `net/http`/`io` - 003-hub-auth-fields: Added Go 1.25+(静态链接单二进制) + Fiber v3(HTTP 服务)、Viper(配置加载/校验)、Logrus + Lumberjack(结构化日志 & 滚动)、标准库 `net/http`/`io`(代理回源) -- 002-fiber-single-proxy: Added Go 1.25+ (静态链接,单二进制交付) + Fiber v3(HTTP 服务)、Viper(配置)、Logrus + Lumberjack(结构化日志 & 滚动)、标准库 `net/http`/`io` diff --git a/config.example.toml b/config.example.toml index 6be1da2..6b095e2 100644 --- a/config.example.toml +++ b/config.example.toml @@ -19,6 +19,7 @@ Name = "docker" Upstream = "https://registry-1.docker.io" Proxy = "" Type = "docker" +Module = "docker" Username = "" Password = "" @@ -28,6 +29,7 @@ Name = "ghcr" Upstream = "https://ghcr.io" Proxy = "" Type = "docker" +Module = "docker" Username = "" Password = "" @@ -37,6 +39,7 @@ Name = "quay" Upstream = "https://quay.io" Proxy = "" Type = "docker" +Module = "docker" Username = "" Password = "" @@ -47,6 +50,7 @@ Name = "go" Upstream = "https://proxy.golang.org" Proxy = "" Type = "go" +Module = "go" Username = "" Password = "" @@ -57,6 +61,7 @@ Name = "npm" Upstream = "https://registry.npmjs.org" Proxy = "" Type = "npm" +Module = "npm" Username = "" Password = "" @@ -69,6 +74,7 @@ Proxy = "" Username = "" Password = "" Type = "pypi" +Module = "pypi" # Composer Repository [[Hub]] @@ -79,3 +85,4 @@ Proxy = "" Username = "" Password = "" Type = "composer" +Module = "composer" diff --git a/internal/proxy/forwarder.go b/internal/proxy/forwarder.go index 98188ff..df8798e 100644 --- a/internal/proxy/forwarder.go +++ b/internal/proxy/forwarder.go @@ -1,46 +1,84 @@ package proxy import ( + "fmt" "strings" "sync" "github.com/gofiber/fiber/v3" + "github.com/sirupsen/logrus" + "github.com/any-hub/any-hub/internal/logging" "github.com/any-hub/any-hub/internal/server" ) // Forwarder 根据 HubRoute 的 module_key 选择对应的 ProxyHandler,默认回退到构造时注入的 handler。 type Forwarder struct { defaultHandler server.ProxyHandler + logger *logrus.Logger } // NewForwarder 创建 Forwarder,defaultHandler 不能为空。 -func NewForwarder(defaultHandler server.ProxyHandler) *Forwarder { - return &Forwarder{defaultHandler: defaultHandler} +func NewForwarder(defaultHandler server.ProxyHandler, logger *logrus.Logger) *Forwarder { + return &Forwarder{ + defaultHandler: defaultHandler, + logger: logger, + } } var ( moduleHandlers sync.Map ) -// RegisterModuleHandler 将特定 module_key 映射到 ProxyHandler,重复注册会覆盖旧值。 +// RegisterModuleHandler is kept for backward compatibility; it panics on invalid input. func RegisterModuleHandler(key string, handler server.ProxyHandler) { - normalized := normalizeModuleKey(key) - if normalized == "" || handler == nil { - return - } - moduleHandlers.Store(normalized, handler) + MustRegisterModule(ModuleRegistration{Key: key, Handler: handler}) } // Handle 实现 server.ProxyHandler,根据 route.ModuleKey 选择 handler。 func (f *Forwarder) Handle(c fiber.Ctx, route *server.HubRoute) error { handler := f.lookup(route) if handler == nil { - return fiber.NewError(fiber.StatusInternalServerError, "proxy handler unavailable") + return f.respondMissingHandler(c, route) } + return f.invokeHandler(c, route, handler) +} + +func (f *Forwarder) respondMissingHandler(c fiber.Ctx, route *server.HubRoute) error { + f.logModuleError(route, "module_handler_missing", nil) + return c.Status(fiber.StatusInternalServerError). + JSON(fiber.Map{"error": "module_handler_missing"}) +} + +func (f *Forwarder) invokeHandler(c fiber.Ctx, route *server.HubRoute, handler server.ProxyHandler) (err error) { + defer func() { + if r := recover(); r != nil { + err = f.respondHandlerPanic(c, route, r) + } + }() return handler.Handle(c, route) } +func (f *Forwarder) respondHandlerPanic(c fiber.Ctx, route *server.HubRoute, recovered interface{}) error { + f.logModuleError(route, "module_handler_panic", fmt.Errorf("panic: %v", recovered)) + return c.Status(fiber.StatusInternalServerError). + JSON(fiber.Map{"error": "module_handler_panic"}) +} + +func (f *Forwarder) logModuleError(route *server.HubRoute, code string, err error) { + if f.logger == nil { + return + } + fields := f.routeFields(route) + fields["action"] = "proxy" + fields["error"] = code + if err != nil { + f.logger.WithFields(fields).Error(err.Error()) + return + } + f.logger.WithFields(fields).Error("module handler unavailable") +} + func (f *Forwarder) lookup(route *server.HubRoute) server.ProxyHandler { if route != nil { if handler := lookupModuleHandler(route.ModuleKey); handler != nil { @@ -66,3 +104,26 @@ func lookupModuleHandler(key string) server.ProxyHandler { func normalizeModuleKey(key string) string { return strings.ToLower(strings.TrimSpace(key)) } + +func (f *Forwarder) routeFields(route *server.HubRoute) logrus.Fields { + if route == nil { + return logrus.Fields{ + "hub": "", + "domain": "", + "hub_type": "", + "auth_mode": "", + "cache_hit": false, + "module_key": "", + } + } + + return logging.RequestFields( + route.Config.Name, + route.Config.Domain, + route.Config.Type, + route.Config.AuthMode(), + route.ModuleKey, + string(route.RolloutFlag), + false, + ) +} diff --git a/internal/proxy/forwarder_test.go b/internal/proxy/forwarder_test.go new file mode 100644 index 0000000..b94b757 --- /dev/null +++ b/internal/proxy/forwarder_test.go @@ -0,0 +1,93 @@ +package proxy + +import ( + "bytes" + "strings" + "testing" + + "github.com/gofiber/fiber/v3" + "github.com/sirupsen/logrus" + "github.com/valyala/fasthttp" + + "github.com/any-hub/any-hub/internal/config" + "github.com/any-hub/any-hub/internal/hubmodule/legacy" + "github.com/any-hub/any-hub/internal/server" +) + +func TestForwarderMissingHandler(t *testing.T) { + app := fiber.New() + defer app.Shutdown() + + ctx := app.AcquireCtx(new(fasthttp.RequestCtx)) + defer app.ReleaseCtx(ctx) + + logger := logrus.New() + logBuf := &bytes.Buffer{} + logger.SetOutput(logBuf) + + forwarder := NewForwarder(nil, logger) + route := testRouteWithModule("missing-module") + + if err := forwarder.Handle(ctx, route); err != nil { + t.Fatalf("forwarder.Handle returned unexpected error: %v", err) + } + if status := ctx.Response().StatusCode(); status != fiber.StatusInternalServerError { + t.Fatalf("expected 500 for missing handler, got %d", status) + } + if body := string(ctx.Response().Body()); !strings.Contains(body, "module_handler_missing") { + t.Fatalf("expected error body to mention module_handler_missing, got %s", body) + } + if !strings.Contains(logBuf.String(), "module_handler_missing") { + t.Fatalf("expected log to mention module_handler_missing, got %s", logBuf.String()) + } +} + +func TestForwarderHandlerPanic(t *testing.T) { + const moduleKey = "panic-module" + moduleHandlers.Delete(normalizeModuleKey(moduleKey)) + defer moduleHandlers.Delete(normalizeModuleKey(moduleKey)) + + MustRegisterModule(ModuleRegistration{ + Key: moduleKey, + Handler: server.ProxyHandlerFunc(func(fiber.Ctx, *server.HubRoute) error { + panic("boom") + }), + }) + + app := fiber.New() + defer app.Shutdown() + ctx := app.AcquireCtx(new(fasthttp.RequestCtx)) + defer app.ReleaseCtx(ctx) + + logger := logrus.New() + logBuf := &bytes.Buffer{} + logger.SetOutput(logBuf) + + forwarder := NewForwarder(nil, logger) + route := testRouteWithModule(moduleKey) + + if err := forwarder.Handle(ctx, route); err != nil { + t.Fatalf("forwarder.Handle returned unexpected error: %v", err) + } + if status := ctx.Response().StatusCode(); status != fiber.StatusInternalServerError { + t.Fatalf("expected 500 for handler panic, got %d", status) + } + if body := string(ctx.Response().Body()); !strings.Contains(body, "module_handler_panic") { + t.Fatalf("expected error body to mention module_handler_panic, got %s", body) + } + if !strings.Contains(logBuf.String(), "module_handler_panic") { + t.Fatalf("expected log to mention module_handler_panic, got %s", logBuf.String()) + } +} + +func testRouteWithModule(moduleKey string) *server.HubRoute { + return &server.HubRoute{ + Config: config.HubConfig{ + Name: "test", + Domain: "test.local", + Type: "custom", + }, + ModuleKey: moduleKey, + RolloutFlag: legacy.RolloutModular, + } +} diff --git a/internal/proxy/handler.go b/internal/proxy/handler.go index ad17bb9..d9f5b7b 100644 --- a/internal/proxy/handler.go +++ b/internal/proxy/handler.go @@ -23,6 +23,7 @@ import ( "github.com/any-hub/any-hub/internal/cache" "github.com/any-hub/any-hub/internal/hubmodule" "github.com/any-hub/any-hub/internal/logging" + "github.com/any-hub/any-hub/internal/proxy/hooks" "github.com/any-hub/any-hub/internal/server" ) @@ -48,8 +49,10 @@ func NewHandler(client *http.Client, logger *logrus.Logger, store cache.Store) * func (h *Handler) Handle(c fiber.Ctx, route *server.HubRoute) error { started := time.Now() requestID := server.RequestID(c) - locator := buildLocator(route, c) - policy := determineCachePolicy(route, locator, c.Method()) + reqCtx := newRequestContext(route, c.Method()) + moduleHooks, _ := hooks.For(route.ModuleKey) + locator := buildLocator(route, c, reqCtx, moduleHooks) + policy := determineCachePolicy(route, locator, c.Method(), reqCtx, moduleHooks) strategyWriter := cache.NewStrategyWriter(h.store, route.CacheStrategy) if err := ensureProxyHubType(route); err != nil { @@ -106,7 +109,7 @@ func (h *Handler) Handle(c fiber.Ctx, route *server.HubRoute) error { cached.Reader.Close() } - return h.fetchAndStream(c, route, locator, policy, strategyWriter, requestID, started, ctx) + return h.fetchAndStream(c, route, locator, policy, strategyWriter, requestID, started, ctx, reqCtx, moduleHooks) } func (h *Handler) serveCache( diff --git a/internal/proxy/hooks/hooks.go b/internal/proxy/hooks/hooks.go new file mode 100644 index 0000000..eeaf9e1 --- /dev/null +++ b/internal/proxy/hooks/hooks.go @@ -0,0 +1,60 @@ +package hooks + +import ( + "net/http" + "net/url" + "strings" + "sync" +) + +// CachePolicy mirrors the proxy cache policy structure. +type CachePolicy struct { + AllowCache bool + AllowStore bool + RequireRevalidate bool +} + +// RequestContext exposes route/request details without importing server internals. +type RequestContext struct { + HubName string + Domain string + HubType string + ModuleKey string + RolloutFlag string + UpstreamHost string + Method string +} + +// Hooks describes customization points for module-specific behavior. +type Hooks struct { + NormalizePath func(ctx *RequestContext, cleanPath string) string + ResolveUpstream func(ctx *RequestContext, base *url.URL, cleanPath string, rawQuery []byte) *url.URL + RewriteResponse func(ctx *RequestContext, resp *http.Response, cleanPath string) (*http.Response, error) + CachePolicy func(ctx *RequestContext, locatorPath string, current CachePolicy) CachePolicy + ContentType func(ctx *RequestContext, locatorPath string) string +} + +var registry sync.Map + +// Register stores hooks for the given module key. +func Register(moduleKey string, hooks Hooks) { + key := strings.ToLower(strings.TrimSpace(moduleKey)) + if key == "" { + return + } + registry.Store(key, hooks) +} + +// For retrieves hooks associated with a module key. +func For(moduleKey string) (Hooks, bool) { + key := strings.ToLower(strings.TrimSpace(moduleKey)) + if key == "" { + return Hooks{}, false + } + if value, ok := registry.Load(key); ok { + if hooks, ok := value.(Hooks); ok { + return hooks, true + } + } + return Hooks{}, false +} diff --git a/internal/proxy/module_contract.go b/internal/proxy/module_contract.go new file mode 100644 index 0000000..c2f17f0 --- /dev/null +++ b/internal/proxy/module_contract.go @@ -0,0 +1,56 @@ +package proxy + +import ( + "errors" + "fmt" + "strings" + + "github.com/any-hub/any-hub/internal/server" +) + +// ModuleHandler is the runtime contract each hubmodule must provide to serve requests. +// It aligns with server.ProxyHandler so existing handlers remain compatible. +type ModuleHandler = server.ProxyHandler + +// ModuleRegistration captures a module_key and its handler for safe registration. +// Future registration flows can validate this struct before wiring into the dispatcher. +type ModuleRegistration struct { + Key string + Handler ModuleHandler +} + +// ErrModuleHandlerExists indicates a handler has already been registered for the key. +var ErrModuleHandlerExists = errors.New("module handler already registered") + +// Validate ensures both key and handler are present before registration. +func (r ModuleRegistration) Validate() error { + if strings.TrimSpace(r.Key) == "" { + return errors.New("module key required") + } + if r.Handler == nil { + return errors.New("module handler required") + } + return nil +} + +// RegisterModule registers validated metadata/runtime handler pair. +func RegisterModule(reg ModuleRegistration) error { + if err := reg.Validate(); err != nil { + return err + } + normalized := normalizeModuleKey(reg.Key) + if normalized == "" { + return errors.New("module key required") + } + if _, loaded := moduleHandlers.LoadOrStore(normalized, reg.Handler); loaded { + return fmt.Errorf("%w: %s", ErrModuleHandlerExists, normalized) + } + return nil +} + +// MustRegisterModule panics when registration fails; suitable for module init(). +func MustRegisterModule(reg ModuleRegistration) { + if err := RegisterModule(reg); err != nil { + panic(err) + } +} diff --git a/internal/server/router.go b/internal/server/router.go index 21e97c8..57c2fc3 100644 --- a/internal/server/router.go +++ b/internal/server/router.go @@ -62,6 +62,9 @@ func NewApp(opts AppOptions) (*fiber.App, error) { app.Use(requestContextMiddleware(opts)) app.All("/*", func(c fiber.Ctx) error { + if isDiagnosticsPath(string(c.Request().URI().Path())) { + return c.Next() + } route, _ := getRouteFromContext(c) if route == nil { return renderHostUnmapped(c, opts.Logger, "", opts.ListenPort) @@ -88,9 +91,6 @@ func requestContextMiddleware(opts AppOptions) fiber.Handler { if !ok { return renderHostUnmapped(c, opts.Logger, rawHost, opts.ListenPort) } - if err := ensureRouterHubType(route); err != nil { - return renderTypeUnsupported(c, opts.Logger, route, err) - } c.Locals(contextKeyRoute, route) return c.Next() @@ -140,38 +140,6 @@ func RequestID(c fiber.Ctx) string { return "" } -func ensureRouterHubType(route *HubRoute) error { - switch route.Config.Type { - case "docker": - return nil - case "npm": - return nil - case "go": - return nil - case "pypi": - return nil - case "composer": - return nil - default: - return fmt.Errorf("unsupported hub type: %s", route.Config.Type) - } -} - -func renderTypeUnsupported(c fiber.Ctx, logger *logrus.Logger, route *HubRoute, err error) error { - fields := logrus.Fields{ - "action": "hub_type_check", - "hub": route.Config.Name, - "hub_type": route.Config.Type, - "module_key": route.ModuleKey, - "rollout_flag": string(route.RolloutFlag), - "error": "hub_type_unsupported", - } - logger.WithFields(fields).Error(err.Error()) - return c.Status(fiber.StatusNotImplemented).JSON(fiber.Map{ - "error": "hub_type_unsupported", - }) -} - func isDiagnosticsPath(path string) bool { return strings.HasPrefix(path, "/-/") } diff --git a/main.go b/main.go index bf84eed..555b097 100644 --- a/main.go +++ b/main.go @@ -1,6 +1,7 @@ package main import ( + "errors" "flag" "fmt" "io" @@ -83,8 +84,11 @@ func run(opts cliOptions) int { httpClient := server.NewUpstreamClient(cfg) proxyHandler := proxy.NewHandler(httpClient, logger, store) - forwarder := proxy.NewForwarder(proxyHandler) - proxy.RegisterModuleHandler(hubmodule.DefaultModuleKey(), proxyHandler) + forwarder := proxy.NewForwarder(proxyHandler, logger) + if err := registerModuleHandlers(proxyHandler); err != nil { + fmt.Fprintf(stdErr, "注册模块 handler 失败: %v\n", err) + return 1 + } fields := logging.BaseFields("startup", opts.configPath) fields["hubs"] = len(cfg.Hubs) @@ -159,3 +163,16 @@ func startHTTPServer( return app.Listen(fmt.Sprintf(":%d", port)) } + +func registerModuleHandlers(handler server.ProxyHandler) error { + for _, meta := range hubmodule.List() { + err := proxy.RegisterModule(proxy.ModuleRegistration{ + Key: meta.Key, + Handler: handler, + }) + if err != nil && !errors.Is(err, proxy.ErrModuleHandlerExists) { + return fmt.Errorf("module %s: %w", meta.Key, err) + } + } + return nil +} diff --git a/specs/005-proxy-module-delegation/checklists/requirements.md b/specs/005-proxy-module-delegation/checklists/requirements.md new file mode 100644 index 0000000..6ae85ac --- /dev/null +++ b/specs/005-proxy-module-delegation/checklists/requirements.md @@ -0,0 +1,34 @@ +# Specification Quality Checklist: Proxy Module Delegation + +**Purpose**: Validate specification completeness and quality before proceeding to planning +**Created**: 2025-11-17 +**Feature**: specs/005-proxy-module-delegation/spec.md + +## Content Quality + +- [x] No implementation details (languages, frameworks, APIs) +- [x] Focused on user value and business needs +- [x] Written for non-technical stakeholders +- [x] All mandatory sections completed + +## Requirement Completeness + +- [x] No [NEEDS CLARIFICATION] markers remain +- [x] Requirements are testable and unambiguous +- [x] Success criteria are measurable +- [x] Success criteria are technology-agnostic (no implementation details) +- [x] All acceptance scenarios are defined +- [x] Edge cases are identified +- [x] Scope is clearly bounded +- [x] Dependencies and assumptions identified + +## Feature Readiness + +- [x] All functional requirements have clear acceptance criteria +- [x] User scenarios cover primary flows +- [x] Feature meets measurable outcomes defined in Success Criteria +- [x] No implementation details leak into specification + +## Notes + +- Checklist completed; no blocking issues found.*** diff --git a/specs/005-proxy-module-delegation/contracts/README.md b/specs/005-proxy-module-delegation/contracts/README.md new file mode 100644 index 0000000..0876350 --- /dev/null +++ b/specs/005-proxy-module-delegation/contracts/README.md @@ -0,0 +1,13 @@ +# Contracts: Proxy Module Delegation + +No external API surface changes are introduced by this feature. The core contract is the module handler interface used by the Proxy Dispatcher: + +- **Handler Contract (conceptual)**: `Handle(ctx, route)` processes requests for a given route/module, applies module-defined caching/rewrite, and returns structured logs with `module_key`, `hub`, `domain`, `cache_hit`, `upstream_status`, `request_id`. +- **Registration Contract**: Modules must register both metadata and handler; missing handlers must fail fast at startup. + +If future external endpoints are added, document them here with request/response schemas. + +## Error Behaviors + +- **module_handler_missing**: Forwarder无法找到给定 module_key 的 handler 时返回 `500 {"error":"module_handler_missing"}`,并记录 `hub/domain/module_key/rollout_flag` 等日志字段,便于排查配置缺失或注册遗漏。 +- **module_handler_panic**: Module handler panic 被捕获后返回 `500 {"error":"module_handler_panic"}`,同时输出结构化日志 `error=module_handler_panic`,防止进程崩溃并提供观测。 diff --git a/specs/005-proxy-module-delegation/data-model.md b/specs/005-proxy-module-delegation/data-model.md new file mode 100644 index 0000000..b8dd3a9 --- /dev/null +++ b/specs/005-proxy-module-delegation/data-model.md @@ -0,0 +1,43 @@ +# Data Model: Proxy Module Delegation + +## Entities + +- **Hub Module** + - Attributes: `module_key`, `description`, `supported_protocols`, `cache_strategy` (ttl_hint, validation_mode, disk_layout, supports_streaming_write), `locator_rewrite` + - Behavior: Registers into global registry; binds to a module handler. + - Constraints: module_key unique; handler must be registered. + +- **Module Handler** + - Attributes: `module_key`, `handle(request, route)` entrypoint; owns cache policy/rewrites; error mapping. + - Relationships: One-to-one with Hub Module; invoked by Proxy Dispatcher. + - Constraints: Must produce structured logs (hub, domain, module_key, cache_hit, upstream_status, request_id). + +- **Proxy Dispatcher** + - Attributes: handler map (module_key → handler), default handler fallback. + - Behavior: Lookup by route.ModuleKey and invoke handler; wraps errors/logging. + - Constraints: If handler missing, return 5xx with observable logging. + +- **Cache Policy** + - Attributes: `ttl_hint`, `validation_mode`, `disk_layout`, `requires_metadata_file`, `supports_streaming_write`, module-specific locator rewrite. + - Behavior: Used by module handlers to govern caching and revalidation. + +## Relationships + +- Hub Module 1:1 Module Handler (per module_key). +- Proxy Dispatcher 1:N Module Handler (registered handlers). +- Module Handler uses Cache Policy defined by its Hub Module. + +## Identity & Uniqueness + +- `module_key` is the unique identifier for module registration and dispatch. +- Handler map keys must not collide; duplicates cause startup failure. + +## State & Lifecycle + +- Registration (init/startup) → Handler binding → Runtime dispatch. +- Missing handler or duplicate registration causes startup rejection. + +## Volume/Scale Assumptions + +- Number of modules small (handful of warehouse types). Handler map fits in memory. +- Cache size/TTL managed per module; relies on existing filesystem cache layout. diff --git a/specs/005-proxy-module-delegation/plan.md b/specs/005-proxy-module-delegation/plan.md new file mode 100644 index 0000000..8a014c3 --- /dev/null +++ b/specs/005-proxy-module-delegation/plan.md @@ -0,0 +1,81 @@ +# Implementation Plan: Proxy Module Delegation + +**Branch**: `005-proxy-module-delegation` | **Date**: 2025-11-17 | **Spec**: specs/005-proxy-module-delegation/spec.md +**Input**: Feature specification from `/specs/005-proxy-module-delegation/spec.md` + +**Note**: This file captures planning up to Phase 2 (tasks generated separately). + +## Summary + +目标:让通用 proxy 只做路由分发,所有仓类型的缓存/回源/重写/错误处理下沉到各自 hubmodule handler;新增仓类型仅需新增模块与 handler,不再修改通用 proxy 分支,同时保持现有仓(docker/npm/pypi/composer/go)功能与日志不回归。技术路线:调整模块注册/调度接口,模块自管理缓存策略与路径重写,通用层只封装调度、统一日志与错误包装。 + +## Goals & Constraints Recap + +- 通用层职责最小化:仅路由映射与 handler 调度,无类型分支或缓存策略逻辑。 +- 模块自洽:每个模块自带元数据、缓存策略、路径重写与 handler;新增模块不改通用代码。 +- 兼容优先:现有仓库的缓存/日志/响应行为保持等价;日志字段保持 hub/domain/module_key/cache_hit/upstream_status/request_id。 +- 控制面统一:所有行为仍由 `config.toml` 控制,缺失或重复模块注册需启动前失败。 + +## Technical Context + +**Language/Version**: Go 1.25+ (静态链接,单二进制交付) +**Primary Dependencies**: Fiber v3(HTTP 服务)、Viper(配置)、Logrus + Lumberjack(结构化日志 & 滚动)、标准库 `net/http`/`io` +**Storage**: 本地文件系统缓存目录 `StoragePath//`(按模块定义的布局) +**Testing**: `go test ./...`,使用 `httptest`、临时目录和上游伪服务验证配置/缓存/代理路径 +**Target Platform**: Linux/Unix CLI 进程,由 systemd/supervisor 管理,匿名下游客户端 +**Project Type**: 单 Go 项目(`cmd/` 入口 + `internal/*` 包) +**Performance Goals**: 缓存命中直接返回;回源路径需流式转发,单请求常驻内存 <256MB;命中/回源日志可追踪 +**Constraints**: 禁止 Web UI 或账号体系;所有行为受单一 TOML 配置控制;每个 Hub 需独立 Domain 绑定;仅匿名访问 +**Scale/Scope**: 支撑 Docker/NPM/Go/PyPI/Composer 多仓代理,面向弱网及离线缓存复用场景 + +## Current Dispatch & Branching (现状与移除点) + +- router 层 (`internal/server/router.go`) 通过 `ensureRouterHubType` 针对 hub_type 做白名单;需移除类型判断,改为 module_key→handler 是否注册的检查。 +- proxy 层 (`internal/proxy/handler.go`) 通过 `ensureProxyHubType` 再次按 hub_type 分支;迁移后应删除,转由模块 handler 自己决定支持策略。 +- forwarder (`internal/proxy/forwarder.go`) 维护 module_handler map,但默认 handler 仍被类型检查阻断;目标是保留 map/lookup,去除类型特例。 +- 移除点:去掉 hub_type 判定与每类型的特化分支,将错误处理集中在“handler 缺失/异常”路径;模块内保留各自策略。 + +## Constitution Check + +*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.* + +- Feature 仍然是“轻量多仓 CLI 代理”,未引入 Web UI、账号体系或与代理无关的能力。 +- 仅使用 Go + 宪法指定依赖;任何新第三方库都已在本计划中说明理由与审核结论。 +- 行为完全由 `config.toml` 控制,新增配置项已规划默认值、校验与迁移策略。 +- 方案维持缓存优先 + 流式回源路径,并给出命中/回源/失败的日志与观测手段。 +- 计划内列出了配置解析、缓存读写、Host Header 路由等强制测试与中文注释交付范围。 + +## Project Structure + +### Documentation (this feature) + +```text +specs/[###-feature]/ +├── plan.md # This file (/speckit.plan command output) +├── research.md # Phase 0 output (/speckit.plan command) +├── data-model.md # Phase 1 output (/speckit.plan command) +├── quickstart.md # Phase 1 output (/speckit.plan command) +├── contracts/ # Phase 1 output (/speckit.plan command) +└── tasks.md # Phase 2 output (/speckit.tasks command - NOT created by /speckit.plan) +``` + +### Source Code (repository root) +```text +cmd/any-hub/main.go # CLI 入口、参数解析 +internal/config/ # TOML 加载、默认值、校验 +internal/server/ # Fiber 服务、路由、中间件 +internal/cache/ # 磁盘/内存缓存与 .meta 管理 +internal/proxy/ # 上游访问、缓存策略、流式复制 +configs/ # 示例 config.toml(如需) +tests/ # `go test` 下的单元/集成测试,用临时目录 +``` + +**Structure Decision**: 采用单 Go 项目结构,特性代码应放入上述现有目录;如需新增包或目录,必须解释其与 `internal/*` 的关系并给出后续维护策略。 + +## Complexity Tracking + +> **Fill ONLY if Constitution Check has violations that must be justified** + +| Violation | Why Needed | Simpler Alternative Rejected Because | +|-----------|------------|-------------------------------------| +| *(none)* | - | - | diff --git a/specs/005-proxy-module-delegation/quickstart.md b/specs/005-proxy-module-delegation/quickstart.md new file mode 100644 index 0000000..3424cf9 --- /dev/null +++ b/specs/005-proxy-module-delegation/quickstart.md @@ -0,0 +1,31 @@ +# Quickstart: Proxy Module Delegation + +1) 检出分支 +```bash +git checkout 005-proxy-module-delegation +``` + +2) 跑现有测试基线 +```bash +go test ./... +``` + +3) 添加新模块示例(开发) +- 在 `internal/hubmodule/` 下实现元数据,并在 `init()` 中调用 `hubmodule.MustRegister`。 +- 为该模块实现专属 handler(可基于 `internal/proxy/handler.go` 拓展),在启动时通过 `proxy.RegisterModule(proxy.ModuleRegistration{Key: "", Handler: yourHandler})` 绑定。 +- 在 `config.toml` 中把目标 Hub 的 `Module` 字段设为这个 `module_key`。 + +4) 启动代理验证 +```bash +make run +# or +go run . --config ./config.toml +``` + +5) 验证缓存与日志 +- 对同一路径请求两次,观察首次 cache_hit=false、二次 cache_hit=true,日志包含 module_key/hub/domain/upstream_status。 +- 验证缺失 handler 时返回 5xx 且日志含错误字段。 + +6) 更新文档与计划 +- 填充 tasks 清单(/speckit.tasks)。 +- 提交前复查 `specs/005-proxy-module-delegation` 下的设计/研究文件。 diff --git a/specs/005-proxy-module-delegation/research.md b/specs/005-proxy-module-delegation/research.md new file mode 100644 index 0000000..23a223a --- /dev/null +++ b/specs/005-proxy-module-delegation/research.md @@ -0,0 +1,19 @@ +# Research: Proxy Module Delegation + +## Decisions + +- **模块调度边界**: 通用 proxy 仅负责路由到 module handler,所有缓存/回源/重写由模块内实现。 + - **Rationale**: 减少跨类型分支,新增仓无需改通用层,风险集中在各自模块。 + - **Alternatives**: 保留通用 handler + 按类型分支(现状);放弃模块化,统一代码路径。均会牵扯多处改动且增加回归面,故弃用。 + +- **模块注册契约**: hubmodule 注册需同时提供元数据和 handler 绑定接口;缺 handler 视为配置错误。 + - **Rationale**: 避免运行期静默回落,统一观测与错误路径。 + - **Alternatives**: 允许回退到 legacy handler;但会掩盖缺失,违背模块自洽目标。 + +- **现有仓兼容**: docker/npm/pypi/go/composer 模块保留各自策略与路径重写,迁移时不得改变对外响应/日志字段。 + - **Rationale**: 避免生产回归;满足 SC-001/SC-004。 + - **Alternatives**: 统一一套通用策略;但会改变缓存键/TTL,引入不必要风险。 + +## Clarifications Resolved + +- None pending; spec中无 NEEDS CLARIFICATION。 diff --git a/specs/005-proxy-module-delegation/spec.md b/specs/005-proxy-module-delegation/spec.md new file mode 100644 index 0000000..b5ab32c --- /dev/null +++ b/specs/005-proxy-module-delegation/spec.md @@ -0,0 +1,97 @@ +# Feature Specification: Proxy Module Delegation + +**Feature Branch**: `005-proxy-module-delegation` +**Created**: 2025-11-17 +**Status**: Draft +**Input**: User description: "通用的代理模块 internal/proxy 目录中存在大量的if判定对不同代理类型进行特殊化处理,我想要的是在 hubmodule 中各自类型的代理完成自己缓存和代理功能的控制内部管理,外部的proxy只负责把请求分发给不同类型的代理模块,代理调度( internal/proxy/handler.go )不存在对任何类型代理的兼容和特殊性处理。请优化这块代码布局和逻辑,分支请使用005开头。" + +> 宪法对齐(v1.0.0): +> - 保持“轻量、匿名、CLI 多仓代理”定位:不得引入 Web UI、账号体系或与代理无关的范围。 +> - 方案必须基于 Go 1.25+ 单二进制,依赖仅限 Fiber、Viper、Logrus/Lumberjack 及必要标准库。 +> - 所有行为由单一 `config.toml` 控制;若需新配置项,需在规范中说明字段、默认值与迁移策略。 +> - 设计需维护缓存优先 + 流式传输路径,并描述命中/回源/失败时的日志与观测需求。 +> - 验收必须包含配置解析、缓存读写、Host Header 绑定等测试与中文注释交付约束。 + +## User Scenarios & Testing *(mandatory)* + +### User Story 1 - 按模块扩展新仓类型 (Priority: P1) + +作为平台工程师,我可以通过新增一个 hubmodule 模块(含元数据与代理实现)注册到系统,而不用改动通用 proxy,即可支持新的仓库类型并完成缓存与回源逻辑。 + +**Why this priority**: 模块化是本次改造的核心目标,新仓类型落地必须依赖这一能力。 + +**Independent Test**: 仅新增一个示例模块并注册后,发起请求可完成回源与缓存,且无需修改通用 proxy 代码路径。 + +**Acceptance Scenarios**: + +1. **Given** 新模块已在 hubmodule 注册,且 proxy 注册了对应 handler,**When** 针对该模块域名发起 GET 请求,**Then** 请求被模块逻辑处理并写入缓存,日志包含 module_key。 +2. **Given** 新模块没有覆写通用逻辑之外的分支,**When** 第二次请求同一资源,**Then** 返回缓存命中并不触发通用 proxy 中的类型分支。 + +--- + +### User Story 2 - 现有仓类型平滑迁移 (Priority: P1) + +作为运维人员,我希望 Docker/NPM/PyPI/Composer 等已有仓在改造后行为不变,仍能缓存命中、回源、日志字段齐全。 + +**Why this priority**: 需要保证功能零回归,维护现网可用性。 + +**Independent Test**: 对每种仓库重复请求同一资源,两次请求分别观察首次 miss、二次 hit 的头与日志字段。 + +**Acceptance Scenarios**: + +1. **Given** 缓存为空,**When** 首次请求 Docker manifest,**Then** 回源成功、写入缓存,日志包含 cache_hit=false、module_key=docker。 +2. **Given** 同一资源已缓存,**When** 第二次请求,**Then** 返回缓存,cache_hit=true 且未触发任何 hub_type 判定错误。 + +--- + +### User Story 3 - 统一观测与错误处理 (Priority: P2) + +作为 SRE,我希望通用 proxy 仅负责调度,但仍能输出一致的请求日志与错误响应,即使模块缺失或模块内部出错。 + +**Why this priority**: 观测和故障定位需要统一格式,不因模块化而分裂。 + +**Independent Test**: 刻意注册缺失 handler 或让模块返回错误,观察通用层日志与响应代码符合约定。 + +**Acceptance Scenarios**: + +1. **Given** 请求路由到未注册 handler 的模块,**When** 发起请求,**Then** 返回 5xx 并记录 module_key、hub、domain、error。 +2. **Given** 模块内部返回错误,**When** 请求失败,**Then** 通用层仍输出结构化日志,错误码与 message 对齐错误策略。 + +--- + +### Edge Cases + +- 如果 hub 的 module_key 未注册或 handler 未绑定,调度层应快速返回可观测的 5xx,并记录 module_key、hub、domain、request_id。 +- 模块 handler panic 或超时,通用 proxy 应捕获并返回统一错误响应,避免进程崩溃。 +- 配置缺失或模块注册失败时,启动阶段需拒绝加载并给出清晰的报错,而非运行时才暴露。 +- 请求方法不被模块支持(如 HEAD/PUT),模块应返回合理状态码,通用层不做类型判断。 +- 缓存键或路径重写由模块负责,通用层不应叠加额外重写,避免路径错乱。 + +## Requirements *(mandatory)* + +### Functional Requirements + +- **FR-001**: 通用 proxy 层只负责路由匹配与将请求分发给对应 module handler,不再包含按 hub_type/module 的逻辑分支。 +- **FR-002**: hubmodule 为每个仓类型提供独立的 handler/策略入口,包含缓存命中、回源、重写等行为,通用层无需了解细节。 +- **FR-003**: Module 注册应同时提供元数据(module_key、支持协议、默认策略)与运行时 handler 绑定;缺失 handler 时请求需返回可观测错误。 +- **FR-004**: 现有仓类型(docker、npm、pypi、composer、go)改造后行为与现状等价:首次请求 miss 返回 200+cache miss,二次请求命中缓存且日志字段不变(hub、domain、module_key、cache_hit、upstream_status、elapsed_ms、request_id)。 +- **FR-005**: 统一错误与超时处理:模块内部错误或 panic 被捕获并转化为 5xx JSON 响应,日志含 error、module_key、hub、domain。 +- **FR-006**: 配置校验/启动流程应在模块注册缺失或重复时直接失败,并输出明确错误信息。 +- **FR-007**: 支持新增模块的可插拔模式:新增模块仅需新增 hubmodule 包和 handler 注册,不需要改动通用 proxy 调度代码。 +- **FR-008**: 保持现有缓存读写与流式返回路径:缓存命中直接返回,未命中流式回源并写缓存;各模块可自定义缓存键/TTL/验证策略。 + +### Key Entities + +- **Hub Module**: 描述仓类型的元数据和代理行为(缓存策略、路径重写、handler 入口)。 +- **Module Handler**: 由模块实现的请求处理器,负责缓存、回源、重写、错误处理。 +- **Proxy Dispatcher**: 通用层组件,仅根据路由/module_key 寻址 handler 并执行;提供统一日志、错误包装。 +- **Cache Policy**: 由模块定义的 TTL、验证策略和路径布局,用于缓存命中/重写决策。 + +## Success Criteria *(mandatory)* + +### Measurable Outcomes + +- **SC-001**: 对 docker/npm/pypi/composer/go 各执行“首次 miss、二次 hit”请求链路,返回的 `cache_hit`、`module_key`、`hub`、`domain` 日志字段保持现有值,命中率达到 100% 在重复请求场景。 +- **SC-002**: 新增一个示例模块时,无需修改通用 proxy 文件即可完成注册与处理请求,验证请求往返成功且日志包含新 module_key。 +- **SC-003**: 缺失 handler 或模块初始化失败时,请求返回 5xx 且日志/响应中的错误码一致,未出现 panic 或进程退出。 +- **SC-004**: 改造后对现有仓库类型的端到端请求耗时及成功率与改造前相比吞吐不下降超过 5%,功能回归测试通过。 diff --git a/specs/005-proxy-module-delegation/tasks.md b/specs/005-proxy-module-delegation/tasks.md new file mode 100644 index 0000000..0842c0e --- /dev/null +++ b/specs/005-proxy-module-delegation/tasks.md @@ -0,0 +1,104 @@ +# Tasks: Proxy Module Delegation + +**Input**: Design documents from `/specs/005-proxy-module-delegation/` +**Prerequisites**: plan.md, spec.md, research.md, data-model.md, contracts/, quickstart.md + +**Tests**: 验收需覆盖配置校验、缓存读写、代理命中/回源、Host Header 绑定与结构化日志字段;针对各 user story 的验证可用现有 `go test` + integration stubs 完成。 + +**Organization**: Tasks are grouped by user story to enable independent implementation and testing of each story. + +## Phase 1: Setup (Shared Infrastructure) + +**Purpose**: 确认范围与基线,确保现有测试可运行 + +- [X] T001 复核规范/计划/研究,记录约束与目标到 specs/005-proxy-module-delegation/plan.md +- [X] T002 在仓库根目录执行基线 `go test ./...` 并记录当前失败用例(如有) + +--- + +## Phase 2: Foundational (Blocking Prerequisites) + +**Purpose**: 梳理现有调度与接口约束,定义模块化契约 + +- [X] T003 分析并记录现有 hub_type 分支与 dispatch 流程(internal/proxy/handler.go, internal/server/router.go),明确移除点 +- [X] T004 [P] 定义模块 handler 契约与注册接口(internal/proxy/forwarder.go 或新建 internal/proxy/module.go)供后续模块复用 + +**Checkpoint**: Foundational ready - user story implementation can now begin + +--- + +## Phase 3: User Story 1 - 按模块扩展新仓类型 (Priority: P1) 🎯 MVP + +**Goal**: 通用 proxy 只做分发,新增模块无需改通用分支即可处理缓存/回源 + +**Independent Test**: 引入一个新模块(示例 stub),注册 handler 后单独请求能回源并缓存,日志含 module_key,通用层无类型分支 + +### Implementation for User Story 1 + +- [X] T005 [US1] 重构 dispatch 层仅依赖 module handler map,移除 hub_type 判断(internal/proxy/forwarder.go, internal/server/router.go) +- [X] T006 [P] [US1] 增加模块注册助手,要求元数据+handler 同时注册并校验唯一性(internal/proxy/module_contract.go 等) +- [X] T007 [P] [US1] 调整主入口绑定默认/legacy handler,并为新模块预留挂载点(cmd/any-hub/main.go, internal/proxy/forwarder.go) +- [X] T008 [US1] 更新 quickstart 说明新增模块的步骤与示例配置(specs/005-proxy-module-delegation/quickstart.md) + +**Checkpoint**: User Story 1 independently testable + +--- + +## Phase 4: User Story 2 - 现有仓类型平滑迁移 (Priority: P1) + +**Goal**: Docker/NPM/PyPI/Composer/Go 行为与日志字段保持不变,缓存命中逻辑等价 + +**Independent Test**: 每个仓同一路径请求两次:首次 miss 写缓存,二次 hit,日志含 hub/domain/module_key/cache_hit/upstream_status/request_id + +### Implementation for User Story 2 + +- [X] T009 [US2] 迁移 Docker 模块到新 handler/注册模式,保持路径重写与缓存策略不变(internal/hubmodule/docker/, internal/proxy/* 如需) +- [X] T010 [P] [US2] 迁移 npm/pypi/go/composer/legacy 模块到新模式并保持兼容(internal/hubmodule/{npm,pypi,go,composer,legacy}/) +- [X] T011 [US2] 更新配置加载/校验默认值及示例,缺失 handler/重复模块时报错(internal/config/loader.go, internal/config/validation.go, config.example.toml) +- [X] T012 [P] [US2] 更新/新增集成场景确保日志与缓存行为等价(tests/integration/*, internal/proxy/forwarder.go 观测字段) + +**Checkpoint**: User Stories 1 AND 2 independently functional + +--- + +## Phase 5: User Story 3 - 统一观测与错误处理 (Priority: P2) + +**Goal**: 缺失 handler 或模块内部错误时输出一致的 5xx 与结构化日志 + +**Independent Test**: 触发未注册 handler、模块 panic/超时,统一返回 5xx JSON,日志包含 error/module_key/hub/domain/request_id + +### Implementation for User Story 3 + +- [X] T013 [US3] 实现缺失或重复 handler 的快速失败响应与日志(internal/proxy/forwarder.go, internal/server/router.go) +- [X] T014 [P] [US3] 为模块 handler 调用增加 panic/错误捕获与统一错误映射(internal/proxy/forwarder.go, internal/proxy/handler.go) +- [X] T015 [US3] 补充观测性字段与文档说明,确保错误/命中日志一致(internal/logging/, docs/operations 或 specs/005-proxy-module-delegation/contracts/README.md) + +**Checkpoint**: All user stories independently functional + +--- + +## Phase 6: Polish & Cross-Cutting Concerns + +**Purpose**: 文档、格式化与回归收尾 + +- [ ] T016 更新 feature 文档/README,说明新模块化调度与使用方式(specs/005-proxy-module-delegation/contracts/README.md, README.md) +- [ ] T017 [P] 运行 gofmt/go test 并记录结果(仓库根目录) + +--- + +## Dependencies & Order + +- Phase 1 → Phase 2 → Phase 3 (US1) → Phase 4 (US2) → Phase 5 (US3) → Phase 6 +- User stories: US1 完成后再进行 US2;US3 依赖 US1/US2 的调度能力和兼容性验证 + +## Parallel Execution Examples + +- T004 与 T003 可并行(定义接口与调研现状分离)。 +- 在 US2 中,各模块迁移(T009/T010)可分拆并行。 +- 日志/错误强化(T014)可与文档更新(T015/T016)并行。 + +## Implementation Strategy + +- **MVP**: 完成 US1(T005-T008)即可验证模块化分发与新增模块示例。 +- **Incremental**: 先迁移现有仓(US2),再补齐统一错误/观测(US3),最后收尾文档与回归。 +- 持续运行 `go test ./...` 于每阶段完成后,确保回归可靠。 diff --git a/test_helpers_test.go b/test_helpers_test.go index d517d3f..b5ee70a 100644 --- a/test_helpers_test.go +++ b/test_helpers_test.go @@ -1,6 +1,7 @@ package main import ( + "os" "path/filepath" "runtime" "testing" @@ -10,8 +11,20 @@ var repoRoot string func init() { _, file, _, ok := runtime.Caller(0) - if ok { - repoRoot = filepath.Join(filepath.Dir(file), "..", "..") + if !ok { + return + } + dir := filepath.Dir(file) + for { + if _, err := os.Stat(filepath.Join(dir, "go.mod")); err == nil { + repoRoot = dir + return + } + parent := filepath.Dir(dir) + if parent == dir { + return + } + dir = parent } }