diff --git a/.dockerignore b/.dockerignore index c470449..95a7c87 100644 --- a/.dockerignore +++ b/.dockerignore @@ -2,6 +2,8 @@ .git .github .codex +.specify +configs node_modules/ coverage/ logs diff --git a/Dockerfile b/Dockerfile index 1ea640b..2dc4987 100644 --- a/Dockerfile +++ b/Dockerfile @@ -8,13 +8,10 @@ ARG COMMIT=dev WORKDIR /src COPY go.mod go.sum ./ RUN --mount=type=cache,target=/go/pkg/mod go mod download -COPY cmd ./cmd -COPY internal ./internal +COPY . . RUN --mount=type=cache,target=/root/.cache/go-build \ - cd /src \ - ls -l \ CGO_ENABLED=0 GOOS=$TARGETOS GOARCH=$TARGETARCH \ - go build -trimpath -ldflags "-s -w -X github.com/rogeecn/any-hub/internal/version.Version=${VERSION} -X github.com/rogeecn/any-hub/internal/version.Commit=${COMMIT}" -o /out/any-hub ./cmd/any-hub + go build -trimpath -ldflags "-s -w -X github.com/any-hub/any-hub/internal/version.Version=${VERSION} -X github.com/any-hub/any-hub/internal/version.Commit=${COMMIT}" -o /out/any-hub . FROM gcr.io/distroless/static-debian12:nonroot COPY --from=builder /out/any-hub /usr/local/bin/any-hub diff --git a/Makefile b/Makefile index 3baa49a..ea69388 100644 --- a/Makefile +++ b/Makefile @@ -4,10 +4,10 @@ GOCACHE ?= /tmp/go-build .PHONY: build fmt test test-all run build: - $(GO) build ./cmd/any-hub + $(GO) build . fmt: - $(GO)fmt ./cmd ./internal ./tests + $(GO) fmt ./... test: $(GO) test ./... @@ -16,4 +16,4 @@ test-all: GOCACHE=$(GOCACHE) $(GO) test ./... run: - $(GO) run ./cmd/any-hub --config ./config.toml \ No newline at end of file + $(GO) run . --config ./config.toml diff --git a/logging_integration_test.go b/logging_integration_test.go new file mode 100644 index 0000000..af5fb48 --- /dev/null +++ b/logging_integration_test.go @@ -0,0 +1,52 @@ +package main + +import ( + "bytes" + "fmt" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestLoggingFallbackToStdout(t *testing.T) { + dir := t.TempDir() + blocked := filepath.Join(dir, "blocked") + if err := os.Mkdir(blocked, 0o755); err != nil { + t.Fatalf("创建目录失败: %v", err) + } + if err := os.Chmod(blocked, 0o000); err != nil { + t.Fatalf("设置目录权限失败: %v", err) + } + t.Cleanup(func() { _ = os.Chmod(blocked, 0o755) }) + + logPath := filepath.Join(blocked, "sub", "any-hub.log") + configPath := writeConfigFile(t, fmt.Sprintf(` +LogLevel = "info" +LogFilePath = "%s" +StoragePath = "%s" +ListenPort = 5000 + +[[Hub]] +Name = "docker" +Domain = "docker.local" +Upstream = "https://registry-1.docker.io" +Type = "docker" +`, logPath, filepath.Join(dir, "storage"))) + + useBufferWriters(t) + code := run(cliOptions{configPath: configPath, checkOnly: true}) + if code != 0 { + t.Fatalf("日志 fallback 不应导致失败,得到 %d", code) + } + t.Log(stdOut.(*bytes.Buffer).String()) +} + +func writeConfigFile(t *testing.T, content string) string { + t.Helper() + file := filepath.Join(t.TempDir(), "config.toml") + if err := os.WriteFile(file, []byte(strings.TrimSpace(content)), 0o600); err != nil { + t.Fatalf("写入配置失败: %v", err) + } + return file +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..2b18287 --- /dev/null +++ b/main.go @@ -0,0 +1,151 @@ +package main + +import ( + "flag" + "fmt" + "io" + "os" + + "github.com/sirupsen/logrus" + + "github.com/any-hub/any-hub/internal/cache" + "github.com/any-hub/any-hub/internal/config" + "github.com/any-hub/any-hub/internal/logging" + "github.com/any-hub/any-hub/internal/proxy" + "github.com/any-hub/any-hub/internal/server" + "github.com/any-hub/any-hub/internal/version" +) + +// cliOptions 汇总 CLI 标志解析后的结果,便于在测试中注入。 +type cliOptions struct { + configPath string + checkOnly bool + showVersion bool +} + +var ( + stdOut io.Writer = os.Stdout + stdErr io.Writer = os.Stderr +) + +func main() { + opts, err := parseCLIFlags(os.Args[1:]) + if err != nil { + fmt.Fprintln(stdErr, err.Error()) + os.Exit(2) + } + os.Exit(run(opts)) +} + +// run 根据解析到的 CLI 选项执行业务流程,并返回退出码,方便测试。 +func run(opts cliOptions) int { + if opts.showVersion { + printVersion() + return 0 + } + + cfg, err := config.Load(opts.configPath) + if err != nil { + fmt.Fprintf(stdErr, "加载配置失败: %v\n", err) + return 1 + } + + logger, err := logging.InitLogger(cfg.Global) + if err != nil { + fmt.Fprintf(stdErr, "初始化日志失败: %v\n", err) + return 1 + } + + if opts.checkOnly { + fields := logging.BaseFields("check_config", opts.configPath) + fields["hubs"] = len(cfg.Hubs) + fields["credentials"] = config.CredentialModes(cfg.Hubs) + fields["result"] = "ok" + logger.WithFields(fields).Info("配置校验通过") + return 0 + } + + registry, err := server.NewHubRegistry(cfg) + if err != nil { + fmt.Fprintf(stdErr, "构建 Hub 注册表失败: %v\n", err) + return 1 + } + + // CLI 启动遵循“配置 → HubRegistry → 磁盘缓存 → Fiber server”顺序, + // 保证所有请求共享统一的路由与缓存实例,方便观察 cache/log 指标。 + store, err := cache.NewStore(cfg.Global.StoragePath) + if err != nil { + fmt.Fprintf(stdErr, "初始化缓存目录失败: %v\n", err) + return 1 + } + + httpClient := server.NewUpstreamClient(cfg) + proxyHandler := proxy.NewHandler(httpClient, logger, store) + + fields := logging.BaseFields("startup", opts.configPath) + fields["hubs"] = len(cfg.Hubs) + fields["listen_port"] = cfg.Global.ListenPort + fields["credentials"] = config.CredentialModes(cfg.Hubs) + fields["version"] = version.Full() + logger.WithFields(fields).Info("配置加载完成") + + if err := startHTTPServer(cfg, registry, proxyHandler, logger); err != nil { + fmt.Fprintf(stdErr, "HTTP 服务启动失败: %v\n", err) + return 1 + } + return 0 +} + +// parseCLIFlags 解析 CLI 参数,并结合环境变量计算最终的配置路径。 +func parseCLIFlags(args []string) (cliOptions, error) { + fs := flag.NewFlagSet("any-hub", flag.ContinueOnError) + fs.SetOutput(io.Discard) + + var ( + configFlag string + checkOnly bool + showVer bool + ) + + fs.StringVar(&configFlag, "config", "", "配置文件路径(默认 ./config.toml,可被 ANY_HUB_CONFIG 覆盖)") + fs.BoolVar(&checkOnly, "check-config", false, "仅校验配置后退出") + fs.BoolVar(&showVer, "version", false, "显示版本信息") + + if err := fs.Parse(args); err != nil { + return cliOptions{}, fmt.Errorf("解析参数失败: %w", err) + } + + path := os.Getenv("ANY_HUB_CONFIG") + if configFlag != "" { + path = configFlag + } + if path == "" { + path = "config.toml" + } + + return cliOptions{ + configPath: path, + checkOnly: checkOnly, + showVersion: showVer, + }, nil +} + +func startHTTPServer(cfg *config.Config, registry *server.HubRegistry, proxyHandler server.ProxyHandler, logger *logrus.Logger) error { + port := cfg.Global.ListenPort + app, err := server.NewApp(server.AppOptions{ + Logger: logger, + Registry: registry, + Proxy: proxyHandler, + ListenPort: port, + }) + if err != nil { + return err + } + + logger.WithFields(logrus.Fields{ + "action": "listen", + "port": port, + }).Info("Fiber 服务启动") + + return app.Listen(fmt.Sprintf(":%d", port)) +} diff --git a/main_test.go b/main_test.go new file mode 100644 index 0000000..feb5743 --- /dev/null +++ b/main_test.go @@ -0,0 +1,54 @@ +package main + +import ( + "bytes" + "strings" + "testing" +) + +func TestParseCLIFlagsPriority(t *testing.T) { + t.Setenv("ANY_HUB_CONFIG", "/tmp/env.toml") + + opts, err := parseCLIFlags([]string{}) + if err != nil { + t.Fatalf("解析失败: %v", err) + } + if opts.configPath != "/tmp/env.toml" { + t.Fatalf("应优先使用环境变量,得到 %s", opts.configPath) + } + + opts, err = parseCLIFlags([]string{"--config", "/tmp/flag.toml"}) + if err != nil { + t.Fatalf("解析失败: %v", err) + } + if opts.configPath != "/tmp/flag.toml" { + t.Fatalf("flag 应高于环境变量,得到 %s", opts.configPath) + } +} + +func TestRunCheckConfigSuccess(t *testing.T) { + useBufferWriters(t) + code := run(cliOptions{configPath: configFixture(t, "valid.toml"), checkOnly: true}) + if code != 0 { + t.Fatalf("期望退出码 0,得到 %d", code) + } +} + +func TestRunCheckConfigFailure(t *testing.T) { + useBufferWriters(t) + code := run(cliOptions{configPath: configFixture(t, "missing.toml"), checkOnly: true}) + if code == 0 { + t.Fatalf("无效配置应返回非零退出码") + } +} + +func TestRunVersionOutput(t *testing.T) { + useBufferWriters(t) + code := run(cliOptions{showVersion: true}) + if code != 0 { + t.Fatalf("version 模式应成功退出,得到 %d", code) + } + if !strings.Contains(stdOut.(*bytes.Buffer).String(), "any-hub") { + t.Fatalf("version 输出应包含 any-hub 标识") + } +} diff --git a/main_test_helpers.go b/main_test_helpers.go new file mode 100644 index 0000000..38fdf02 --- /dev/null +++ b/main_test_helpers.go @@ -0,0 +1,38 @@ +package main + +import ( + "bytes" + "testing" +) + +// useBufferWriters swaps stdOut/stdErr with in-memory buffers for the duration +// of a test, allowing assertions on CLI output without polluting test logs. +func useBufferWriters(t *testing.T) { + t.Helper() + + outBuf := &bytes.Buffer{} + errBuf := &bytes.Buffer{} + + prevOut := stdOut + prevErr := stdErr + + stdOut = outBuf + stdErr = errBuf + + t.Cleanup(func() { + stdOut = prevOut + stdErr = prevErr + }) +} + +// stdOutBuffer returns the in-use stdout buffer when useBufferWriters is active. +func stdOutBuffer() *bytes.Buffer { + buf, _ := stdOut.(*bytes.Buffer) + return buf +} + +// stdErrBuffer returns the in-use stderr buffer when useBufferWriters is active. +func stdErrBuffer() *bytes.Buffer { + buf, _ := stdErr.(*bytes.Buffer) + return buf +} diff --git a/scripts/demo-proxy.sh b/scripts/demo-proxy.sh index 49e93d5..33fe39a 100755 --- a/scripts/demo-proxy.sh +++ b/scripts/demo-proxy.sh @@ -10,4 +10,4 @@ if [[ ! -f "${CONFIG}" ]]; then fi echo "Starting any-hub with ${CONFIG}" -exec go run ./cmd/any-hub --config "${CONFIG}" +exec go run . --config "${CONFIG}" diff --git a/test_helpers_test.go b/test_helpers_test.go new file mode 100644 index 0000000..d517d3f --- /dev/null +++ b/test_helpers_test.go @@ -0,0 +1,29 @@ +package main + +import ( + "path/filepath" + "runtime" + "testing" +) + +var repoRoot string + +func init() { + _, file, _, ok := runtime.Caller(0) + if ok { + repoRoot = filepath.Join(filepath.Dir(file), "..", "..") + } +} + +func projectRoot(t *testing.T) string { + t.Helper() + if repoRoot == "" { + t.Fatal("无法定位项目根目录") + } + return repoRoot +} + +func configFixture(t *testing.T, name string) string { + t.Helper() + return filepath.Join(projectRoot(t), "internal", "config", "testdata", name) +} diff --git a/version.go b/version.go new file mode 100644 index 0000000..745e3b0 --- /dev/null +++ b/version.go @@ -0,0 +1,12 @@ +package main + +import ( + "fmt" + + "github.com/any-hub/any-hub/internal/version" +) + +// printVersion 输出注入的版本 + 提交信息。 +func printVersion() { + fmt.Fprintln(stdOut, version.Full()) +}