开发检查点:完善多项目发布中心基础能力
This commit is contained in:
parent
e0ad5ea50a
commit
82bf06eca6
@ -46,11 +46,12 @@ func main() {
|
||||
func legacyCheckCommand(args []string) {
|
||||
flags := flag.NewFlagSet("check", flag.ExitOnError)
|
||||
server := flags.String("server", "http://localhost:8080", "服务端地址")
|
||||
project := flags.String("project", "", "项目标识;为空时使用兼容接口")
|
||||
name := flags.String("package", "", "包名")
|
||||
current := flags.String("current", "", "当前版本")
|
||||
flags.Parse(args)
|
||||
requirePackage(*name)
|
||||
api := newClient(*server, "")
|
||||
api := newClient(*server, "", *project)
|
||||
latest, available, err := api.CheckUpdate(context.Background(), *name, *current)
|
||||
if err != nil {
|
||||
fatal("检查更新失败: %v", err)
|
||||
@ -65,6 +66,7 @@ func legacyCheckCommand(args []string) {
|
||||
func legacyDownloadCommand(args []string) {
|
||||
flags := flag.NewFlagSet("download", flag.ExitOnError)
|
||||
server := flags.String("server", "http://localhost:8080", "服务端地址")
|
||||
project := flags.String("project", "", "项目标识;为空时使用兼容接口")
|
||||
name := flags.String("package", "", "包名")
|
||||
version := flags.String("version", "", "版本;为空时下载最新版本")
|
||||
output := flags.String("output", "", "保存路径")
|
||||
@ -77,7 +79,7 @@ func legacyDownloadCommand(args []string) {
|
||||
}
|
||||
*output = filepath.Join(".", fmt.Sprintf("%s-%s.pkg", *name, resolvedVersion))
|
||||
}
|
||||
api := newClient(*server, "")
|
||||
api := newClient(*server, "", *project)
|
||||
item, err := api.Download(context.Background(), *name, *version, *output)
|
||||
if err != nil {
|
||||
fatal("下载失败: %v", err)
|
||||
@ -88,6 +90,7 @@ func legacyDownloadCommand(args []string) {
|
||||
func legacyUploadCommand(args []string) {
|
||||
flags := flag.NewFlagSet("upload", flag.ExitOnError)
|
||||
server := flags.String("server", "http://localhost:8080", "服务端地址")
|
||||
project := flags.String("project", "", "项目标识;为空时使用兼容接口")
|
||||
token := flags.String("token", "", "开发者令牌")
|
||||
name := flags.String("package", "", "包名")
|
||||
version := flags.String("version", "", "版本")
|
||||
@ -105,7 +108,7 @@ func legacyUploadCommand(args []string) {
|
||||
fatal("打开包文件失败: %v", err)
|
||||
}
|
||||
defer file.Close()
|
||||
api := newClient(*server, *token)
|
||||
api := newClient(*server, *token, *project)
|
||||
item, err := api.Upload(context.Background(), client.UploadRequest{
|
||||
Name: *name,
|
||||
Version: *version,
|
||||
@ -124,9 +127,10 @@ func legacyUploadCommand(args []string) {
|
||||
func legacyWatchCommand(args []string) {
|
||||
flags := flag.NewFlagSet("watch", flag.ExitOnError)
|
||||
server := flags.String("server", "http://localhost:8080", "服务端地址")
|
||||
project := flags.String("project", "", "项目标识;为空时使用兼容接口")
|
||||
packages := flags.String("packages", "", "要监听的包名,多个包用逗号分隔")
|
||||
flags.Parse(args)
|
||||
api := newClient(*server, "")
|
||||
api := newClient(*server, "", *project)
|
||||
updates, err := api.WatchUpdates(context.Background(), splitPackages(*packages)...)
|
||||
if err != nil {
|
||||
fatal("连接更新通知失败: %v", err)
|
||||
@ -137,8 +141,12 @@ func legacyWatchCommand(args []string) {
|
||||
}
|
||||
}
|
||||
|
||||
func newClient(server, token string) *client.Client {
|
||||
api, err := client.New(server, client.WithDeveloperToken(token))
|
||||
func newClient(server, token string, projects ...string) *client.Client {
|
||||
options := []client.Option{client.WithDeveloperToken(token)}
|
||||
if len(projects) > 0 && strings.TrimSpace(projects[0]) != "" {
|
||||
options = append(options, client.WithProject(projects[0]))
|
||||
}
|
||||
api, err := client.New(server, options...)
|
||||
if err != nil {
|
||||
fatal("创建客户端失败: %v", err)
|
||||
}
|
||||
|
||||
@ -23,6 +23,7 @@ type targetFlags struct {
|
||||
|
||||
type managerFlags struct {
|
||||
server *string
|
||||
project *string
|
||||
packageName *string
|
||||
rootDir *string
|
||||
currentVersion *string
|
||||
@ -38,12 +39,13 @@ type managerFlags struct {
|
||||
func checkCommand(args []string) {
|
||||
flags := flag.NewFlagSet("check", flag.ExitOnError)
|
||||
server := flags.String("server", "http://localhost:8080", "\u670d\u52a1\u7aef\u5730\u5740")
|
||||
project := flags.String("project", "", "\u9879\u76ee\u6807\u8bc6\uff1b\u4e3a\u7a7a\u65f6\u4f7f\u7528\u517c\u5bb9\u63a5\u53e3")
|
||||
name := flags.String("package", "", "\u5305\u540d")
|
||||
current := flags.String("current", "", "\u5f53\u524d\u7248\u672c")
|
||||
target := bindTargetFlags(flags)
|
||||
flags.Parse(args)
|
||||
requirePackage(*name)
|
||||
api := newClient(*server, "")
|
||||
api := newClient(*server, "", *project)
|
||||
latest, available, err := api.CheckUpdateFor(context.Background(), *name, *current, target.options(*current))
|
||||
if err != nil {
|
||||
fatal("\u68c0\u67e5\u66f4\u65b0\u5931\u8d25: %v", err)
|
||||
@ -58,6 +60,7 @@ func checkCommand(args []string) {
|
||||
func downloadCommand(args []string) {
|
||||
flags := flag.NewFlagSet("download", flag.ExitOnError)
|
||||
server := flags.String("server", "http://localhost:8080", "\u670d\u52a1\u7aef\u5730\u5740")
|
||||
project := flags.String("project", "", "\u9879\u76ee\u6807\u8bc6\uff1b\u4e3a\u7a7a\u65f6\u4f7f\u7528\u517c\u5bb9\u63a5\u53e3")
|
||||
name := flags.String("package", "", "\u5305\u540d")
|
||||
version := flags.String("version", "", "\u7248\u672c\uff1b\u4e3a\u7a7a\u65f6\u4e0b\u8f7d\u6700\u65b0\u7248\u672c")
|
||||
current := flags.String("current", "", "\u5f53\u524d\u7248\u672c")
|
||||
@ -72,7 +75,7 @@ func downloadCommand(args []string) {
|
||||
}
|
||||
*output = filepath.Join(".", fmt.Sprintf("%s-%s.pkg", *name, resolvedVersion))
|
||||
}
|
||||
api := newClient(*server, "")
|
||||
api := newClient(*server, "", *project)
|
||||
item, err := api.DownloadFor(context.Background(), *name, *version, *output, target.options(*current))
|
||||
if err != nil {
|
||||
fatal("\u4e0b\u8f7d\u5931\u8d25: %v", err)
|
||||
@ -83,6 +86,7 @@ func downloadCommand(args []string) {
|
||||
func uploadCommand(args []string) {
|
||||
flags := flag.NewFlagSet("upload", flag.ExitOnError)
|
||||
server := flags.String("server", "http://localhost:8080", "\u670d\u52a1\u7aef\u5730\u5740")
|
||||
project := flags.String("project", "", "\u9879\u76ee\u6807\u8bc6\uff1b\u4e3a\u7a7a\u65f6\u4f7f\u7528\u517c\u5bb9\u63a5\u53e3")
|
||||
token := flags.String("token", "", "\u5f00\u53d1\u8005\u4ee4\u724c")
|
||||
name := flags.String("package", "", "\u5305\u540d\uff1bScout \u5305\u53ef\u7701\u7565")
|
||||
version := flags.String("version", "", "\u7248\u672c\uff1bScout \u5305\u53ef\u7701\u7565")
|
||||
@ -120,7 +124,7 @@ func uploadCommand(args []string) {
|
||||
fatal("\u6253\u5f00\u5305\u6587\u4ef6\u5931\u8d25: %v", err)
|
||||
}
|
||||
defer file.Close()
|
||||
api := newClient(*server, *token)
|
||||
api := newClient(*server, *token, *project)
|
||||
item, err := api.Upload(context.Background(), client.UploadRequest{
|
||||
Name: *name,
|
||||
Version: *version,
|
||||
@ -143,11 +147,12 @@ func uploadCommand(args []string) {
|
||||
func watchCommand(args []string) {
|
||||
flags := flag.NewFlagSet("watch", flag.ExitOnError)
|
||||
server := flags.String("server", "http://localhost:8080", "\u670d\u52a1\u7aef\u5730\u5740")
|
||||
project := flags.String("project", "", "\u9879\u76ee\u6807\u8bc6\uff1b\u4e3a\u7a7a\u65f6\u4f7f\u7528\u517c\u5bb9\u63a5\u53e3")
|
||||
packages := flags.String("packages", "", "\u8981\u76d1\u542c\u7684\u5305\u540d\uff0c\u591a\u4e2a\u5305\u7528\u9017\u53f7\u5206\u9694")
|
||||
versions := flags.String("versions", "", "\u5f53\u524d\u7248\u672c\uff0c\u683c\u5f0f\u4e3a package:version\uff0c\u591a\u4e2a\u7528\u9017\u53f7\u5206\u9694")
|
||||
target := bindTargetFlags(flags)
|
||||
flags.Parse(args)
|
||||
api := newClient(*server, "")
|
||||
api := newClient(*server, "", *project)
|
||||
updates, err := api.WatchUpdatesFor(context.Background(), client.WatchRequest{
|
||||
Packages: splitPackages(*packages),
|
||||
Versions: parseVersions(*versions),
|
||||
@ -259,6 +264,7 @@ func bindManagerFlags(flags *flag.FlagSet) managerFlags {
|
||||
defaultPlatform, defaultArchitecture := scoutpkg.CurrentTarget()
|
||||
return managerFlags{
|
||||
server: flags.String("server", "http://localhost:8080", "\u670d\u52a1\u7aef\u5730\u5740"),
|
||||
project: flags.String("project", "", "\u9879\u76ee\u6807\u8bc6\uff1b\u4e3a\u7a7a\u65f6\u4f7f\u7528\u517c\u5bb9\u63a5\u53e3"),
|
||||
packageName: flags.String("package", "", "\u5305\u540d"),
|
||||
rootDir: flags.String("root", ".scout-update", "\u66f4\u65b0\u5de5\u4f5c\u76ee\u5f55"),
|
||||
currentVersion: flags.String("current", "", "\u5f53\u524d\u7248\u672c"),
|
||||
@ -274,7 +280,7 @@ func bindManagerFlags(flags *flag.FlagSet) managerFlags {
|
||||
|
||||
func newUpdateManager(input managerFlags) *updater.Manager {
|
||||
requirePackage(*input.packageName)
|
||||
api := newClient(*input.server, "")
|
||||
api := newClient(*input.server, "", *input.project)
|
||||
manager, err := updater.New(updater.Config{
|
||||
Client: api,
|
||||
PackageName: *input.packageName,
|
||||
|
||||
@ -28,6 +28,10 @@ func main() {
|
||||
if err != nil {
|
||||
logger.Fatal(err)
|
||||
}
|
||||
projectStore, err := registry.NewProjectRegistry(cfg.DataDir)
|
||||
if err != nil {
|
||||
logger.Fatal(err)
|
||||
}
|
||||
userStore, err := users.New(filepath.Join(cfg.DataDir, "users.json"))
|
||||
if err != nil {
|
||||
logger.Fatal(err)
|
||||
@ -40,6 +44,7 @@ func main() {
|
||||
|
||||
api := &httpapi.Server{
|
||||
Registry: store,
|
||||
Projects: projectStore,
|
||||
DeveloperToken: cfg.DeveloperToken,
|
||||
AllowLegacyToken: cfg.AllowLegacyToken,
|
||||
MaxUploadBytes: cfg.MaxUploadBytes,
|
||||
|
||||
@ -86,6 +86,7 @@ func (s *Server) upload(writer http.ResponseWriter, request *http.Request) {
|
||||
}
|
||||
|
||||
input := registry.Package{
|
||||
Project: s.projectScope,
|
||||
Name: strings.TrimSpace(request.FormValue("name")),
|
||||
Version: strings.TrimSpace(request.FormValue("version")),
|
||||
Description: strings.TrimSpace(request.FormValue("description")),
|
||||
|
||||
136
internal/httpapi/projects.go
Normal file
136
internal/httpapi/projects.go
Normal file
@ -0,0 +1,136 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"scout/internal/registry"
|
||||
"scout/internal/users"
|
||||
)
|
||||
|
||||
type projectCreateRequest struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
|
||||
func (s *Server) projectList(writer http.ResponseWriter, _ *http.Request) {
|
||||
projects := make([]registry.Project, 0, 1)
|
||||
projects = append(projects, registry.Project{
|
||||
ID: registry.DefaultProject,
|
||||
Name: "兼容项目",
|
||||
Status: "active",
|
||||
})
|
||||
if s.Projects != nil {
|
||||
projects = append(projects, s.Projects.List()...)
|
||||
}
|
||||
writeJSON(writer, http.StatusOK, map[string]any{"projects": projects})
|
||||
}
|
||||
|
||||
func (s *Server) projectCreate(writer http.ResponseWriter, request *http.Request) {
|
||||
if s.Projects == nil {
|
||||
writeError(writer, http.StatusServiceUnavailable, "项目管理尚未启用")
|
||||
return
|
||||
}
|
||||
if !s.requireRole(writer, request, users.RoleAdmin) {
|
||||
return
|
||||
}
|
||||
var input projectCreateRequest
|
||||
if err := decodeJSON(request, &input); err != nil {
|
||||
writeError(writer, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
project, err := s.Projects.Create(registry.Project{
|
||||
ID: input.ID,
|
||||
Name: input.Name,
|
||||
Description: input.Description,
|
||||
})
|
||||
if err != nil {
|
||||
status := http.StatusBadRequest
|
||||
if strings.Contains(err.Error(), "已经存在") {
|
||||
status = http.StatusConflict
|
||||
}
|
||||
writeError(writer, status, err.Error())
|
||||
return
|
||||
}
|
||||
s.sendAdminsEmail("Scout 创建了新项目", "项目 "+project.ID+" 已创建。")
|
||||
writeJSON(writer, http.StatusCreated, project)
|
||||
}
|
||||
|
||||
func (s *Server) projectRoute(writer http.ResponseWriter, request *http.Request) {
|
||||
if s.Projects == nil {
|
||||
writeError(writer, http.StatusServiceUnavailable, "项目管理尚未启用")
|
||||
return
|
||||
}
|
||||
parts := strings.Split(strings.Trim(strings.TrimPrefix(request.URL.Path, "/api/v1/projects/"), "/"), "/")
|
||||
if len(parts) == 0 || parts[0] == "" {
|
||||
writeError(writer, http.StatusNotFound, "项目不存在")
|
||||
return
|
||||
}
|
||||
projectID, err := url.PathUnescape(parts[0])
|
||||
if err != nil || registry.ValidateProjectID(projectID) != nil {
|
||||
writeError(writer, http.StatusBadRequest, "项目标识无效")
|
||||
return
|
||||
}
|
||||
if projectID == registry.DefaultProject {
|
||||
writeError(writer, http.StatusNotFound, "default 项目请使用兼容接口")
|
||||
return
|
||||
}
|
||||
project, exists := s.Projects.Get(projectID)
|
||||
if !exists {
|
||||
writeError(writer, http.StatusNotFound, "项目不存在")
|
||||
return
|
||||
}
|
||||
if len(parts) == 1 {
|
||||
if request.Method != http.MethodGet {
|
||||
writeError(writer, http.StatusMethodNotAllowed, "请求方法不支持")
|
||||
return
|
||||
}
|
||||
writeJSON(writer, http.StatusOK, project)
|
||||
return
|
||||
}
|
||||
if project.Status != "active" {
|
||||
writeError(writer, http.StatusForbidden, "项目当前已停用")
|
||||
return
|
||||
}
|
||||
store, ok := s.Projects.Store(projectID)
|
||||
if !ok {
|
||||
writeError(writer, http.StatusNotFound, "项目存储不存在")
|
||||
return
|
||||
}
|
||||
scoped := *s
|
||||
scoped.Registry = store
|
||||
scoped.projectScope = projectID
|
||||
if parts[1] == "packages" {
|
||||
if len(parts) == 2 && request.Method == http.MethodGet {
|
||||
scoped.packageList(writer, scopedRequest(request, "/api/v1/packages"))
|
||||
return
|
||||
}
|
||||
if len(parts) >= 3 && parts[2] == "upload" && request.Method == http.MethodPost {
|
||||
scoped.upload(writer, scopedRequest(request, "/api/v1/packages/upload"))
|
||||
return
|
||||
}
|
||||
if request.Method == http.MethodGet || request.Method == http.MethodHead {
|
||||
rest := strings.Join(parts[1:], "/")
|
||||
scoped.packageRoute(writer, scopedRequest(request, "/api/v1/"+rest))
|
||||
return
|
||||
}
|
||||
}
|
||||
if len(parts) == 2 && parts[1] == "updates" {
|
||||
writeError(writer, http.StatusNotFound, "接口不存在")
|
||||
return
|
||||
}
|
||||
if len(parts) == 3 && parts[1] == "updates" && parts[2] == "stream" && request.Method == http.MethodGet {
|
||||
scoped.stream(writer, scopedRequest(request, "/api/v1/updates/stream"))
|
||||
return
|
||||
}
|
||||
writeError(writer, http.StatusNotFound, "接口不存在")
|
||||
}
|
||||
|
||||
func scopedRequest(request *http.Request, path string) *http.Request {
|
||||
clone := request.Clone(request.Context())
|
||||
clone.URL.Path = path
|
||||
clone.RequestURI = ""
|
||||
return clone
|
||||
}
|
||||
117
internal/httpapi/projects_test.go
Normal file
117
internal/httpapi/projects_test.go
Normal file
@ -0,0 +1,117 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"scout/internal/registry"
|
||||
)
|
||||
|
||||
func TestProjectRoutesKeepSamePackageNamesIsolated(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
legacy, err := registry.New(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
projects, err := registry.NewProjectRegistry(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, _, err := projects.Ensure("ssl", "SSL", "证书项目"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, _, err := projects.Ensure("smail", "Smail", "邮件项目"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
server := httptest.NewServer((&Server{
|
||||
Registry: legacy,
|
||||
Projects: projects,
|
||||
DeveloperToken: "test-token",
|
||||
AllowLegacyToken: true,
|
||||
}).Handler())
|
||||
defer server.Close()
|
||||
|
||||
uploadProjectPackage(t, server.URL, "ssl", "shared", "1.0.0", "ssl-content")
|
||||
uploadProjectPackage(t, server.URL, "smail", "shared", "1.0.0", "smail-content")
|
||||
|
||||
assertProjectPackageContent(t, server.URL+"/api/v1/projects/ssl/packages/shared/1.0.0/download?platform=linux&architecture=amd64", "ssl-content")
|
||||
assertProjectPackageContent(t, server.URL+"/api/v1/projects/smail/packages/shared/1.0.0/download?platform=linux&architecture=amd64", "smail-content")
|
||||
|
||||
response, err := http.Get(server.URL + "/api/v1/projects")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer response.Body.Close()
|
||||
var listed struct {
|
||||
Projects []registry.Project `json:"projects"`
|
||||
}
|
||||
if err := json.NewDecoder(response.Body).Decode(&listed); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if response.StatusCode != http.StatusOK || len(listed.Projects) != 3 {
|
||||
t.Fatalf("项目列表错误:%d %+v", response.StatusCode, listed.Projects)
|
||||
}
|
||||
}
|
||||
|
||||
func uploadProjectPackage(t *testing.T, baseURL, project, name, version, content string) {
|
||||
t.Helper()
|
||||
body := &bytes.Buffer{}
|
||||
writer := multipart.NewWriter(body)
|
||||
for key, value := range map[string]string{
|
||||
"name": name,
|
||||
"version": version,
|
||||
"platform": "linux",
|
||||
"architecture": "amd64",
|
||||
"status": "published",
|
||||
} {
|
||||
if err := writer.WriteField(key, value); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
file, err := writer.CreateFormFile("file", name+"-"+version+".pkg")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := file.Write([]byte(content)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := writer.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
request, err := http.NewRequest(http.MethodPost, baseURL+"/api/v1/projects/"+project+"/packages/upload", body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
request.Header.Set("Content-Type", writer.FormDataContentType())
|
||||
request.Header.Set("Authorization", "Bearer test-token")
|
||||
response, err := http.DefaultClient.Do(request)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if response.StatusCode != http.StatusCreated {
|
||||
data, _ := io.ReadAll(response.Body)
|
||||
t.Fatalf("项目上传失败:%d %s", response.StatusCode, data)
|
||||
}
|
||||
}
|
||||
|
||||
func assertProjectPackageContent(t *testing.T, endpoint, expected string) {
|
||||
t.Helper()
|
||||
response, err := http.Get(endpoint)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer response.Body.Close()
|
||||
data, err := io.ReadAll(response.Body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if response.StatusCode != http.StatusOK || string(data) != expected {
|
||||
t.Fatalf("项目包内容错误:%d %q", response.StatusCode, data)
|
||||
}
|
||||
}
|
||||
@ -22,6 +22,7 @@ import (
|
||||
|
||||
type Server struct {
|
||||
Registry *registry.Registry
|
||||
Projects *registry.ProjectRegistry
|
||||
DeveloperToken string
|
||||
MaxUploadBytes int64
|
||||
Users *users.Store
|
||||
@ -36,6 +37,7 @@ type Server struct {
|
||||
Logger *log.Logger
|
||||
resetMu sync.Mutex
|
||||
resetTokens map[string]resetToken
|
||||
projectScope string
|
||||
}
|
||||
|
||||
type resetToken struct {
|
||||
@ -69,6 +71,9 @@ func (s *Server) Handler() http.Handler {
|
||||
mux.HandleFunc("GET /api/v1/auth/me", s.me)
|
||||
mux.HandleFunc("POST /api/v1/auth/password/request", s.requestPasswordReset)
|
||||
mux.HandleFunc("POST /api/v1/auth/password/reset", s.resetPassword)
|
||||
mux.HandleFunc("GET /api/v1/projects", s.projectList)
|
||||
mux.HandleFunc("POST /api/v1/projects", s.projectCreate)
|
||||
mux.HandleFunc("/api/v1/projects/", s.projectRoute)
|
||||
mux.HandleFunc("GET /api/v1/admin/users", s.adminUsers)
|
||||
mux.HandleFunc("PATCH /api/v1/admin/users/", s.adminUpdateUser)
|
||||
mux.HandleFunc("POST /api/v1/packages/upload", s.upload)
|
||||
|
||||
200
internal/registry/projects.go
Normal file
200
internal/registry/projects.go
Normal file
@ -0,0 +1,200 @@
|
||||
package registry
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const DefaultProject = "default"
|
||||
|
||||
var projectIDPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$`)
|
||||
|
||||
type Project struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Status string `json:"status"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
type ProjectRegistry struct {
|
||||
root string
|
||||
projects map[string]Project
|
||||
registries map[string]*Registry
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
func ValidateProjectID(id string) error {
|
||||
if !projectIDPattern.MatchString(strings.TrimSpace(id)) {
|
||||
return errors.New("项目标识只能包含字母、数字、点、下划线和短横线,长度为 1 到 64")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func NewProjectRegistry(root string) (*ProjectRegistry, error) {
|
||||
if strings.TrimSpace(root) == "" {
|
||||
return nil, errors.New("项目存储目录不能为空")
|
||||
}
|
||||
projectsRoot := filepath.Join(root, "projects")
|
||||
if err := os.MkdirAll(projectsRoot, 0o755); err != nil {
|
||||
return nil, fmt.Errorf("创建项目存储目录失败: %w", err)
|
||||
}
|
||||
result := &ProjectRegistry{
|
||||
root: projectsRoot,
|
||||
projects: make(map[string]Project),
|
||||
registries: make(map[string]*Registry),
|
||||
}
|
||||
entries, err := os.ReadDir(projectsRoot)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("读取项目目录失败: %w", err)
|
||||
}
|
||||
for _, entry := range entries {
|
||||
if !entry.IsDir() || ValidateProjectID(entry.Name()) != nil {
|
||||
continue
|
||||
}
|
||||
if err := result.loadProject(entry.Name()); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (r *ProjectRegistry) Create(input Project) (Project, error) {
|
||||
input.ID = strings.TrimSpace(input.ID)
|
||||
if err := ValidateProjectID(input.ID); err != nil {
|
||||
return Project{}, err
|
||||
}
|
||||
if input.ID == DefaultProject {
|
||||
return Project{}, errors.New("default 是兼容项目标识,不能创建")
|
||||
}
|
||||
if input.Name == "" {
|
||||
input.Name = input.ID
|
||||
}
|
||||
input.Status = strings.TrimSpace(input.Status)
|
||||
if input.Status == "" {
|
||||
input.Status = "active"
|
||||
}
|
||||
if input.Status != "active" && input.Status != "disabled" {
|
||||
return Project{}, errors.New("项目状态只能是 active 或 disabled")
|
||||
}
|
||||
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if _, exists := r.projects[input.ID]; exists {
|
||||
return Project{}, errors.New("项目已经存在")
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
input.CreatedAt = now
|
||||
input.UpdatedAt = now
|
||||
projectDir := filepath.Join(r.root, input.ID)
|
||||
if err := os.MkdirAll(projectDir, 0o755); err != nil {
|
||||
return Project{}, fmt.Errorf("创建项目目录失败: %w", err)
|
||||
}
|
||||
if err := writeProjectMetadata(projectDir, input); err != nil {
|
||||
return Project{}, err
|
||||
}
|
||||
store, err := New(projectDir)
|
||||
if err != nil {
|
||||
return Project{}, err
|
||||
}
|
||||
r.projects[input.ID] = input
|
||||
r.registries[input.ID] = store
|
||||
return input, nil
|
||||
}
|
||||
|
||||
func (r *ProjectRegistry) Ensure(id, name, description string) (Project, *Registry, error) {
|
||||
id = strings.TrimSpace(id)
|
||||
if err := ValidateProjectID(id); err != nil {
|
||||
return Project{}, nil, err
|
||||
}
|
||||
if id == DefaultProject {
|
||||
return Project{}, nil, errors.New("default 是兼容项目标识,请继续使用旧接口")
|
||||
}
|
||||
r.mu.RLock()
|
||||
project, exists := r.projects[id]
|
||||
store := r.registries[id]
|
||||
r.mu.RUnlock()
|
||||
if exists {
|
||||
return project, store, nil
|
||||
}
|
||||
project, err := r.Create(Project{ID: id, Name: name, Description: description})
|
||||
if err != nil {
|
||||
return Project{}, nil, err
|
||||
}
|
||||
r.mu.RLock()
|
||||
store = r.registries[id]
|
||||
r.mu.RUnlock()
|
||||
return project, store, nil
|
||||
}
|
||||
|
||||
func (r *ProjectRegistry) Get(id string) (Project, bool) {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
project, ok := r.projects[strings.TrimSpace(id)]
|
||||
return project, ok
|
||||
}
|
||||
|
||||
func (r *ProjectRegistry) List() []Project {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
result := make([]Project, 0, len(r.projects))
|
||||
for _, project := range r.projects {
|
||||
result = append(result, project)
|
||||
}
|
||||
sort.Slice(result, func(i, j int) bool { return result[i].ID < result[j].ID })
|
||||
return result
|
||||
}
|
||||
|
||||
func (r *ProjectRegistry) Store(id string) (*Registry, bool) {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
store, ok := r.registries[strings.TrimSpace(id)]
|
||||
return store, ok
|
||||
}
|
||||
|
||||
func (r *ProjectRegistry) loadProject(id string) error {
|
||||
projectDir := filepath.Join(r.root, id)
|
||||
project := Project{ID: id, Name: id, Status: "active"}
|
||||
metadata, err := os.ReadFile(filepath.Join(projectDir, "project.json"))
|
||||
if err == nil {
|
||||
if err := json.Unmarshal(metadata, &project); err != nil {
|
||||
return fmt.Errorf("读取项目元数据失败: %w", err)
|
||||
}
|
||||
project.ID = id
|
||||
if project.Name == "" {
|
||||
project.Name = id
|
||||
}
|
||||
if project.Status == "" {
|
||||
project.Status = "active"
|
||||
}
|
||||
} else if !os.IsNotExist(err) {
|
||||
return fmt.Errorf("读取项目元数据失败: %w", err)
|
||||
}
|
||||
store, err := New(projectDir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("加载项目 %s 失败: %w", id, err)
|
||||
}
|
||||
r.projects[id] = project
|
||||
r.registries[id] = store
|
||||
return nil
|
||||
}
|
||||
|
||||
func writeProjectMetadata(projectDir string, project Project) error {
|
||||
data, err := json.MarshalIndent(project, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("编码项目元数据失败: %w", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(projectDir, "project.json"), data, 0o644); err != nil {
|
||||
return fmt.Errorf("保存项目元数据失败: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
79
internal/registry/projects_test.go
Normal file
79
internal/registry/projects_test.go
Normal file
@ -0,0 +1,79 @@
|
||||
package registry
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestProjectRegistryKeepsProjectsIsolated(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
projects, err := NewProjectRegistry(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, ssl, err := projects.Ensure("ssl", "SSL", "证书项目")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, smail, err := projects.Ensure("smail", "Smail", "邮件项目")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := ssl.AddPackage(Package{
|
||||
Name: "shared",
|
||||
Version: "1.0.0",
|
||||
Platform: "linux",
|
||||
Architecture: "amd64",
|
||||
Status: "published",
|
||||
}, strings.NewReader("ssl")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := smail.AddPackage(Package{
|
||||
Name: "shared",
|
||||
Version: "1.0.0",
|
||||
Platform: "linux",
|
||||
Architecture: "amd64",
|
||||
Status: "published",
|
||||
}, strings.NewReader("smail")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sslItem, ok := ssl.Latest("shared")
|
||||
if !ok || sslItem.SHA256 == "" {
|
||||
t.Fatal("SSL 项目未保存包")
|
||||
}
|
||||
smailItem, ok := smail.Latest("shared")
|
||||
if !ok || smailItem.SHA256 == "" {
|
||||
t.Fatal("Smail 项目未保存包")
|
||||
}
|
||||
if sslItem.SHA256 == smailItem.SHA256 {
|
||||
t.Fatal("不同项目不应共享同一个包内容")
|
||||
}
|
||||
if len(projects.List()) != 2 {
|
||||
t.Fatalf("项目数量错误:%d", len(projects.List()))
|
||||
}
|
||||
|
||||
reloaded, err := NewProjectRegistry(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
store, ok := reloaded.Store("ssl")
|
||||
if !ok {
|
||||
t.Fatal("重载后找不到 SSL 项目")
|
||||
}
|
||||
if item, ok := store.Latest("shared"); !ok || item.SHA256 == "" {
|
||||
t.Fatalf("项目包重载结果异常:%+v", item)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateProjectID(t *testing.T) {
|
||||
for _, value := range []string{"ssl", "Smail_2", "release.center"} {
|
||||
if err := ValidateProjectID(value); err != nil {
|
||||
t.Errorf("项目标识 %q 不应失败:%v", value, err)
|
||||
}
|
||||
}
|
||||
for _, value := range []string{"", "../ssl", "项目", "a/b"} {
|
||||
if err := ValidateProjectID(value); err == nil {
|
||||
t.Errorf("项目标识 %q 应该失败", value)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -23,6 +23,7 @@ var (
|
||||
)
|
||||
|
||||
type Package struct {
|
||||
Project string `json:"project,omitempty"`
|
||||
Name string `json:"name"`
|
||||
Version string `json:"version"`
|
||||
Description string `json:"description,omitempty"`
|
||||
|
||||
@ -19,6 +19,7 @@ import (
|
||||
)
|
||||
|
||||
type Package struct {
|
||||
Project string `json:"project,omitempty"`
|
||||
Name string `json:"name"`
|
||||
Version string `json:"version"`
|
||||
Description string `json:"description,omitempty"`
|
||||
@ -71,6 +72,7 @@ type Client struct {
|
||||
baseURL string
|
||||
httpClient *http.Client
|
||||
token string
|
||||
project string
|
||||
}
|
||||
|
||||
func New(baseURL string, options ...Option) (*Client, error) {
|
||||
@ -102,11 +104,42 @@ func WithDeveloperToken(token string) Option {
|
||||
}
|
||||
}
|
||||
|
||||
func WithProject(project string) Option {
|
||||
return func(client *Client) {
|
||||
client.project = strings.TrimSpace(project)
|
||||
}
|
||||
}
|
||||
|
||||
func NewProject(baseURL, project string, options ...Option) (*Client, error) {
|
||||
project = strings.TrimSpace(project)
|
||||
if project == "" {
|
||||
return nil, errors.New("\u9879\u76ee\u6807\u8bc6\u4e0d\u80fd\u4e3a\u7a7a")
|
||||
}
|
||||
for _, char := range project {
|
||||
if !(char == '-' || char == '_' || char == '.' || char >= 'a' && char <= 'z' || char >= 'A' && char <= 'Z' || char >= '0' && char <= '9') {
|
||||
return nil, errors.New("\u9879\u76ee\u6807\u8bc6\u5305\u542b\u65e0\u6548\u5b57\u7b26")
|
||||
}
|
||||
}
|
||||
options = append(options, WithProject(project))
|
||||
return New(baseURL, options...)
|
||||
}
|
||||
|
||||
func (c *Client) Project() string {
|
||||
return c.project
|
||||
}
|
||||
|
||||
func (c *Client) resourcePath(path string) string {
|
||||
if c.project == "" {
|
||||
return path
|
||||
}
|
||||
return "/api/v1/projects/" + url.PathEscape(c.project) + path[len("/api/v1"):]
|
||||
}
|
||||
|
||||
func (c *Client) ListPackages(ctx context.Context) ([]Package, error) {
|
||||
var response struct {
|
||||
Packages []Package `json:"packages"`
|
||||
}
|
||||
if err := c.getJSON(ctx, "/api/v1/packages", &response); err != nil {
|
||||
if err := c.getJSON(ctx, c.resourcePath("/api/v1/packages"), &response); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return response.Packages, nil
|
||||
@ -114,7 +147,7 @@ func (c *Client) ListPackages(ctx context.Context) ([]Package, error) {
|
||||
|
||||
func (c *Client) GetLatest(ctx context.Context, name string) (Package, error) {
|
||||
var item Package
|
||||
if err := c.getJSON(ctx, "/api/v1/packages/"+url.PathEscape(name), &item); err != nil {
|
||||
if err := c.getJSON(ctx, c.resourcePath("/api/v1/packages/")+url.PathEscape(name), &item); err != nil {
|
||||
return Package{}, err
|
||||
}
|
||||
return item, nil
|
||||
@ -125,7 +158,7 @@ func (c *Client) ListVersions(ctx context.Context, name string) ([]Package, erro
|
||||
Name string `json:"name"`
|
||||
Versions []Package `json:"versions"`
|
||||
}
|
||||
if err := c.getJSON(ctx, "/api/v1/packages/"+url.PathEscape(name)+"/versions", &response); err != nil {
|
||||
if err := c.getJSON(ctx, c.resourcePath("/api/v1/packages/")+url.PathEscape(name)+"/versions", &response); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return response.Versions, nil
|
||||
@ -147,7 +180,7 @@ func (c *Client) Download(ctx context.Context, name, version, destination string
|
||||
}
|
||||
version = latest.Version
|
||||
}
|
||||
path := "/api/v1/packages/" + url.PathEscape(name) + "/" + url.PathEscape(version) + "/download"
|
||||
path := c.resourcePath("/api/v1/packages/") + url.PathEscape(name) + "/" + url.PathEscape(version) + "/download"
|
||||
request, err := c.newRequest(ctx, http.MethodGet, path, nil)
|
||||
if err != nil {
|
||||
return Package{}, err
|
||||
@ -208,7 +241,7 @@ func (c *Client) Upload(ctx context.Context, input UploadRequest) (Package, erro
|
||||
}
|
||||
_ = bodyWriter.CloseWithError(err)
|
||||
}()
|
||||
request, err := c.newRequest(ctx, http.MethodPost, "/api/v1/packages/upload", bodyReader)
|
||||
request, err := c.newRequest(ctx, http.MethodPost, c.resourcePath("/api/v1/packages/upload"), bodyReader)
|
||||
if err != nil {
|
||||
return Package{}, err
|
||||
}
|
||||
@ -240,7 +273,7 @@ func (c *Client) WatchUpdates(ctx context.Context, packages ...string) (<-chan U
|
||||
if len(wanted) > 0 {
|
||||
query.Set("packages", strings.Join(wanted, ","))
|
||||
}
|
||||
path := "/api/v1/updates/stream"
|
||||
path := c.resourcePath("/api/v1/updates/stream")
|
||||
if encoded := query.Encode(); encoded != "" {
|
||||
path += "?" + encoded
|
||||
}
|
||||
|
||||
@ -34,7 +34,7 @@ func (c *Client) ListPackagesFor(ctx context.Context, options QueryOptions) ([]P
|
||||
var response struct {
|
||||
Packages []Package `json:"packages"`
|
||||
}
|
||||
if err := c.getJSON(ctx, withQuery("/api/v1/packages", options.values()), &response); err != nil {
|
||||
if err := c.getJSON(ctx, withQuery(c.resourcePath("/api/v1/packages"), options.values()), &response); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return response.Packages, nil
|
||||
@ -42,7 +42,7 @@ func (c *Client) ListPackagesFor(ctx context.Context, options QueryOptions) ([]P
|
||||
|
||||
func (c *Client) GetLatestFor(ctx context.Context, name string, options QueryOptions) (Package, error) {
|
||||
var item Package
|
||||
path := "/api/v1/packages/" + url.PathEscape(name)
|
||||
path := c.resourcePath("/api/v1/packages/") + url.PathEscape(name)
|
||||
if err := c.getJSON(ctx, withQuery(path, options.values()), &item); err != nil {
|
||||
return Package{}, err
|
||||
}
|
||||
@ -81,7 +81,7 @@ func (c *Client) DownloadFor(ctx context.Context, name, version, destination str
|
||||
}
|
||||
version = latest.Version
|
||||
}
|
||||
path := "/api/v1/packages/" + url.PathEscape(name) + "/" + url.PathEscape(version) + "/download"
|
||||
path := c.resourcePath("/api/v1/packages/") + url.PathEscape(name) + "/" + url.PathEscape(version) + "/download"
|
||||
request, err := c.newRequest(ctx, http.MethodGet, withQuery(path, options.values()), nil)
|
||||
if err != nil {
|
||||
return Package{}, err
|
||||
@ -165,7 +165,7 @@ func (c *Client) WatchUpdatesFor(ctx context.Context, input WatchRequest) (<-cha
|
||||
if input.Channel != "" {
|
||||
query.Set("channel", input.Channel)
|
||||
}
|
||||
path := withQuery("/api/v1/updates/stream", query)
|
||||
path := withQuery(c.resourcePath("/api/v1/updates/stream"), query)
|
||||
request, err := c.newRequest(ctx, http.MethodGet, path, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
66
pkg/client/projects.go
Normal file
66
pkg/client/projects.go
Normal file
@ -0,0 +1,66 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type Project struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Status string `json:"status"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
}
|
||||
|
||||
func (c *Client) ListProjects(ctx context.Context) ([]Project, error) {
|
||||
var response struct {
|
||||
Projects []Project `json:"projects"`
|
||||
}
|
||||
if err := c.getJSON(ctx, "/api/v1/projects", &response); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return response.Projects, nil
|
||||
}
|
||||
|
||||
func (c *Client) GetProject(ctx context.Context, project string) (Project, error) {
|
||||
project = strings.TrimSpace(project)
|
||||
if project == "" {
|
||||
return Project{}, errors.New("项目标识不能为空")
|
||||
}
|
||||
var result Project
|
||||
path := "/api/v1/projects/" + url.PathEscape(project)
|
||||
if err := c.getJSON(ctx, path, &result); err != nil {
|
||||
return Project{}, err
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (c *Client) CreateProject(ctx context.Context, project Project) (Project, error) {
|
||||
var result Project
|
||||
if err := c.postJSON(ctx, "/api/v1/projects", map[string]string{
|
||||
"id": project.ID,
|
||||
"name": project.Name,
|
||||
"description": project.Description,
|
||||
}, &result); err != nil {
|
||||
return Project{}, err
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (c *Client) ProjectHealth(ctx context.Context) error {
|
||||
request, err := c.newRequest(ctx, http.MethodGet, "/healthz", nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
response, err := c.httpClient.Do(request)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer response.Body.Close()
|
||||
return checkResponse(response)
|
||||
}
|
||||
37
pkg/client/projects_test.go
Normal file
37
pkg/client/projects_test.go
Normal file
@ -0,0 +1,37 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestProjectClientUsesProjectScopedPaths(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||
if request.URL.Path != "/api/v1/projects/ssl/packages/demo" {
|
||||
t.Fatalf("项目请求路径错误:%s", request.URL.Path)
|
||||
}
|
||||
writer.Header().Set("Content-Type", "application/json")
|
||||
_, _ = writer.Write([]byte(`{"name":"demo","version":"1.0.0","project":"ssl"}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
api, err := NewProject(server.URL, "ssl")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
item, err := api.GetLatest(context.Background(), "demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if item.Project != "ssl" || item.Version != "1.0.0" {
|
||||
t.Fatalf("项目包响应错误:%+v", item)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewProjectRejectsUnsafeProjectID(t *testing.T) {
|
||||
if _, err := NewProject("http://127.0.0.1", "../ssl"); err == nil {
|
||||
t.Fatal("不安全项目标识应该失败")
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user