Skip to content

Commit e92272d

Browse files
authored
Merge pull request #704 from chaitin/feature/extension-package-import
feat: 支持团队扩展包导入
2 parents c216743 + 61db5dd commit e92272d

60 files changed

Lines changed: 10432 additions & 368 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

backend/biz/host/usecase/host.go

Lines changed: 33 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -194,9 +194,9 @@ func (h *HostUsecase) GetInstallCommand(ctx context.Context, user *domain.User)
194194

195195
// InstallScript implements domain.HostUsecase.
196196
func (h *HostUsecase) InstallScript(ctx context.Context, token *domain.InstallReq) (string, error) {
197-
key := fmt.Sprintf("host:token:%s", token.Token)
198-
if _, err := h.redis.Get(ctx, key).Result(); err != nil {
199-
return "", errcode.ErrInvalidInstallToken
197+
teamID, err := h.teamIDFromInstallToken(ctx, token.Token)
198+
if err != nil {
199+
return "", err
200200
}
201201

202202
tplName := "install"
@@ -212,19 +212,36 @@ func (h *HostUsecase) InstallScript(ctx context.Context, token *domain.InstallRe
212212
}
213213
buf := bytes.NewBuffer([]byte(""))
214214
param := map[string]any{
215-
"token": token.Token,
216-
"grpc_url": h.cfg.TaskFlow.GrpcURL,
217-
"base_url": h.cfg.Server.BaseURL,
218-
"installer_url": h.installerURL(),
219-
"docker_bundle_path": h.installerBundlePath("docker.tgz"),
220-
"host_bundle_path": h.hostBundlePath(),
215+
"token": token.Token,
216+
"grpc_url": h.cfg.TaskFlow.GrpcURL,
217+
"base_url": h.cfg.Server.BaseURL,
218+
"installer_url": h.installerURL(),
219+
"docker_bundle_path": h.installerBundlePath("docker.tgz"),
220+
"host_bundle_path": h.hostBundlePath(),
221+
"extension_images_manifest_path": h.extensionImagesManifestPath(teamID),
221222
}
222223
if err := tmp.Execute(buf, param); err != nil {
223224
return "", fmt.Errorf("failed to execute template %s", err)
224225
}
225226
return buf.String(), nil
226227
}
227228

229+
func (h *HostUsecase) teamIDFromInstallToken(ctx context.Context, token string) (uuid.UUID, error) {
230+
key := fmt.Sprintf("host:token:%s", token)
231+
raw, err := h.redis.Get(ctx, key).Result()
232+
if err != nil {
233+
return uuid.Nil, errcode.ErrInvalidInstallToken
234+
}
235+
var user domain.User
236+
if err := json.Unmarshal([]byte(raw), &user); err != nil {
237+
return uuid.Nil, nil
238+
}
239+
if user.Team == nil {
240+
return uuid.Nil, nil
241+
}
242+
return user.Team.ID, nil
243+
}
244+
228245
func (h *HostUsecase) installerURL() string {
229246
if h.cfg.Server.BaseURL == "" {
230247
return ""
@@ -249,6 +266,13 @@ func (h *HostUsecase) installerBundlePath(name string) string {
249266
return "/" + strings.Trim(h.cfg.StaticFiles.RoutePrefix, "/") + "/installer/{{.arch}}/" + name
250267
}
251268

269+
func (h *HostUsecase) extensionImagesManifestPath(teamID uuid.UUID) string {
270+
if teamID == uuid.Nil {
271+
return ""
272+
}
273+
return "/" + strings.Trim(h.cfg.StaticFiles.RoutePrefix, "/") + "/extensions/teams/" + teamID.String() + "/images/{{.arch}}/manifest.json"
274+
}
275+
252276
// List implements domain.HostUsecase.
253277
func (h *HostUsecase) List(ctx context.Context, uid uuid.UUID) (*domain.HostListResp, error) {
254278
user, err := h.userRepo.Get(ctx, uid)

backend/biz/host/usecase/host_test.go

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package usecase
22

33
import (
44
"context"
5+
"encoding/json"
56
"io"
67
"log/slog"
78
"net/url"
@@ -148,6 +149,61 @@ func TestInstallScriptUsesOfflineBundle(t *testing.T) {
148149
assertInstallScriptChecksAVX(t, script)
149150
}
150151

152+
func TestInstallScriptIncludesExtensionImagesManifestPath(t *testing.T) {
153+
t.Parallel()
154+
155+
mr := miniredis.RunT(t)
156+
rdb := redis.NewClient(&redis.Options{Addr: mr.Addr()})
157+
t.Cleanup(func() { _ = rdb.Close() })
158+
159+
token := "install-token"
160+
teamID := uuid.New()
161+
rawUser, err := json.Marshal(&domain.User{
162+
ID: uuid.New(),
163+
Team: &domain.Team{
164+
ID: teamID,
165+
},
166+
})
167+
if err != nil {
168+
t.Fatal(err)
169+
}
170+
if err := rdb.Set(context.Background(), "host:token:"+token, string(rawUser), time.Minute).Err(); err != nil {
171+
t.Fatal(err)
172+
}
173+
u := &HostUsecase{
174+
cfg: &config.Config{
175+
Server: struct {
176+
Addr string `mapstructure:"addr"`
177+
BaseURL string `mapstructure:"base_url"`
178+
}{BaseURL: "http://monkeycode.local"},
179+
TaskFlow: config.TaskFlow{GrpcURL: "127.0.0.1:50443"},
180+
StaticFiles: config.StaticFilesConfig{
181+
RoutePrefix: "/static",
182+
},
183+
HostInstaller: config.HostInstaller{
184+
Mode: "offline",
185+
BundlePath: "installer/{{.arch}}/host.tgz",
186+
},
187+
},
188+
redis: rdb,
189+
}
190+
191+
script, err := u.InstallScript(context.Background(), &domain.InstallReq{Token: token})
192+
if err != nil {
193+
t.Fatal(err)
194+
}
195+
want := "/static/extensions/teams/" + teamID.String() + "/images/{{.arch}}/manifest.json"
196+
if !strings.Contains(script, "EXTENSION_IMAGES_MANIFEST_PATH=\""+want+"\"") {
197+
t.Fatalf("script missing extension manifest path %q:\n%s", want, script)
198+
}
199+
if !strings.Contains(script, "EXTENSION_IMAGES_MANIFEST_PATH=${EXTENSION_IMAGES_MANIFEST_PATH//\\{\\{.arch\\}\\}/$ARCH}") {
200+
t.Fatalf("script missing extension manifest arch replacement:\n%s", script)
201+
}
202+
if !strings.Contains(script, "MCAI_EXTENSION_IMAGES_MANIFEST_PATH=\"$EXTENSION_IMAGES_MANIFEST_PATH\"") {
203+
t.Fatalf("script missing installer extension manifest env:\n%s", script)
204+
}
205+
}
206+
151207
func assertInstallScriptChecksAVX(t *testing.T, script string) {
152208
t.Helper()
153209

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
package v1
2+
3+
import (
4+
"fmt"
5+
"io"
6+
"mime/multipart"
7+
8+
"github.com/GoYoko/web"
9+
"github.com/samber/do"
10+
11+
"github.com/chaitin/MonkeyCode/backend/config"
12+
"github.com/chaitin/MonkeyCode/backend/domain"
13+
"github.com/chaitin/MonkeyCode/backend/errcode"
14+
"github.com/chaitin/MonkeyCode/backend/middleware"
15+
)
16+
17+
type TeamExtensionPackageHandler struct {
18+
usecase domain.TeamExtensionPackageUsecase
19+
cfg *config.Config
20+
}
21+
22+
func NewTeamExtensionPackageHandler(i *do.Injector) (*TeamExtensionPackageHandler, error) {
23+
w := do.MustInvoke[*web.Web](i)
24+
auth := do.MustInvoke[*middleware.AuthMiddleware](i)
25+
audit := do.MustInvoke[*middleware.AuditMiddleware](i)
26+
27+
h := &TeamExtensionPackageHandler{
28+
usecase: do.MustInvoke[domain.TeamExtensionPackageUsecase](i),
29+
cfg: do.MustInvoke[*config.Config](i),
30+
}
31+
32+
g := w.Group("/api/v1/teams/extension-packages")
33+
g.Use(auth.TeamAuth())
34+
g.POST("", web.BindHandler(h.Import), audit.Audit("import_team_extension_package"))
35+
36+
return h, nil
37+
}
38+
39+
type importTeamExtensionPackageFormReq struct {
40+
File *multipart.FileHeader `form:"file" validate:"required"`
41+
}
42+
43+
// Import 上传团队扩展包
44+
//
45+
// @Summary 上传团队扩展包
46+
// @Description 上传团队扩展包并导入 Skills 和团队镜像记录
47+
// @Tags 【Team 管理员】扩展包管理
48+
// @Accept multipart/form-data
49+
// @Produce json
50+
// @Security MonkeyCodeAITeamAuth
51+
// @Param file formData file true "扩展包 zip"
52+
// @Success 200 {object} web.Resp{data=domain.ImportTeamExtensionPackageResp} "成功"
53+
// @Failure 401 {object} web.Resp "未授权"
54+
// @Failure 500 {object} web.Resp "服务器内部错误"
55+
// @Router /api/v1/teams/extension-packages [post]
56+
func (h *TeamExtensionPackageHandler) Import(c *web.Context, req importTeamExtensionPackageFormReq) error {
57+
teamUser := middleware.GetTeamUser(c)
58+
data, err := h.readPackageFile(req.File)
59+
if err != nil {
60+
return err
61+
}
62+
resp, err := h.usecase.Import(c.Request().Context(), teamUser, &domain.ImportTeamExtensionPackageReq{
63+
Filename: req.File.Filename,
64+
Data: data,
65+
})
66+
if err != nil {
67+
return err
68+
}
69+
return c.Success(resp)
70+
}
71+
72+
func (h *TeamExtensionPackageHandler) readPackageFile(fileHeader *multipart.FileHeader) ([]byte, error) {
73+
if fileHeader == nil {
74+
return nil, errcode.ErrBadRequest.Wrap(fmt.Errorf("file is required"))
75+
}
76+
maxSize := h.cfg.ObjectStorage.MaxSize
77+
if maxSize <= 0 {
78+
maxSize = 50 << 20
79+
}
80+
if fileHeader.Size > maxSize {
81+
return nil, errcode.ErrBadRequest.Wrap(fmt.Errorf("file exceeds limit"))
82+
}
83+
file, err := fileHeader.Open()
84+
if err != nil {
85+
return nil, err
86+
}
87+
defer file.Close()
88+
data, err := io.ReadAll(io.LimitReader(file, maxSize+1))
89+
if err != nil {
90+
return nil, err
91+
}
92+
if int64(len(data)) > maxSize {
93+
return nil, errcode.ErrBadRequest.Wrap(fmt.Errorf("file exceeds limit"))
94+
}
95+
return data, nil
96+
}
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
package v1
2+
3+
import (
4+
"bytes"
5+
"mime/multipart"
6+
"net/http/httptest"
7+
"testing"
8+
9+
"github.com/chaitin/MonkeyCode/backend/config"
10+
)
11+
12+
func TestTeamExtensionPackageHandlerReadPackageFileUsesConfiguredLimit(t *testing.T) {
13+
h := &TeamExtensionPackageHandler{
14+
cfg: &config.Config{},
15+
}
16+
h.cfg.ObjectStorage.MaxSize = 3
17+
file := makeMultipartFileHeader(t, "extension.zip", "abcd")
18+
19+
if _, err := h.readPackageFile(file); err == nil {
20+
t.Fatal("expected file size limit error")
21+
}
22+
}
23+
24+
func makeMultipartFileHeader(t *testing.T, filename, content string) *multipart.FileHeader {
25+
t.Helper()
26+
var body bytes.Buffer
27+
writer := multipart.NewWriter(&body)
28+
part, err := writer.CreateFormFile("file", filename)
29+
if err != nil {
30+
t.Fatal(err)
31+
}
32+
if _, err := part.Write([]byte(content)); err != nil {
33+
t.Fatal(err)
34+
}
35+
if err := writer.Close(); err != nil {
36+
t.Fatal(err)
37+
}
38+
39+
req := httptest.NewRequest("POST", "/upload", &body)
40+
req.Header.Set("Content-Type", writer.FormDataContentType())
41+
if err := req.ParseMultipartForm(1 << 20); err != nil {
42+
t.Fatal(err)
43+
}
44+
return req.MultipartForm.File["file"][0]
45+
}

backend/biz/team/register.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,9 @@ func ProvideTeam(i *do.Injector) {
2727
do.Provide(i, repo.NewTeamSkillRepo)
2828
do.Provide(i, usecase.NewTeamSkillUsecase)
2929
do.Provide(i, v1.NewTeamSkillHandler)
30+
do.Provide(i, repo.NewTeamExtensionPackageRepo)
31+
do.Provide(i, usecase.NewTeamExtensionPackageUsecase)
32+
do.Provide(i, v1.NewTeamExtensionPackageHandler)
3033
do.Provide(i, repo.NewTeamHostRepo)
3134
do.Provide(i, usecase.NewTeamHostUsecase)
3235
do.Provide(i, v1.NewTeamHostHandler)
@@ -51,6 +54,7 @@ func InvokeTeam(i *do.Injector) {
5154
do.MustInvoke[*v1.TeamModelHandler](i)
5255
do.MustInvoke[*v1.TeamImageHandler](i)
5356
do.MustInvoke[*v1.TeamSkillHandler](i)
57+
do.MustInvoke[*v1.TeamExtensionPackageHandler](i)
5458
do.MustInvoke[*v1.TeamHostHandler](i)
5559
do.MustInvoke[*v1.TeamPolicyHandler](i)
5660
do.MustInvoke[*v1.TeamOIDCHandler](i)

0 commit comments

Comments
 (0)