Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9692219e0f | |||
| 83bb6623e8 | |||
| 960cf59a1d | |||
| 53d581eb9f | |||
| 60fac006e3 | |||
| b2b813c389 |
@@ -2,6 +2,8 @@
|
|||||||
.git
|
.git
|
||||||
.github
|
.github
|
||||||
.codex
|
.codex
|
||||||
|
.specify
|
||||||
|
configs
|
||||||
node_modules/
|
node_modules/
|
||||||
coverage/
|
coverage/
|
||||||
logs
|
logs
|
||||||
|
|||||||
24
.github/workflows/docker-release.yml
vendored
24
.github/workflows/docker-release.yml
vendored
@@ -4,6 +4,12 @@ on:
|
|||||||
push:
|
push:
|
||||||
tags:
|
tags:
|
||||||
- "v*"
|
- "v*"
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
image_tag:
|
||||||
|
description: "Tag to apply when running manually (e.g. dev-build)"
|
||||||
|
required: false
|
||||||
|
default: dev-build
|
||||||
|
|
||||||
env:
|
env:
|
||||||
GHCR_IMAGE: ghcr.io/${{ github.repository_owner }}/any-hub
|
GHCR_IMAGE: ghcr.io/${{ github.repository_owner }}/any-hub
|
||||||
@@ -21,6 +27,17 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Resolve version tag
|
||||||
|
id: vars
|
||||||
|
env:
|
||||||
|
INPUT_TAG: ${{ inputs.image_tag }}
|
||||||
|
run: |
|
||||||
|
VERSION="$GITHUB_REF_NAME"
|
||||||
|
if [ "$GITHUB_REF_TYPE" != "tag" ] || [ -z "$VERSION" ]; then
|
||||||
|
VERSION="${INPUT_TAG:-manual-${GITHUB_RUN_NUMBER}}"
|
||||||
|
fi
|
||||||
|
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
- name: Set up QEMU
|
- name: Set up QEMU
|
||||||
uses: docker/setup-qemu-action@v3
|
uses: docker/setup-qemu-action@v3
|
||||||
|
|
||||||
@@ -49,8 +66,9 @@ jobs:
|
|||||||
${{ env.DOCKERHUB_IMAGE }}
|
${{ env.DOCKERHUB_IMAGE }}
|
||||||
tags: |
|
tags: |
|
||||||
type=ref,event=tag
|
type=ref,event=tag
|
||||||
type=semver,pattern={{version}}
|
type=semver,pattern={{version}},event=tag
|
||||||
type=semver,pattern={{major}}.{{minor}}
|
type=semver,pattern={{major}}.{{minor}},event=tag
|
||||||
|
type=raw,value=${{ steps.vars.outputs.version }},enable=${{ github.event_name == 'workflow_dispatch' }}
|
||||||
labels: |
|
labels: |
|
||||||
org.opencontainers.image.source=${{ github.repository }}
|
org.opencontainers.image.source=${{ github.repository }}
|
||||||
|
|
||||||
@@ -63,5 +81,5 @@ jobs:
|
|||||||
tags: ${{ steps.meta.outputs.tags }}
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
labels: ${{ steps.meta.outputs.labels }}
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
build-args: |
|
build-args: |
|
||||||
VERSION=${{ github.ref_name }}
|
VERSION=${{ steps.vars.outputs.version }}
|
||||||
COMMIT=${{ github.sha }}
|
COMMIT=${{ github.sha }}
|
||||||
|
|||||||
@@ -7,12 +7,11 @@ ARG VERSION=dev
|
|||||||
ARG COMMIT=dev
|
ARG COMMIT=dev
|
||||||
WORKDIR /src
|
WORKDIR /src
|
||||||
COPY go.mod go.sum ./
|
COPY go.mod go.sum ./
|
||||||
RUN --mount=type=cache,target=/go/pkg/mod \
|
RUN --mount=type=cache,target=/go/pkg/mod go mod download
|
||||||
go mod download
|
|
||||||
COPY . .
|
COPY . .
|
||||||
RUN --mount=type=cache,target=/root/.cache/go-build \
|
RUN --mount=type=cache,target=/root/.cache/go-build \
|
||||||
CGO_ENABLED=0 GOOS=$TARGETOS GOARCH=$TARGETARCH \
|
CGO_ENABLED=0 GOOS=$TARGETOS GOARCH=$TARGETARCH \
|
||||||
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 ./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
|
FROM gcr.io/distroless/static-debian12:nonroot
|
||||||
COPY --from=builder /out/any-hub /usr/local/bin/any-hub
|
COPY --from=builder /out/any-hub /usr/local/bin/any-hub
|
||||||
|
|||||||
6
Makefile
6
Makefile
@@ -4,10 +4,10 @@ GOCACHE ?= /tmp/go-build
|
|||||||
.PHONY: build fmt test test-all run
|
.PHONY: build fmt test test-all run
|
||||||
|
|
||||||
build:
|
build:
|
||||||
$(GO) build ./cmd/any-hub
|
$(GO) build .
|
||||||
|
|
||||||
fmt:
|
fmt:
|
||||||
$(GO)fmt ./cmd ./internal ./tests
|
$(GO) fmt ./...
|
||||||
|
|
||||||
test:
|
test:
|
||||||
$(GO) test ./...
|
$(GO) test ./...
|
||||||
@@ -16,4 +16,4 @@ test-all:
|
|||||||
GOCACHE=$(GOCACHE) $(GO) test ./...
|
GOCACHE=$(GOCACHE) $(GO) test ./...
|
||||||
|
|
||||||
run:
|
run:
|
||||||
$(GO) run ./cmd/any-hub --config ./config.toml
|
$(GO) run . --config ./config.toml
|
||||||
|
|||||||
2
internal/cache/doc.go
vendored
2
internal/cache/doc.go
vendored
@@ -1,5 +1,5 @@
|
|||||||
// Package cache defines the disk-backed store responsible for translating hub
|
// Package cache defines the disk-backed store responsible for translating hub
|
||||||
// requests into StoragePath/<hub>/<path> files. The store exposes read/write
|
// requests into StoragePath/<hub>/<path>.body files. The store exposes read/write
|
||||||
// primitives with safe semantics (temp file + rename) and surfaces file info
|
// primitives with safe semantics (temp file + rename) and surfaces file info
|
||||||
// (size, modtime) for higher layers to implement conditional revalidation.
|
// (size, modtime) for higher layers to implement conditional revalidation.
|
||||||
// Proxy handlers depend on this package to stream cached responses or trigger
|
// Proxy handlers depend on this package to stream cached responses or trigger
|
||||||
|
|||||||
163
internal/cache/fs_store.go
vendored
163
internal/cache/fs_store.go
vendored
@@ -11,9 +11,12 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const cacheFileSuffix = ".body"
|
||||||
|
|
||||||
// NewStore 以 basePath 为根目录构建磁盘缓存,整站复用一份实例。
|
// NewStore 以 basePath 为根目录构建磁盘缓存,整站复用一份实例。
|
||||||
func NewStore(basePath string) (Store, error) {
|
func NewStore(basePath string) (Store, error) {
|
||||||
if basePath == "" {
|
if basePath == "" {
|
||||||
@@ -55,27 +58,13 @@ func (s *fileStore) Get(ctx context.Context, locator Locator) (*ReadResult, erro
|
|||||||
default:
|
default:
|
||||||
}
|
}
|
||||||
|
|
||||||
filePath, err := s.path(locator)
|
primary, legacy, err := s.entryPaths(locator)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
info, err := os.Stat(filePath)
|
filePath, info, f, err := s.openEntryFile(primary, legacy)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, fs.ErrNotExist) {
|
|
||||||
return nil, ErrNotFound
|
|
||||||
}
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if info.IsDir() {
|
|
||||||
return nil, ErrNotFound
|
|
||||||
}
|
|
||||||
|
|
||||||
f, err := os.Open(filePath)
|
|
||||||
if err != nil {
|
|
||||||
if errors.Is(err, fs.ErrNotExist) {
|
|
||||||
return nil, ErrNotFound
|
|
||||||
}
|
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -99,12 +88,12 @@ func (s *fileStore) Put(ctx context.Context, locator Locator, body io.Reader, op
|
|||||||
}
|
}
|
||||||
defer unlock()
|
defer unlock()
|
||||||
|
|
||||||
filePath, err := s.path(locator)
|
filePath, legacyPath, err := s.entryPaths(locator)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := os.MkdirAll(filepath.Dir(filePath), 0o755); err != nil {
|
if err := s.ensureDirWithUpgrade(filepath.Dir(filePath)); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -136,6 +125,7 @@ func (s *fileStore) Put(ctx context.Context, locator Locator, body io.Reader, op
|
|||||||
if err := os.Chtimes(filePath, modTime, modTime); err != nil {
|
if err := os.Chtimes(filePath, modTime, modTime); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
_ = os.Remove(legacyPath)
|
||||||
|
|
||||||
entry := Entry{
|
entry := Entry{
|
||||||
Locator: locator,
|
Locator: locator,
|
||||||
@@ -153,13 +143,16 @@ func (s *fileStore) Remove(ctx context.Context, locator Locator) error {
|
|||||||
}
|
}
|
||||||
defer unlock()
|
defer unlock()
|
||||||
|
|
||||||
filePath, err := s.path(locator)
|
filePath, legacyPath, err := s.entryPaths(locator)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err := os.Remove(filePath); err != nil && !errors.Is(err, fs.ErrNotExist) {
|
if err := os.Remove(filePath); err != nil && !errors.Is(err, fs.ErrNotExist) {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if err := os.Remove(legacyPath); err != nil && !errors.Is(err, fs.ErrNotExist) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -201,13 +194,141 @@ func (s *fileStore) path(locator Locator) (string, error) {
|
|||||||
rel = "root"
|
rel = "root"
|
||||||
}
|
}
|
||||||
|
|
||||||
filePath := filepath.Join(s.basePath, locator.HubName, filepath.FromSlash(rel))
|
hubRoot := filepath.Join(s.basePath, locator.HubName)
|
||||||
if !strings.HasPrefix(filePath, filepath.Join(s.basePath, locator.HubName)) {
|
filePath := filepath.Join(hubRoot, filepath.FromSlash(rel))
|
||||||
|
hubPrefix := hubRoot + string(os.PathSeparator)
|
||||||
|
if filePath != hubRoot && !strings.HasPrefix(filePath, hubPrefix) {
|
||||||
return "", errors.New("invalid cache path")
|
return "", errors.New("invalid cache path")
|
||||||
}
|
}
|
||||||
return filePath, nil
|
return filePath, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *fileStore) entryPaths(locator Locator) (string, string, error) {
|
||||||
|
legacyPath, err := s.path(locator)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
return legacyPath + cacheFileSuffix, legacyPath, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *fileStore) openEntryFile(primaryPath, legacyPath string) (string, fs.FileInfo, *os.File, error) {
|
||||||
|
info, err := os.Stat(primaryPath)
|
||||||
|
if err == nil {
|
||||||
|
if info.IsDir() {
|
||||||
|
return "", nil, nil, ErrNotFound
|
||||||
|
}
|
||||||
|
f, err := os.Open(primaryPath)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, fs.ErrNotExist) || isNotDirError(err) {
|
||||||
|
return "", nil, nil, ErrNotFound
|
||||||
|
}
|
||||||
|
return "", nil, nil, err
|
||||||
|
}
|
||||||
|
return primaryPath, info, f, nil
|
||||||
|
}
|
||||||
|
if !errors.Is(err, fs.ErrNotExist) && !isNotDirError(err) {
|
||||||
|
return "", nil, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
info, err = os.Stat(legacyPath)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, fs.ErrNotExist) || isNotDirError(err) {
|
||||||
|
return "", nil, nil, ErrNotFound
|
||||||
|
}
|
||||||
|
return "", nil, nil, err
|
||||||
|
}
|
||||||
|
if info.IsDir() {
|
||||||
|
return "", nil, nil, ErrNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
if migrateErr := s.migrateLegacyFile(primaryPath, legacyPath); migrateErr == nil {
|
||||||
|
return s.openEntryFile(primaryPath, legacyPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
f, err := os.Open(legacyPath)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, fs.ErrNotExist) || isNotDirError(err) {
|
||||||
|
return "", nil, nil, ErrNotFound
|
||||||
|
}
|
||||||
|
return "", nil, nil, err
|
||||||
|
}
|
||||||
|
return legacyPath, info, f, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *fileStore) migrateLegacyFile(primaryPath, legacyPath string) error {
|
||||||
|
if legacyPath == "" || primaryPath == legacyPath {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(legacyPath); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(primaryPath); err == nil {
|
||||||
|
if removeErr := os.Remove(legacyPath); removeErr != nil && !errors.Is(removeErr, fs.ErrNotExist) {
|
||||||
|
return removeErr
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return os.Rename(legacyPath, primaryPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *fileStore) ensureDirWithUpgrade(dir string) error {
|
||||||
|
for i := 0; i < 8; i++ {
|
||||||
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||||
|
if isNotDirError(err) {
|
||||||
|
var pathErr *os.PathError
|
||||||
|
if errors.As(err, &pathErr) {
|
||||||
|
if upgradeErr := s.upgradeLegacyNode(pathErr.Path); upgradeErr != nil {
|
||||||
|
return upgradeErr
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return fmt.Errorf("ensure cache directory failed for %s", dir)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *fileStore) upgradeLegacyNode(conflictPath string) error {
|
||||||
|
if conflictPath == "" {
|
||||||
|
return errors.New("empty conflict path")
|
||||||
|
}
|
||||||
|
rel, err := filepath.Rel(s.basePath, conflictPath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(rel, "..") {
|
||||||
|
return fmt.Errorf("conflict path outside storage: %s", conflictPath)
|
||||||
|
}
|
||||||
|
info, err := os.Stat(conflictPath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if info.IsDir() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if strings.HasSuffix(conflictPath, cacheFileSuffix) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
newPath := conflictPath + cacheFileSuffix
|
||||||
|
if _, err := os.Stat(newPath); err == nil {
|
||||||
|
return os.Remove(conflictPath)
|
||||||
|
}
|
||||||
|
return os.Rename(conflictPath, newPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
func isNotDirError(err error) bool {
|
||||||
|
if err == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
var pathErr *os.PathError
|
||||||
|
if errors.As(err, &pathErr) {
|
||||||
|
return errors.Is(pathErr.Err, syscall.ENOTDIR)
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
func copyWithContext(ctx context.Context, dst io.Writer, src io.Reader) (int64, error) {
|
func copyWithContext(ctx context.Context, dst io.Writer, src io.Reader) (int64, error) {
|
||||||
var copied int64
|
var copied int64
|
||||||
buf := make([]byte, 32*1024)
|
buf := make([]byte, 32*1024)
|
||||||
|
|||||||
2
internal/cache/store.go
vendored
2
internal/cache/store.go
vendored
@@ -9,7 +9,7 @@ import (
|
|||||||
|
|
||||||
// Store 负责管理磁盘缓存的读写。磁盘布局遵循:
|
// Store 负责管理磁盘缓存的读写。磁盘布局遵循:
|
||||||
//
|
//
|
||||||
// <StoragePath>/<HubName>/<path> # 实际正文
|
// <StoragePath>/<HubName>/<path>.body # 实际正文
|
||||||
//
|
//
|
||||||
// 每个条目仅由正文文件组成,文件的 ModTime/Size 由文件系统提供。
|
// 每个条目仅由正文文件组成,文件的 ModTime/Size 由文件系统提供。
|
||||||
type Store interface {
|
type Store interface {
|
||||||
|
|||||||
85
internal/cache/store_test.go
vendored
85
internal/cache/store_test.go
vendored
@@ -3,8 +3,12 @@ package cache
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"io"
|
"io"
|
||||||
|
"io/fs"
|
||||||
"os"
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
@@ -38,6 +42,9 @@ func TestStorePutAndGet(t *testing.T) {
|
|||||||
if !result.Entry.ModTime.Equal(modTime) {
|
if !result.Entry.ModTime.Equal(modTime) {
|
||||||
t.Fatalf("modtime mismatch: expected %v got %v", modTime, result.Entry.ModTime)
|
t.Fatalf("modtime mismatch: expected %v got %v", modTime, result.Entry.ModTime)
|
||||||
}
|
}
|
||||||
|
if !strings.HasSuffix(result.Entry.FilePath, cacheFileSuffix) {
|
||||||
|
t.Fatalf("expected cache file suffix %s, got %s", cacheFileSuffix, result.Entry.FilePath)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestStoreGetMissing(t *testing.T) {
|
func TestStoreGetMissing(t *testing.T) {
|
||||||
@@ -75,7 +82,7 @@ func TestStoreIgnoresDirectories(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("path error: %v", err)
|
t.Fatalf("path error: %v", err)
|
||||||
}
|
}
|
||||||
if err := os.MkdirAll(filePath, 0o755); err != nil {
|
if err := os.MkdirAll(filePath+cacheFileSuffix, 0o755); err != nil {
|
||||||
t.Fatalf("mkdir error: %v", err)
|
t.Fatalf("mkdir error: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,6 +91,82 @@ func TestStoreIgnoresDirectories(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestStoreMigratesLegacyEntryOnGet(t *testing.T) {
|
||||||
|
store := newTestStore(t)
|
||||||
|
fs, ok := store.(*fileStore)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("unexpected store type %T", store)
|
||||||
|
}
|
||||||
|
locator := Locator{HubName: "npm", Path: "/pkg"}
|
||||||
|
legacyPath, err := fs.path(locator)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("path error: %v", err)
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(filepath.Dir(legacyPath), 0o755); err != nil {
|
||||||
|
t.Fatalf("mkdir error: %v", err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(legacyPath, []byte("legacy"), 0o644); err != nil {
|
||||||
|
t.Fatalf("write legacy error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := store.Get(context.Background(), locator)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("get legacy error: %v", err)
|
||||||
|
}
|
||||||
|
body, err := io.ReadAll(result.Reader)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read legacy error: %v", err)
|
||||||
|
}
|
||||||
|
result.Reader.Close()
|
||||||
|
if string(body) != "legacy" {
|
||||||
|
t.Fatalf("unexpected legacy body: %s", string(body))
|
||||||
|
}
|
||||||
|
if !strings.HasSuffix(result.Entry.FilePath, cacheFileSuffix) {
|
||||||
|
t.Fatalf("expected migrated file suffix, got %s", result.Entry.FilePath)
|
||||||
|
}
|
||||||
|
if _, statErr := os.Stat(legacyPath); !errors.Is(statErr, fs.ErrNotExist) {
|
||||||
|
t.Fatalf("expected legacy path removed, got %v", statErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStoreHandlesAncestorFileConflict(t *testing.T) {
|
||||||
|
store := newTestStore(t)
|
||||||
|
fs, ok := store.(*fileStore)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("unexpected store type %T", store)
|
||||||
|
}
|
||||||
|
metaLocator := Locator{HubName: "npm", Path: "/pkg"}
|
||||||
|
legacyPath, err := fs.path(metaLocator)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("path error: %v", err)
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(filepath.Dir(legacyPath), 0o755); err != nil {
|
||||||
|
t.Fatalf("mkdir error: %v", err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(legacyPath, []byte("legacy"), 0o644); err != nil {
|
||||||
|
t.Fatalf("write legacy error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tarLocator := Locator{HubName: "npm", Path: "/pkg/-/pkg-1.0.0.tgz"}
|
||||||
|
if _, err := store.Put(context.Background(), tarLocator, bytes.NewReader([]byte("tar")), PutOptions{}); err != nil {
|
||||||
|
t.Fatalf("put tar error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := os.Stat(legacyPath); !errors.Is(err, fs.ErrNotExist) {
|
||||||
|
t.Fatalf("expected legacy metadata renamed, got %v", err)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(legacyPath + cacheFileSuffix); err != nil {
|
||||||
|
t.Fatalf("expected migrated legacy cache, got %v", err)
|
||||||
|
}
|
||||||
|
primary, _, err := fs.entryPaths(tarLocator)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("entry path error: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(primary); err != nil {
|
||||||
|
t.Fatalf("expected tar cache file, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// newTestStore returns a Store backed by a temporary directory.
|
// newTestStore returns a Store backed by a temporary directory.
|
||||||
func newTestStore(t *testing.T) Store {
|
func newTestStore(t *testing.T) Store {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|||||||
52
logging_integration_test.go
Normal file
52
logging_integration_test.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
151
main.go
Normal file
151
main.go
Normal file
@@ -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))
|
||||||
|
}
|
||||||
54
main_test.go
Normal file
54
main_test.go
Normal file
@@ -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 标识")
|
||||||
|
}
|
||||||
|
}
|
||||||
38
main_test_helpers.go
Normal file
38
main_test_helpers.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
@@ -10,4 +10,4 @@ if [[ ! -f "${CONFIG}" ]]; then
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
echo "Starting any-hub with ${CONFIG}"
|
echo "Starting any-hub with ${CONFIG}"
|
||||||
exec go run ./cmd/any-hub --config "${CONFIG}"
|
exec go run . --config "${CONFIG}"
|
||||||
|
|||||||
29
test_helpers_test.go
Normal file
29
test_helpers_test.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
12
version.go
Normal file
12
version.go
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/any-hub/any-hub/internal/version"
|
||||||
|
)
|
||||||
|
|
||||||
|
// printVersion 输出注入的版本 + 提交信息。
|
||||||
|
func printVersion() {
|
||||||
|
fmt.Fprintln(stdOut, version.Full())
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user