diff --git a/.github/workflows/offline-package.yml b/.github/workflows/offline-package.yml index 43dfa2d0..e37def6d 100644 --- a/.github/workflows/offline-package.yml +++ b/.github/workflows/offline-package.yml @@ -70,9 +70,12 @@ jobs: AWS_SECRET_ACCESS_KEY: ${{ secrets.OSS_ACCESS_KEY_SECRET }} AWS_DEFAULT_REGION: ${{ secrets.OSS_REGION || 'us-east-1' }} AWS_EC2_METADATA_DISABLED: "true" + AWS_REQUEST_CHECKSUM_CALCULATION: when_required + AWS_RESPONSE_CHECKSUM_VALIDATION: when_required OSS_ENDPOINT: ${{ secrets.OSS_ENDPOINT }} OSS_BUCKET: ${{ secrets.OSS_BUCKET }} OSS_PREFIX: ${{ secrets.OSS_PREFIX }} + OSS_ADDRESSING_STYLE: ${{ secrets.OSS_ADDRESSING_STYLE || 'virtual' }} PACKAGE_VERSION: ${{ inputs.package_version }} ARTIFACT_NAME: ${{ matrix.artifact }} run: | @@ -94,13 +97,13 @@ jobs: version="${PACKAGE_VERSION:-}" if [ -z "$version" ]; then - version="${GITHUB_REF_NAME}-${GITHUB_SHA}" + version="${GITHUB_SHA}" fi object_prefix="${OSS_PREFIX:-monkeycode/offline-package}" object_prefix="${object_prefix%/}/$version" object_uri="s3://${OSS_BUCKET}/${object_prefix}/${ARTIFACT_NAME}.tgz" - aws configure set default.s3.addressing_style path + aws configure set default.s3.addressing_style "$OSS_ADDRESSING_STYLE" aws s3 cp "$package_file" "$object_uri" --endpoint-url "$OSS_ENDPOINT" --only-show-errors echo "package uploaded: $object_uri" diff --git a/.gitignore b/.gitignore index 307712be..76acdd03 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,7 @@ logs # Local build artifacts monkeycode-ai/ +backend/bin/ .pnpm-store/ frontend/.vite/ @@ -16,3 +17,10 @@ frontend/.vite/ desktop/release/ desktop/release-full/ frontend/release/ + +# tar +*.tar +*.tgz + +# Builder +dist \ No newline at end of file diff --git a/backend/Makefile b/backend/Makefile index c2c93c8c..efa409cc 100644 --- a/backend/Makefile +++ b/backend/Makefile @@ -4,6 +4,8 @@ OUTPUT=type=docker,dest=$(HOME)/tmp/mcai_server.tar GOCACHE=/root/.cache/go-build GOMODCACHE?=/go/pkg/mod REGISTRY=chaitin-registry.cn-hangzhou.cr.aliyuncs.com/monkeycode +INSTALLER_OUT=bin/installer +.PHONY: installer # make server PLATFORM= TAG= OUTPUT_SERVER= GOCACHE= image: docker buildx build \ @@ -45,3 +47,14 @@ check-generate: migrate_sql: migrate create -ext sql -dir migration -seq ${SEQ} + +installer: + mkdir -p ${INSTALLER_OUT}/x86_64 ${INSTALLER_OUT}/aarch64 + CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o ${INSTALLER_OUT}/x86_64/installer ./cmd/installer + CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -o ${INSTALLER_OUT}/aarch64/installer ./cmd/installer + @echo Installer: ${INSTALLER_OUT}/x86_64/installer + @echo Installer: ${INSTALLER_OUT}/aarch64/installer + +.PHONY: offline-package +offline-package: + ARCH=$${ARCH:-amd64} ./scripts/build-offline-package.sh diff --git a/backend/biz/host/usecase/host.go b/backend/biz/host/usecase/host.go index 2320cd21..ce1d2b10 100644 --- a/backend/biz/host/usecase/host.go +++ b/backend/biz/host/usecase/host.go @@ -11,6 +11,7 @@ import ( "net/url" "sort" "strconv" + "strings" "time" "github.com/google/uuid" @@ -198,14 +199,25 @@ func (h *HostUsecase) InstallScript(ctx context.Context, token *domain.InstallRe return "", errcode.ErrInvalidInstallToken } - tmp, err := template.New("install").Parse(string(templates.InstallTmpl)) + tplName := "install" + tplContent := templates.InstallTmpl + if h.cfg.HostInstaller.Mode == "offline" { + tplName = "install_offline" + tplContent = templates.InstallOfflineTmpl + } + + tmp, err := template.New(tplName).Parse(string(tplContent)) if err != nil { return "", fmt.Errorf("failed to parse template %s", err) } buf := bytes.NewBuffer([]byte("")) param := map[string]any{ - "token": token.Token, - "grpc_url": h.cfg.TaskFlow.GrpcURL, + "token": token.Token, + "grpc_url": h.cfg.TaskFlow.GrpcURL, + "base_url": h.cfg.Server.BaseURL, + "installer_url": h.installerURL(), + "docker_bundle_path": h.installerBundlePath("docker.tgz"), + "host_bundle_path": h.hostBundlePath(), } if err := tmp.Execute(buf, param); err != nil { return "", fmt.Errorf("failed to execute template %s", err) @@ -213,6 +225,30 @@ func (h *HostUsecase) InstallScript(ctx context.Context, token *domain.InstallRe return buf.String(), nil } +func (h *HostUsecase) installerURL() string { + if h.cfg.Server.BaseURL == "" { + return "" + } + baseurl, err := url.Parse(h.cfg.Server.BaseURL) + if err != nil { + return "" + } + baseurl = baseurl.JoinPath(h.cfg.StaticFiles.RoutePrefix, "installer") + return strings.TrimRight(baseurl.String(), "/") + "/{{.arch}}/installer" +} + +func (h *HostUsecase) hostBundlePath() string { + bundlePath := h.cfg.HostInstaller.BundlePath + if bundlePath == "" { + bundlePath = "installer/{{.arch}}/host.tgz" + } + return "/" + strings.Trim(h.cfg.StaticFiles.RoutePrefix, "/") + "/" + strings.TrimLeft(bundlePath, "/") +} + +func (h *HostUsecase) installerBundlePath(name string) string { + return "/" + strings.Trim(h.cfg.StaticFiles.RoutePrefix, "/") + "/installer/{{.arch}}/" + name +} + // List implements domain.HostUsecase. func (h *HostUsecase) List(ctx context.Context, uid uuid.UUID) (*domain.HostListResp, error) { user, err := h.userRepo.Get(ctx, uid) diff --git a/backend/biz/host/usecase/host_test.go b/backend/biz/host/usecase/host_test.go index bf16848c..f4bad352 100644 --- a/backend/biz/host/usecase/host_test.go +++ b/backend/biz/host/usecase/host_test.go @@ -4,12 +4,16 @@ import ( "context" "io" "log/slog" + "strings" "testing" "time" + "github.com/alicebob/miniredis/v2" "github.com/google/uuid" _ "github.com/mattn/go-sqlite3" + "github.com/redis/go-redis/v9" + "github.com/chaitin/MonkeyCode/backend/config" "github.com/chaitin/MonkeyCode/backend/consts" "github.com/chaitin/MonkeyCode/backend/db" "github.com/chaitin/MonkeyCode/backend/db/enttest" @@ -17,6 +21,98 @@ import ( "github.com/chaitin/MonkeyCode/backend/pkg/taskflow" ) +func TestInstallScriptDefaultsToOnlineInstaller(t *testing.T) { + t.Parallel() + + mr := miniredis.RunT(t) + rdb := redis.NewClient(&redis.Options{Addr: mr.Addr()}) + t.Cleanup(func() { _ = rdb.Close() }) + + token := "install-token" + if err := rdb.Set(context.Background(), "host:token:"+token, "1", time.Minute).Err(); err != nil { + t.Fatal(err) + } + u := &HostUsecase{ + cfg: &config.Config{ + TaskFlow: config.TaskFlow{GrpcURL: "121.41.208.82:50443"}, + }, + redis: rdb, + } + + script, err := u.InstallScript(context.Background(), &domain.InstallReq{Token: token}) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(script, "release.baizhi.cloud/monkeycode/runner/$ARCH/installer") { + t.Fatalf("script missing online installer: %s", script) + } + if !strings.Contains(script, "--env GRPC_URL=121.41.208.82:50443") { + t.Fatalf("script missing grpc url: %s", script) + } + if strings.Contains(script, "install_docker_from_bundle") { + t.Fatalf("online script should not include offline installer: %s", script) + } +} + +func TestInstallScriptUsesOfflineBundle(t *testing.T) { + t.Parallel() + + mr := miniredis.RunT(t) + rdb := redis.NewClient(&redis.Options{Addr: mr.Addr()}) + t.Cleanup(func() { _ = rdb.Close() }) + + token := "install-token" + if err := rdb.Set(context.Background(), "host:token:"+token, "1", time.Minute).Err(); err != nil { + t.Fatal(err) + } + u := &HostUsecase{ + cfg: &config.Config{ + Server: struct { + Addr string `mapstructure:"addr"` + BaseURL string `mapstructure:"base_url"` + }{BaseURL: "http://monkeycode.local"}, + TaskFlow: config.TaskFlow{GrpcURL: "121.41.208.82:50443"}, + StaticFiles: config.StaticFilesConfig{ + RoutePrefix: "/static", + }, + HostInstaller: config.HostInstaller{ + Mode: "offline", + BundlePath: "installer/{{.arch}}/host.tgz", + }, + }, + redis: rdb, + } + + script, err := u.InstallScript(context.Background(), &domain.InstallReq{Token: token}) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(script, "GRPC_URL=\"121.41.208.82:50443\"") { + t.Fatalf("script missing grpc url: %s", script) + } + if !strings.Contains(script, "INSTALLER_URL=\"http://monkeycode.local/static/installer/{{.arch}}/installer\"") { + t.Fatalf("script missing installer url: %s", script) + } + if !strings.Contains(script, "BASE_URL=\"http://monkeycode.local\"") || !strings.Contains(script, "MCAI_BASE_URL=\"$BASE_URL\"") { + t.Fatalf("script missing base url: %s", script) + } + if !strings.Contains(script, "HOST_BUNDLE_PATH=\"/static/installer/{{.arch}}/host.tgz\"") || !strings.Contains(script, "HOST_BUNDLE_PATH=${HOST_BUNDLE_PATH//\\{\\{.arch\\}\\}/$ARCH}") || !strings.Contains(script, "MCAI_HOST_BUNDLE_PATH=\"$HOST_BUNDLE_PATH\"") { + t.Fatalf("script missing host bundle path: %s", script) + } + if !strings.Contains(script, "DOCKER_BUNDLE_PATH=\"/static/installer/{{.arch}}/docker.tgz\"") || !strings.Contains(script, "DOCKER_BUNDLE_PATH=${DOCKER_BUNDLE_PATH//\\{\\{.arch\\}\\}/$ARCH}") || !strings.Contains(script, "MCAI_DOCKER_BUNDLE_PATH=\"$DOCKER_BUNDLE_PATH\"") { + t.Fatalf("script missing docker bundle path: %s", script) + } + if !strings.Contains(script, "TOKEN=\"install-token\"") || !strings.Contains(script, "MCAI_HOST_TOKEN=\"$TOKEN\"") { + t.Fatalf("script missing host token: %s", script) + } + if strings.Contains(script, "docker load") || strings.Contains(script, "docker compose") { + t.Fatalf("bootstrap script should not install host directly: %s", script) + } + if strings.Contains(script, "release.baizhi.cloud") { + t.Fatalf("script should not download public installer: %s", script) + } +} + func TestHostUsecase_markRecycledTasksFinished(t *testing.T) { t.Parallel() diff --git a/backend/biz/host/usecase/publichost_test.go b/backend/biz/host/usecase/publichost_test.go index 21bb07be..9286e7d7 100644 --- a/backend/biz/host/usecase/publichost_test.go +++ b/backend/biz/host/usecase/publichost_test.go @@ -64,12 +64,13 @@ func TestPickHostSelectsHostByRandomOffset(t *testing.T) { } } -func TestPickHostTreatsNonPositiveWeightsAsOne(t *testing.T) { +func TestPickHostIgnoresNonPositiveWeights(t *testing.T) { u := &PublicHostUsecase{ repo: &publicHostRepoStub{ hosts: []*db.Host{ {ID: "host-a", Hostname: "a", Weight: 0}, {ID: "host-b", Hostname: "b", Weight: -2}, + {ID: "host-c", Hostname: "c", Weight: 1}, }, }, taskflow: &taskflowClientStub{ @@ -77,6 +78,7 @@ func TestPickHostTreatsNonPositiveWeightsAsOne(t *testing.T) { onlineMap: map[string]bool{ "host-a": true, "host-b": true, + "host-c": true, }, }, }, @@ -84,10 +86,10 @@ func TestPickHostTreatsNonPositiveWeightsAsOne(t *testing.T) { prevRandUint64n := randUint64n randUint64n = func(n uint64) (uint64, error) { - if n != 2 { - t.Fatalf("rand limit = %d, want 2", n) + if n != 1 { + t.Fatalf("rand limit = %d, want 1", n) } - return 1, nil + return 0, nil } t.Cleanup(func() { randUint64n = prevRandUint64n @@ -97,8 +99,8 @@ func TestPickHostTreatsNonPositiveWeightsAsOne(t *testing.T) { if err != nil { t.Fatalf("PickHost() error = %v", err) } - if host.ID != "host-b" { - t.Fatalf("PickHost() = %q, want %q", host.ID, "host-b") + if host.ID != "host-c" { + t.Fatalf("PickHost() = %q, want %q", host.ID, "host-c") } } diff --git a/backend/biz/llmproxy/proxy.go b/backend/biz/llmproxy/proxy.go new file mode 100644 index 00000000..f9c124b6 --- /dev/null +++ b/backend/biz/llmproxy/proxy.go @@ -0,0 +1,207 @@ +package llmproxy + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "log/slog" + "net" + "net/http" + "net/http/httputil" + "net/url" + "path" + "strings" + "time" + + "github.com/google/uuid" + + "github.com/chaitin/MonkeyCode/backend/db" + "github.com/chaitin/MonkeyCode/backend/db/modelapikey" +) + +const upstreamFailureMessage = "连接上游模型失败,请检查模型配置,或重试" + +var allowPaths = map[string]string{ + "/v1/chat/completions": "/chat/completions", + "/v1/responses": "/responses", + "/v1/messages": "/messages", +} + +type contextKey struct{} + +type modelContext struct { + modelName string + baseURL string + apiKey string +} + +type proxyContext struct { + model *modelContext + upstreamPath string +} + +type Proxy struct { + db *db.Client + logger *slog.Logger + transport *http.Transport + proxy *httputil.ReverseProxy +} + +func NewProxy(db *db.Client, logger *slog.Logger) *Proxy { + if logger == nil { + logger = slog.Default() + } + p := &Proxy{ + db: db, + logger: logger.With("module", "llmproxy"), + transport: &http.Transport{ + MaxIdleConns: 100, + MaxIdleConnsPerHost: 100, + MaxConnsPerHost: 100, + IdleConnTimeout: 90 * time.Second, + Proxy: http.ProxyFromEnvironment, + DialContext: (&net.Dialer{ + Timeout: 5 * time.Second, + KeepAlive: 30 * time.Second, + }).DialContext, + TLSHandshakeTimeout: 5 * time.Second, + ResponseHeaderTimeout: 300 * time.Second, + }, + } + p.proxy = &httputil.ReverseProxy{ + Transport: p.transport, + Rewrite: p.rewrite, + ErrorHandler: p.errorHandler, + FlushInterval: 100 * time.Millisecond, + } + return p +} + +func (p *Proxy) ServeHTTP(w http.ResponseWriter, r *http.Request) { + upstreamPath, ok := allowPaths[r.URL.Path] + if !ok { + http.NotFound(w, r) + return + } + token, ok := extractToken(r) + if !ok { + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + body, err := io.ReadAll(r.Body) + if err != nil { + http.Error(w, "Bad Request", http.StatusBadRequest) + return + } + r.Body = io.NopCloser(bytes.NewReader(body)) + r.ContentLength = int64(len(body)) + + reqModel, err := readRequestModel(body) + if err != nil { + http.Error(w, "Bad Request", http.StatusBadRequest) + return + } + m, err := p.resolveModel(r.Context(), token) + if err != nil { + p.logger.WarnContext(r.Context(), "resolve runtime model failed", "error", err) + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + if reqModel != "" && reqModel != m.modelName { + p.logger.WarnContext(r.Context(), "model mismatch", "request_model", reqModel, "expected_model", m.modelName) + http.Error(w, "Forbidden", http.StatusForbidden) + return + } + + ctx := context.WithValue(r.Context(), contextKey{}, &proxyContext{ + model: m, + upstreamPath: upstreamPath, + }) + p.proxy.ServeHTTP(w, r.WithContext(ctx)) +} + +func (p *Proxy) resolveModel(ctx context.Context, token string) (*modelContext, error) { + keyID, err := uuid.Parse(token) + query := p.db.ModelApiKey.Query(). + WithModel(). + Where(modelapikey.APIKey(token)) + if err == nil { + query = p.db.ModelApiKey.Query(). + WithModel(). + Where(modelapikey.Or(modelapikey.ID(keyID), modelapikey.APIKey(token))) + } + key, err := query.Only(ctx) + if err != nil { + return nil, err + } + if key.Edges.Model == nil { + return nil, errors.New("model not found") + } + return &modelContext{ + modelName: key.Edges.Model.Model, + baseURL: key.Edges.Model.BaseURL, + apiKey: key.Edges.Model.APIKey, + }, nil +} + +func (p *Proxy) rewrite(r *httputil.ProxyRequest) { + ctx, ok := r.In.Context().Value(contextKey{}).(*proxyContext) + if !ok || ctx == nil || ctx.model == nil { + p.logger.WarnContext(r.In.Context(), "missing model context") + return + } + m := ctx.model + baseURL, err := url.Parse(m.baseURL) + if err != nil { + p.logger.ErrorContext(r.In.Context(), "parse model base url failed", "base_url", m.baseURL, "error", err) + return + } + r.Out.URL.Scheme = baseURL.Scheme + r.Out.URL.Host = baseURL.Host + r.Out.URL.Path = joinURLPath(baseURL.Path, ctx.upstreamPath) + r.Out.URL.RawQuery = r.In.URL.RawQuery + r.Out.Host = baseURL.Host + r.Out.Header.Set("Authorization", "Bearer "+m.apiKey) + r.Out.Header.Set("X-Api-Key", m.apiKey) + r.SetXForwarded() +} + +func (p *Proxy) errorHandler(w http.ResponseWriter, r *http.Request, err error) { + p.logger.ErrorContext(r.Context(), "proxy upstream failed", "path", r.URL.Path, "error", err) + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + w.WriteHeader(http.StatusBadGateway) + _, _ = w.Write([]byte(upstreamFailureMessage)) +} + +func extractToken(req *http.Request) (string, bool) { + token := strings.TrimSpace(req.Header.Get("X-Api-Key")) + if token != "" { + return token, true + } + token, ok := strings.CutPrefix(req.Header.Get("Authorization"), "Bearer ") + if !ok { + return "", false + } + token = strings.TrimSpace(token) + return token, token != "" +} + +func readRequestModel(body []byte) (string, error) { + var payload struct { + Model string `json:"model"` + } + if err := json.Unmarshal(body, &payload); err != nil { + return "", fmt.Errorf("parse llm request: %w", err) + } + return payload.Model, nil +} + +func joinURLPath(basePath, requestPath string) string { + if basePath == "" || basePath == "/" { + return requestPath + } + return path.Join(basePath, requestPath) +} diff --git a/backend/biz/llmproxy/proxy_test.go b/backend/biz/llmproxy/proxy_test.go new file mode 100644 index 00000000..4ded27f8 --- /dev/null +++ b/backend/biz/llmproxy/proxy_test.go @@ -0,0 +1,166 @@ +package llmproxy + +import ( + "context" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/google/uuid" + _ "github.com/mattn/go-sqlite3" + + "github.com/chaitin/MonkeyCode/backend/consts" + "github.com/chaitin/MonkeyCode/backend/db" + "github.com/chaitin/MonkeyCode/backend/db/enttest" +) + +func newProxyTestDB(t *testing.T) *db.Client { + t.Helper() + client := enttest.Open(t, "sqlite3", "file:llmproxy-test?mode=memory&cache=shared&_fk=1") + t.Cleanup(func() { _ = client.Close() }) + return client +} + +func seedProxyModel(t *testing.T, client *db.Client, upstreamURL string) string { + t.Helper() + ctx := context.Background() + userID := uuid.New() + modelID := uuid.New() + key := "runtime-" + uuid.NewString() + + if _, err := client.User.Create(). + SetID(userID). + SetName("user"). + SetRole(consts.UserRoleIndividual). + SetStatus(consts.UserStatusActive). + Save(ctx); err != nil { + t.Fatal(err) + } + if _, err := client.Model.Create(). + SetID(modelID). + SetUserID(userID). + SetProvider("OpenAI"). + SetAPIKey("real-model-key"). + SetBaseURL(upstreamURL). + SetModel("gpt-4o"). + Save(ctx); err != nil { + t.Fatal(err) + } + if _, err := client.ModelApiKey.Create(). + SetID(uuid.New()). + SetUserID(userID). + SetModelID(modelID). + SetAPIKey(key). + Save(ctx); err != nil { + t.Fatal(err) + } + return key +} + +func TestProxyForwardsRuntimeKeyToUpstreamModel(t *testing.T) { + var gotPath string + var gotAuth string + var gotBody string + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + gotAuth = r.Header.Get("Authorization") + body, err := io.ReadAll(r.Body) + if err != nil { + t.Fatal(err) + } + gotBody = string(body) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"id":"chatcmpl_test","choices":[{"message":{"content":"ok"}}]}`)) + })) + t.Cleanup(upstream.Close) + + client := newProxyTestDB(t) + runtimeKey := seedProxyModel(t, client, upstream.URL+"/v1") + proxy := NewProxy(client, slog.New(slog.NewTextHandler(io.Discard, nil))) + + body := `{"model":"gpt-4o","messages":[{"role":"user","content":"hi"}]}` + req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(body)) + req.Header.Set("Authorization", "Bearer "+runtimeKey) + rec := httptest.NewRecorder() + + proxy.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String()) + } + if gotPath != "/v1/chat/completions" { + t.Fatalf("upstream path = %q", gotPath) + } + if gotAuth != "Bearer real-model-key" { + t.Fatalf("upstream auth = %q", gotAuth) + } + if gotBody != body { + t.Fatalf("upstream body = %q", gotBody) + } +} + +func TestProxyRejectsMissingRuntimeKey(t *testing.T) { + client := newProxyTestDB(t) + proxy := NewProxy(client, slog.New(slog.NewTextHandler(io.Discard, nil))) + + req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{"model":"gpt-4o"}`)) + rec := httptest.NewRecorder() + + proxy.ServeHTTP(rec, req) + + if rec.Code != http.StatusUnauthorized { + t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String()) + } +} + +func TestProxyRejectsModelMismatch(t *testing.T) { + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Fatal("upstream should not be called") + })) + t.Cleanup(upstream.Close) + + client := newProxyTestDB(t) + runtimeKey := seedProxyModel(t, client, upstream.URL) + proxy := NewProxy(client, slog.New(slog.NewTextHandler(io.Discard, nil))) + + req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{"model":"other-model"}`)) + req.Header.Set("Authorization", "Bearer "+runtimeKey) + rec := httptest.NewRecorder() + + proxy.ServeHTTP(rec, req) + + if rec.Code != http.StatusForbidden { + t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String()) + } +} + +func TestProxyAppendsEndpointToVersionedBaseURL(t *testing.T) { + var gotPath string + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"id":"resp_test"}`)) + })) + t.Cleanup(upstream.Close) + + client := newProxyTestDB(t) + runtimeKey := seedProxyModel(t, client, upstream.URL+"/v1") + proxy := NewProxy(client, slog.New(slog.NewTextHandler(io.Discard, nil))) + + req := httptest.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader(`{"model":"gpt-4o","input":"hi"}`)) + req.Header.Set("X-Api-Key", runtimeKey) + rec := httptest.NewRecorder() + + proxy.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String()) + } + if gotPath != "/v1/responses" { + t.Fatalf("upstream path = %q, want /v1/responses", gotPath) + } +} diff --git a/backend/biz/llmproxy/register.go b/backend/biz/llmproxy/register.go new file mode 100644 index 00000000..db458f5c --- /dev/null +++ b/backend/biz/llmproxy/register.go @@ -0,0 +1,40 @@ +package llmproxy + +import ( + "log/slog" + + "github.com/GoYoko/web" + "github.com/samber/do" + + "github.com/chaitin/MonkeyCode/backend/db" +) + +type Handler struct { + proxy *Proxy +} + +func ProvideLLMProxy(i *do.Injector) { + do.Provide(i, NewHandler) +} + +func InvokeLLMProxy(i *do.Injector) { + do.MustInvoke[*Handler](i) +} + +func NewHandler(i *do.Injector) (*Handler, error) { + w := do.MustInvoke[*web.Web](i) + client := do.MustInvoke[*db.Client](i) + logger := do.MustInvoke[*slog.Logger](i) + + h := &Handler{proxy: NewProxy(client, logger)} + g := w.Group("/v1") + g.POST("/chat/completions", web.BaseHandler(h.ServeHTTP)) + g.POST("/responses", web.BaseHandler(h.ServeHTTP)) + g.POST("/messages", web.BaseHandler(h.ServeHTTP)) + return h, nil +} + +func (h *Handler) ServeHTTP(c *web.Context) error { + h.proxy.ServeHTTP(c.Response(), c.Request()) + return nil +} diff --git a/backend/biz/llmproxy/register_test.go b/backend/biz/llmproxy/register_test.go new file mode 100644 index 00000000..5fa16fb5 --- /dev/null +++ b/backend/biz/llmproxy/register_test.go @@ -0,0 +1,43 @@ +package llmproxy + +import ( + "io" + "log/slog" + "testing" + + "github.com/GoYoko/web" + "github.com/samber/do" + + "github.com/chaitin/MonkeyCode/backend/db" +) + +func TestNewHandlerRegistersProxyRoutes(t *testing.T) { + injector := do.New() + w := web.New() + do.ProvideValue(injector, w) + do.ProvideValue(injector, newProxyTestDB(t)) + do.ProvideValue(injector, slog.New(slog.NewTextHandler(io.Discard, nil))) + + if _, err := NewHandler(injector); err != nil { + t.Fatal(err) + } + + want := map[string]bool{ + "POST /v1/chat/completions": false, + "POST /v1/responses": false, + "POST /v1/messages": false, + } + for _, route := range w.Routes() { + key := route.Method + " " + route.Path + if _, ok := want[key]; ok { + want[key] = true + } + } + for route, found := range want { + if !found { + t.Fatalf("route %s is not registered", route) + } + } + + _ = do.MustInvoke[*db.Client](injector) +} diff --git a/backend/biz/register.go b/backend/biz/register.go index 8e227ac7..62222b2b 100644 --- a/backend/biz/register.go +++ b/backend/biz/register.go @@ -1,25 +1,33 @@ package biz import ( + "context" + + "github.com/google/uuid" "github.com/samber/do" "github.com/chaitin/MonkeyCode/backend/biz/file" "github.com/chaitin/MonkeyCode/backend/biz/git" "github.com/chaitin/MonkeyCode/backend/biz/host" + "github.com/chaitin/MonkeyCode/backend/biz/llmproxy" "github.com/chaitin/MonkeyCode/backend/biz/notify" "github.com/chaitin/MonkeyCode/backend/biz/project" "github.com/chaitin/MonkeyCode/backend/biz/public" "github.com/chaitin/MonkeyCode/backend/biz/setting" + "github.com/chaitin/MonkeyCode/backend/biz/static" + "github.com/chaitin/MonkeyCode/backend/biz/subscription" "github.com/chaitin/MonkeyCode/backend/biz/task" "github.com/chaitin/MonkeyCode/backend/biz/team" + "github.com/chaitin/MonkeyCode/backend/biz/uploader" "github.com/chaitin/MonkeyCode/backend/biz/user" "github.com/chaitin/MonkeyCode/backend/biz/vmidle" + "github.com/chaitin/MonkeyCode/backend/consts" + "github.com/chaitin/MonkeyCode/backend/domain" ) // RegisterAll 注册所有 biz 模块 // 分两阶段:先 Provide(懒注册),再 Invoke(解析依赖),避免模块间循环依赖 func RegisterAll(i *do.Injector) error { - // 阶段一:所有模块注册服务工厂(do.Provide,不触发依赖解析) notify.ProvideNotify(i) public.ProvidePublic(i) user.ProvideUser(i) @@ -31,8 +39,10 @@ func RegisterAll(i *do.Injector) error { project.ProvideProject(i) file.ProvideFile(i) vmidle.ProvideVMIdle(i) + return nil +} - // 阶段二:统一触发 handler 初始化(do.MustInvoke,此时所有服务已注册) +func InvokeAll(i *do.Injector) { notify.InvokeNotify(i) public.InvokePublic(i) user.InvokeUser(i) @@ -44,6 +54,44 @@ func RegisterAll(i *do.Injector) error { project.InvokeProject(i) file.InvokeFile(i) vmidle.InvokeVMIdle(i) +} + +// RegisterOpenSource 注册仅在开源项目中使用的模块 +func RegisterOpenSource(i *do.Injector) { + subscription.ProvideSubscription(i) + uploader.ProvideUploader(i) + llmproxy.ProvideLLMProxy(i) + static.ProviderStatic(i) + do.ProvideValue[domain.TaskHook](i, &taskhook{}) +} + +func InvokeOpenSource(i *do.Injector) { + subscription.InvokeSubscription(i) + uploader.InvokeUploader(i) + llmproxy.InvokeLLMProxy(i) + static.InvokeStatic(i) +} +type taskhook struct{} + +// GetMaxConcurrent implements [domain.TaskHook]. +func (t *taskhook) GetMaxConcurrent(ctx context.Context, uid uuid.UUID) (int, error) { + return 3, nil +} + +// GetSystemPrompt implements [domain.TaskHook]. +func (t *taskhook) GetSystemPrompt(ctx context.Context, taskType consts.TaskType, subType consts.TaskSubType) (string, error) { + return "", nil +} + +// GitTask implements [domain.TaskHook]. +func (t *taskhook) GitTask(ctx context.Context, id uuid.UUID) (*domain.GitTask, error) { + return &domain.GitTask{}, nil +} + +// OnTaskCreated implements [domain.TaskHook]. +func (t *taskhook) OnTaskCreated(ctx context.Context, task *domain.ProjectTask) error { return nil } + +var _ domain.TaskHook = &taskhook{} diff --git a/backend/biz/static/handler/static.go b/backend/biz/static/handler/static.go new file mode 100644 index 00000000..e58b2b36 --- /dev/null +++ b/backend/biz/static/handler/static.go @@ -0,0 +1,21 @@ +package handler + +import ( + "github.com/GoYoko/web" + "github.com/samber/do" + + "github.com/chaitin/MonkeyCode/backend/config" +) + +type StaticHandler struct { +} + +func NewStaticHandler(i *do.Injector) (*StaticHandler, error) { + w := do.MustInvoke[*web.Web](i) + cfg := do.MustInvoke[*config.Config](i) + + s := &StaticHandler{} + + w.Echo().Static(cfg.StaticFiles.RoutePrefix, cfg.StaticFiles.Dir) + return s, nil +} diff --git a/backend/biz/static/register.go b/backend/biz/static/register.go new file mode 100644 index 00000000..0bfe85cf --- /dev/null +++ b/backend/biz/static/register.go @@ -0,0 +1,16 @@ +package static + +import ( + "github.com/samber/do" + + "github.com/chaitin/MonkeyCode/backend/biz/static/handler" +) + +func ProviderStatic(i *do.Injector) { + do.Provide(i, handler.NewStaticHandler) + handler.NewStaticHandler(i) +} + +func InvokeStatic(i *do.Injector) { + do.MustInvoke[*handler.StaticHandler](i) +} diff --git a/backend/biz/subscription/handler/v1/subscription.go b/backend/biz/subscription/handler/v1/subscription.go new file mode 100644 index 00000000..b9f87a3a --- /dev/null +++ b/backend/biz/subscription/handler/v1/subscription.go @@ -0,0 +1,48 @@ +package v1 + +import ( + "log/slog" + + "github.com/GoYoko/web" + "github.com/samber/do" + + "github.com/chaitin/MonkeyCode/backend/domain" + "github.com/chaitin/MonkeyCode/backend/errcode" + "github.com/chaitin/MonkeyCode/backend/middleware" +) + +type Handler struct { + logger *slog.Logger +} + +func NewHandler(i *do.Injector) (*Handler, error) { + w := do.MustInvoke[*web.Web](i) + logger := do.MustInvoke[*slog.Logger](i) + auth := do.MustInvoke[*middleware.AuthMiddleware](i) + targetActive := do.MustInvoke[*middleware.TargetActiveMiddleware](i) + + h := &Handler{logger: logger.With("module", "subscription.handler")} + w.GET("/api/v1/users/subscription", web.BaseHandler(h.Get), auth.Auth(), targetActive.TargetActive()) + return h, nil +} + +// Get 查询开源版订阅状态 +// +// @Summary 查询当前会员状态 +// @Description 开源版固定返回基础订阅状态 +// @Tags 【用户】会员 +// @Accept json +// @Produce json +// @Security MonkeyCodeAIAuth +// @Success 200 {object} web.Resp{data=domain.SubscriptionResp} "成功" +// @Failure 401 {object} web.Resp "未授权,用户未登录" +// @Router /api/v1/users/subscription [get] +func (h *Handler) Get(c *web.Context) error { + if middleware.GetUser(c) == nil { + return errcode.ErrUnauthorized + } + return c.Success(domain.SubscriptionResp{ + Plan: "pro", + AutoRenew: false, + }) +} diff --git a/backend/biz/subscription/register.go b/backend/biz/subscription/register.go new file mode 100644 index 00000000..fea04dc7 --- /dev/null +++ b/backend/biz/subscription/register.go @@ -0,0 +1,15 @@ +package subscription + +import ( + "github.com/samber/do" + + v1 "github.com/chaitin/MonkeyCode/backend/biz/subscription/handler/v1" +) + +func ProvideSubscription(i *do.Injector) { + do.Provide(i, v1.NewHandler) +} + +func InvokeSubscription(i *do.Injector) { + do.MustInvoke[*v1.Handler](i) +} diff --git a/backend/biz/subscription/register_test.go b/backend/biz/subscription/register_test.go new file mode 100644 index 00000000..ce545b90 --- /dev/null +++ b/backend/biz/subscription/register_test.go @@ -0,0 +1,40 @@ +package subscription + +import ( + "io" + "log/slog" + "testing" + + "github.com/GoYoko/web" + "github.com/samber/do" + + "github.com/chaitin/MonkeyCode/backend/middleware" +) + +func TestNewHandlerRegistersSubscriptionRoute(t *testing.T) { + injector := do.New() + w := web.New() + do.ProvideValue(injector, w) + do.ProvideValue(injector, slog.New(slog.NewTextHandler(io.Discard, nil))) + do.ProvideValue(injector, &middleware.AuthMiddleware{}) + do.ProvideValue(injector, middleware.NewTargetActiveMiddleware(slog.New(slog.NewTextHandler(io.Discard, nil)), nil)) + + ProvideSubscription(injector) + InvokeSubscription(injector) + + if !hasRoute(w, "GET", "/api/v1/users/subscription") { + t.Fatal("GET /api/v1/users/subscription route is not registered") + } + if hasRoute(w, "POST", "/api/v1/users/subscription") { + t.Fatal("POST /api/v1/users/subscription should not be registered in opensource") + } +} + +func hasRoute(w *web.Web, method, path string) bool { + for _, route := range w.Routes() { + if route.Method == method && route.Path == path { + return true + } + } + return false +} diff --git a/backend/biz/team/handler/http/v1/user.go b/backend/biz/team/handler/http/v1/user.go index edc413de..3c6e6927 100644 --- a/backend/biz/team/handler/http/v1/user.go +++ b/backend/biz/team/handler/http/v1/user.go @@ -60,6 +60,7 @@ func NewTeamGroupUserHandler(i *do.Injector) (*TeamGroupUserHandler, error) { u.POST("/logout", web.BaseHandler(h.Logout), auth.TeamAuthCheck()) u.GET("/status", web.BaseHandler(h.Status), auth.TeamAuthCheck()) u.PUT("/passwords/change", web.BindHandler(h.ChangePassword), auth.TeamAuth(), audit.Audit("change_team_user_password")) + u.POST("/with-password", web.BindHandler(h.AddUserWithPassword), auth.TeamAuth(), adminAuth, audit.Audit("add_team_user_with_password")) u.POST("", web.BindHandler(h.AddUser), auth.TeamAuth(), adminAuth, audit.Audit("add_team_user")) u.GET("", web.BindHandler(h.MemberList), auth.TeamAuth(), adminAuth) u.PUT("/:user_id", web.BindHandler(h.UpdateUser), auth.TeamAuth(), adminAuth, audit.Audit("update_team_user")) @@ -210,6 +211,28 @@ func (h *TeamGroupUserHandler) AddUser(c *web.Context, req domain.AddTeamUserReq return c.Success(resp) } +// AddUserWithPassword 创建团队成员并返回初始密码 +// +// @Summary 创建团队成员并返回初始密码 +// @Description 创建团队成员,后端生成初始密码并只在响应中返回一次 +// @Tags 【Team 管理员】分组成员管理 +// @Accept json +// @Produce json +// @Security MonkeyCodeAITeamAuth +// @Param req body domain.AddTeamUserReq true "请求参数" +// @Success 200 {object} web.Resp{data=domain.AddTeamUserWithPasswordResp} "成功" +// @Failure 401 {object} web.Resp "未授权" +// @Failure 500 {object} web.Resp "服务器内部错误" +// @Router /api/v1/teams/users/with-password [post] +func (h *TeamGroupUserHandler) AddUserWithPassword(c *web.Context, req domain.AddTeamUserReq) error { + teamUser := middleware.GetTeamUser(c) + resp, err := h.usecase.AddUserWithPassword(c.Request().Context(), teamUser, &req) + if err != nil { + return err + } + return c.Success(resp) +} + // AddAdmin 创建团队管理员 // // @Summary 创建团队管理员 diff --git a/backend/biz/team/repo/user.go b/backend/biz/team/repo/user.go index 8836f314..5035513e 100644 --- a/backend/biz/team/repo/user.go +++ b/backend/biz/team/repo/user.go @@ -12,8 +12,11 @@ import ( "github.com/chaitin/MonkeyCode/backend/config" "github.com/chaitin/MonkeyCode/backend/consts" "github.com/chaitin/MonkeyCode/backend/db" + "github.com/chaitin/MonkeyCode/backend/db/image" "github.com/chaitin/MonkeyCode/backend/db/teamgroup" + "github.com/chaitin/MonkeyCode/backend/db/teamgroupimage" "github.com/chaitin/MonkeyCode/backend/db/teamgroupmember" + "github.com/chaitin/MonkeyCode/backend/db/teamimage" "github.com/chaitin/MonkeyCode/backend/db/teammember" "github.com/chaitin/MonkeyCode/backend/db/user" "github.com/chaitin/MonkeyCode/backend/domain" @@ -30,6 +33,8 @@ type TeamGroupUserRepo struct { logger *slog.Logger } +const defaultTeamGroupName = "默认分组" + // NewTeamGroupUserRepo 创建团队分组成员数据访问层 (samber/do 风格) func NewTeamGroupUserRepo(i *do.Injector) (domain.TeamGroupUserRepo, error) { return &TeamGroupUserRepo{ @@ -124,6 +129,80 @@ func (r *TeamGroupUserRepo) CreateUsers(ctx context.Context, teamID uuid.UUID, r return users, nil } +func (r *TeamGroupUserRepo) CreateUsersWithPassword(ctx context.Context, teamID uuid.UUID, req *domain.AddTeamUserWithPasswordReq) ([]*db.User, error) { + var users []*db.User + + for _, emailAddr := range req.Emails { + existingUser, err := r.db.User.Query().Where(user.EmailEQ(emailAddr)).First(ctx) + if err == nil && existingUser != nil { + _, err := r.db.TeamMember.Query().Where( + teammember.TeamIDEQ(teamID), + teammember.UserIDEQ(existingUser.ID), + ).First(ctx) + if err == nil { + continue + } + if existingUser.Password == "" { + hashedPassword, err := crypto.HashPassword(req.Passwords[emailAddr]) + if err != nil { + r.logger.ErrorContext(ctx, "hash password failed", "error", err, "email", emailAddr) + continue + } + existingUser, err = r.db.User.UpdateOneID(existingUser.ID). + SetPassword(hashedPassword). + Save(ctx) + if err != nil { + r.logger.ErrorContext(ctx, "set user password failed", "error", err, "email", emailAddr) + continue + } + } + _, err = r.db.TeamMember.Create(). + SetID(uuid.New()). + SetTeamID(teamID). + SetUserID(existingUser.ID). + SetRole(consts.TeamMemberRoleUser). + Save(ctx) + if err != nil { + r.logger.ErrorContext(ctx, "add user to team failed", "error", err) + continue + } + users = append(users, existingUser) + continue + } + + hashedPassword, err := crypto.HashPassword(req.Passwords[emailAddr]) + if err != nil { + r.logger.ErrorContext(ctx, "hash password failed", "error", err, "email", emailAddr) + continue + } + newUser, err := r.db.User.Create(). + SetID(uuid.New()). + SetName(emailAddr). + SetEmail(emailAddr). + SetStatus(consts.UserStatusActive). + SetPassword(hashedPassword). + SetRole(consts.UserRoleSubAccount). + Save(ctx) + if err != nil { + r.logger.ErrorContext(ctx, "create user failed", "error", err, "email", emailAddr) + continue + } + + _, err = r.db.TeamMember.Create(). + SetID(uuid.New()). + SetTeamID(teamID). + SetUserID(newUser.ID). + SetRole(consts.TeamMemberRoleUser). + Save(ctx) + if err != nil { + r.logger.ErrorContext(ctx, "add user to team failed", "error", err) + continue + } + users = append(users, newUser) + } + return users, nil +} + // CreateAdmin 创建团队管理员 func (r *TeamGroupUserRepo) CreateAdmin(ctx context.Context, teamID uuid.UUID, req *domain.AddTeamAdminReq) (*db.User, error) { // 检查邮箱是否已注册 @@ -324,51 +403,148 @@ func (r *TeamGroupUserRepo) GetMember(ctx context.Context, teamID, userID uuid.U } // InitTeam 初始化团队:创建用户(如果不存在)、创建团队、并将用户设为管理员。 -func (r *TeamGroupUserRepo) InitTeam(ctx context.Context, email string, name string, password string) error { +func (r *TeamGroupUserRepo) InitTeam(ctx context.Context, email string, name string, password string, imageName string) error { return entx.WithTx2(ctx, r.db, func(tx *db.Tx) error { // 检查用户是否已存在 - exists, err := tx.User.Query().Where(user.EmailEQ(email)).Exist(ctx) + existingUser, err := tx.User.Query().Where(user.EmailEQ(email)).First(ctx) if err != nil { - return err + if !db.IsNotFound(err) { + return err + } } - if exists { - return nil + + var initUser *db.User + var initTeam *db.Team + if existingUser == nil { + // 哈希密码 + hashedPassword, err := crypto.HashPassword(password) + if err != nil { + return err + } + + // 创建用户 + initUser, err = tx.User.Create(). + SetID(uuid.New()). + SetName(name). + SetEmail(email). + SetStatus(consts.UserStatusActive). + SetPassword(hashedPassword). + SetRole(consts.UserRoleEnterprise). + Save(ctx) + if err != nil { + return err + } + + // 创建团队 + initTeam, err = tx.Team.Create(). + SetID(uuid.New()). + SetName(name). + SetMemberLimit(1000). + Save(ctx) + if err != nil { + return err + } + + // 将用户添加为团队管理员 + if _, err = tx.TeamMember.Create(). + SetID(uuid.New()). + SetTeamID(initTeam.ID). + SetUserID(initUser.ID). + SetRole(consts.TeamMemberRoleAdmin). + Save(ctx); err != nil { + return err + } + } else { + initUser = existingUser + member, err := tx.TeamMember.Query(). + Where(teammember.UserIDEQ(initUser.ID)). + First(ctx) + if err != nil { + if db.IsNotFound(err) { + return nil + } + return err + } + initTeam, err = tx.Team.Get(ctx, member.TeamID) + if err != nil { + return err + } } - // 哈希密码 - hashedPassword, err := crypto.HashPassword(password) + defaultGroup, err := r.ensureDefaultTeamGroup(ctx, tx, initTeam.ID) if err != nil { return err } + return r.initTeamImage(ctx, tx, initTeam.ID, defaultGroup.ID, initUser.ID, imageName) + }) +} - // 创建用户 - newUser, err := tx.User.Create(). - SetName(name). - SetEmail(email). - SetStatus(consts.UserStatusActive). - SetPassword(hashedPassword). - SetRole(consts.UserRoleEnterprise). - Save(ctx) - if err != nil { +func (r *TeamGroupUserRepo) ensureDefaultTeamGroup(ctx context.Context, tx *db.Tx, teamID uuid.UUID) (*db.TeamGroup, error) { + group, err := tx.TeamGroup.Query(). + Where(teamgroup.TeamIDEQ(teamID), teamgroup.NameEQ(defaultTeamGroupName)). + First(ctx) + if err == nil { + return group, nil + } + if !db.IsNotFound(err) { + return nil, err + } + return tx.TeamGroup.Create(). + SetID(uuid.New()). + SetTeamID(teamID). + SetName(defaultTeamGroupName). + Save(ctx) +} + +func (r *TeamGroupUserRepo) initTeamImage(ctx context.Context, tx *db.Tx, teamID, groupID, userID uuid.UUID, imageName string) error { + if imageName == "" { + return nil + } + img, err := tx.Image.Query(). + Where(image.UserIDEQ(userID), image.NameEQ(imageName)). + First(ctx) + if err != nil { + if !db.IsNotFound(err) { return err } - - // 创建团队 - newTeam, err := tx.Team.Create(). + img, err = tx.Image.Create(). SetID(uuid.New()). - SetName(name). - SetMemberLimit(1000). + SetUserID(userID). + SetName(imageName). + SetRemark("MonkeyCode-AI 默认开发环境"). Save(ctx) if err != nil { return err } + } - // 将用户添加为团队管理员 - _, err = tx.TeamMember.Create(). - SetTeamID(newTeam.ID). - SetUserID(newUser.ID). - SetRole(consts.TeamMemberRoleAdmin). - Save(ctx) + exists, err := tx.TeamImage.Query(). + Where(teamimage.TeamIDEQ(teamID), teamimage.ImageIDEQ(img.ID)). + Exist(ctx) + if err != nil { return err - }) + } + if !exists { + if err := tx.TeamImage.Create(). + SetID(uuid.New()). + SetTeamID(teamID). + SetImageID(img.ID). + Exec(ctx); err != nil { + return err + } + } + groupImageExists, err := tx.TeamGroupImage.Query(). + Where(teamgroupimage.GroupIDEQ(groupID), teamgroupimage.ImageIDEQ(img.ID)). + Exist(ctx) + if err != nil { + return err + } + if groupImageExists { + return nil + } + return tx.TeamGroupImage.Create(). + SetID(uuid.New()). + SetGroupID(groupID). + SetImageID(img.ID). + Exec(ctx) } diff --git a/backend/biz/team/repo/user_test.go b/backend/biz/team/repo/user_test.go new file mode 100644 index 00000000..9e558665 --- /dev/null +++ b/backend/biz/team/repo/user_test.go @@ -0,0 +1,198 @@ +package repo + +import ( + "context" + "io" + "log/slog" + "testing" + + "github.com/google/uuid" + _ "github.com/mattn/go-sqlite3" + + "github.com/chaitin/MonkeyCode/backend/consts" + "github.com/chaitin/MonkeyCode/backend/db" + "github.com/chaitin/MonkeyCode/backend/db/enttest" + "github.com/chaitin/MonkeyCode/backend/db/image" + "github.com/chaitin/MonkeyCode/backend/db/teamgroup" + "github.com/chaitin/MonkeyCode/backend/db/teamgroupimage" + "github.com/chaitin/MonkeyCode/backend/db/teamimage" + "github.com/chaitin/MonkeyCode/backend/db/teammember" + "github.com/chaitin/MonkeyCode/backend/domain" + "github.com/chaitin/MonkeyCode/backend/pkg/crypto" +) + +func newTeamRepoTestDB(t *testing.T) *db.Client { + t.Helper() + client := enttest.Open(t, "sqlite3", "file:team-repo-test?mode=memory&cache=shared&_fk=1") + t.Cleanup(func() { _ = client.Close() }) + return client +} + +func TestInitTeamCreatesConfiguredImage(t *testing.T) { + ctx := context.Background() + client := newTeamRepoTestDB(t) + repo := &TeamGroupUserRepo{ + db: client, + logger: slog.New(slog.NewTextHandler(io.Discard, nil)), + } + + if err := repo.InitTeam(ctx, "admin@example.com", "MonkeyCode", "password", "ghcr.io/chaitin/monkeycode-runner/devbox:latest"); err != nil { + t.Fatal(err) + } + + admin, err := client.User.Query().First(ctx) + if err != nil { + t.Fatal(err) + } + member, err := client.TeamMember.Query(). + Where(teammember.UserIDEQ(admin.ID)). + First(ctx) + if err != nil { + t.Fatal(err) + } + img, err := client.Image.Query(). + Where(image.UserIDEQ(admin.ID), image.NameEQ("ghcr.io/chaitin/monkeycode-runner/devbox:latest")). + First(ctx) + if err != nil { + t.Fatal(err) + } + exists, err := client.TeamImage.Query(). + Where(teamimage.TeamIDEQ(member.TeamID), teamimage.ImageIDEQ(img.ID)). + Exist(ctx) + if err != nil { + t.Fatal(err) + } + if !exists { + t.Fatal("team image relation was not created") + } + group, err := client.TeamGroup.Query(). + Where(teamgroup.TeamIDEQ(member.TeamID), teamgroup.NameEQ("默认分组")). + First(ctx) + if err != nil { + t.Fatal(err) + } + exists, err = client.TeamGroupImage.Query(). + Where(teamgroupimage.GroupIDEQ(group.ID), teamgroupimage.ImageIDEQ(img.ID)). + Exist(ctx) + if err != nil { + t.Fatal(err) + } + if !exists { + t.Fatal("default group image relation was not created") + } + + if err := repo.InitTeam(ctx, "admin@example.com", "MonkeyCode", "password", "ghcr.io/chaitin/monkeycode-runner/devbox:latest"); err != nil { + t.Fatal(err) + } + if count, err := client.Image.Query().Where(image.NameEQ("ghcr.io/chaitin/monkeycode-runner/devbox:latest")).Count(ctx); err != nil { + t.Fatal(err) + } else if count != 1 { + t.Fatalf("image count = %d, want 1", count) + } + if count, err := client.TeamGroup.Query().Where(teamgroup.TeamIDEQ(member.TeamID), teamgroup.NameEQ("默认分组")).Count(ctx); err != nil { + t.Fatal(err) + } else if count != 1 { + t.Fatalf("default group count = %d, want 1", count) + } +} + +func TestInitTeamSkipsImageWhenConfigEmpty(t *testing.T) { + ctx := context.Background() + client := newTeamRepoTestDB(t) + repo := &TeamGroupUserRepo{ + db: client, + logger: slog.New(slog.NewTextHandler(io.Discard, nil)), + } + + if err := repo.InitTeam(ctx, "admin@example.com", "MonkeyCode", "password", ""); err != nil { + t.Fatal(err) + } + + if count, err := client.Image.Query().Count(ctx); err != nil { + t.Fatal(err) + } else if count != 0 { + t.Fatalf("image count = %d, want 0", count) + } +} + +func TestInitTeamAddsImageForExistingTeam(t *testing.T) { + ctx := context.Background() + client := newTeamRepoTestDB(t) + repo := &TeamGroupUserRepo{ + db: client, + logger: slog.New(slog.NewTextHandler(io.Discard, nil)), + } + userID := uuid.New() + teamID := uuid.New() + if _, err := client.User.Create(). + SetID(userID). + SetName("admin"). + SetEmail("admin@example.com"). + SetPassword("hashed"). + SetRole(consts.UserRoleEnterprise). + SetStatus(consts.UserStatusActive). + Save(ctx); err != nil { + t.Fatal(err) + } + if _, err := client.Team.Create(). + SetID(teamID). + SetName("MonkeyCode"). + SetMemberLimit(1000). + Save(ctx); err != nil { + t.Fatal(err) + } + if _, err := client.TeamMember.Create(). + SetID(uuid.New()). + SetTeamID(teamID). + SetUserID(userID). + SetRole(consts.TeamMemberRoleAdmin). + Save(ctx); err != nil { + t.Fatal(err) + } + + if err := repo.InitTeam(ctx, "admin@example.com", "MonkeyCode", "password", "ghcr.io/chaitin/monkeycode-runner/devbox:latest"); err != nil { + t.Fatal(err) + } + + if count, err := client.TeamImage.Query().Where(teamimage.TeamIDEQ(teamID)).Count(ctx); err != nil { + t.Fatal(err) + } else if count != 1 { + t.Fatalf("team image count = %d, want 1", count) + } +} + +func TestCreateUsersWithPasswordStoresHashedPassword(t *testing.T) { + ctx := context.Background() + client := newTeamRepoTestDB(t) + repo := &TeamGroupUserRepo{ + db: client, + logger: slog.New(slog.NewTextHandler(io.Discard, nil)), + } + teamID := uuid.New() + if _, err := client.Team.Create(). + SetID(teamID). + SetName("MonkeyCode"). + SetMemberLimit(1000). + Save(ctx); err != nil { + t.Fatal(err) + } + + users, err := repo.CreateUsersWithPassword(ctx, teamID, &domain.AddTeamUserWithPasswordReq{ + Emails: []string{"member@example.com"}, + Passwords: map[string]string{ + "member@example.com": "Abcdef123456", + }, + }) + if err != nil { + t.Fatal(err) + } + if len(users) != 1 { + t.Fatalf("users len = %d, want 1", len(users)) + } + if users[0].Password == "" || users[0].Password == "Abcdef123456" { + t.Fatalf("password should be hashed, got %q", users[0].Password) + } + if err := crypto.VerifyPassword(users[0].Password, "Abcdef123456"); err != nil { + t.Fatalf("verify password failed: %v", err) + } +} diff --git a/backend/biz/team/usecase/user.go b/backend/biz/team/usecase/user.go index fef51417..1916de8d 100644 --- a/backend/biz/team/usecase/user.go +++ b/backend/biz/team/usecase/user.go @@ -15,7 +15,9 @@ import ( "github.com/chaitin/MonkeyCode/backend/db" "github.com/chaitin/MonkeyCode/backend/domain" "github.com/chaitin/MonkeyCode/backend/errcode" + "github.com/chaitin/MonkeyCode/backend/pkg/crypto" "github.com/chaitin/MonkeyCode/backend/pkg/cvt" + "github.com/chaitin/MonkeyCode/backend/pkg/random" ) // TeamGroupUserUsecase 团队分组成员业务逻辑层 @@ -62,7 +64,7 @@ func (u *TeamGroupUserUsecase) initTeam() { } ctx := context.Background() - if err := u.repo.InitTeam(ctx, u.config.InitTeam.Email, name, u.config.InitTeam.Password); err != nil { + if err := u.repo.InitTeam(ctx, u.config.InitTeam.Email, name, u.config.InitTeam.Password, u.config.InitTeam.Image); err != nil { u.logger.ErrorContext(ctx, "init team failed", "error", err) return } @@ -128,6 +130,43 @@ func (u *TeamGroupUserUsecase) AddUser(ctx context.Context, teamUser *domain.Tea return &domain.AddTeamUserResp{Users: teamUsers}, nil } +func (u *TeamGroupUserUsecase) AddUserWithPassword(ctx context.Context, teamUser *domain.TeamUser, req *domain.AddTeamUserReq) (*domain.AddTeamUserWithPasswordResp, error) { + passwords := make(map[string]string, len(req.Emails)) + for _, email := range req.Emails { + passwords[email] = random.String(16) + } + users, err := u.repo.CreateUsersWithPassword(ctx, teamUser.GetTeamID(), &domain.AddTeamUserWithPasswordReq{ + Emails: req.Emails, + GroupID: req.GroupID, + Passwords: passwords, + }) + if err != nil { + return nil, err + } + if u.teamHook != nil { + for _, user := range users { + if err := u.teamHook.OnMemberAdded(ctx, teamUser.GetTeamID(), user.ID); err != nil { + u.logger.WarnContext(ctx, "teamHook.OnMemberAdded failed", "user_id", user.ID, "error", err) + } + } + } + return &domain.AddTeamUserWithPasswordResp{ + Users: cvt.Iter(users, func(_ int, user *db.User) *domain.TeamUser { + return cvt.From(user, &domain.TeamUser{}) + }), + Passwords: cvt.Filter(users, func(_ int, user *db.User) (*domain.TeamUserPassword, bool) { + password, ok := passwords[user.Email] + if !ok || user.Password == "" || crypto.VerifyPassword(user.Password, password) != nil { + return nil, false + } + return &domain.TeamUserPassword{ + Email: user.Email, + Password: password, + }, true + }), + }, nil +} + // AddAdmin 创建团队管理员 func (u *TeamGroupUserUsecase) AddAdmin(ctx context.Context, teamUser *domain.TeamUser, req *domain.AddTeamAdminReq) (*domain.AddTeamAdminResp, error) { user, err := u.repo.CreateAdmin(ctx, teamUser.GetTeamID(), req) diff --git a/backend/biz/uploader/handler/http/v1/uploader.go b/backend/biz/uploader/handler/http/v1/uploader.go new file mode 100644 index 00000000..7f8479b9 --- /dev/null +++ b/backend/biz/uploader/handler/http/v1/uploader.go @@ -0,0 +1,175 @@ +package v1 + +import ( + "bytes" + "context" + "crypto/md5" + "encoding/hex" + "fmt" + "io" + "log/slog" + "net/http" + "path/filepath" + "strings" + "time" + + "github.com/GoYoko/web" + "github.com/google/uuid" + "github.com/samber/do" + + "github.com/chaitin/MonkeyCode/backend/config" + "github.com/chaitin/MonkeyCode/backend/consts" + "github.com/chaitin/MonkeyCode/backend/domain" + "github.com/chaitin/MonkeyCode/backend/errcode" + "github.com/chaitin/MonkeyCode/backend/middleware" + "github.com/chaitin/MonkeyCode/backend/pkg/oss" +) + +const defaultUploadMaxSize = 50 << 20 + +var allowedExtensions = map[string]bool{ + ".jpg": true, ".jpeg": true, ".png": true, ".gif": true, ".webp": true, ".ico": true, ".bmp": true, + ".pdf": true, ".doc": true, ".docx": true, ".xls": true, ".xlsx": true, ".ppt": true, ".pptx": true, + ".txt": true, ".md": true, ".markdown": true, ".csv": true, ".json": true, ".yaml": true, ".yml": true, + ".zip": true, ".tar": true, ".gz": true, ".tgz": true, ".rar": true, ".7z": true, + ".exe": true, ".dmg": true, ".deb": true, ".rpm": true, +} + +type UploaderHandler struct { + cfg *config.Config + logger *slog.Logger + client *oss.Client +} + +func NewUploaderHandler(i *do.Injector) (*UploaderHandler, error) { + cfg := do.MustInvoke[*config.Config](i) + if !cfg.ObjectStorage.Enabled { + return &UploaderHandler{cfg: cfg}, nil + } + w := do.MustInvoke[*web.Web](i) + auth := do.MustInvoke[*middleware.AuthMiddleware](i) + targetActive := do.MustInvoke[*middleware.TargetActiveMiddleware](i) + logger := do.MustInvoke[*slog.Logger](i).With("module", "handler.uploader") + opt := oss.S3Option{ForcePathStyle: cfg.ObjectStorage.ForcePathStyle, InitBucket: cfg.ObjectStorage.InitBucket} + client, err := oss.NewS3Compatible(context.Background(), cfg.ObjectStorage, opt) + if err != nil { + return nil, err + } + h := &UploaderHandler{cfg: cfg, logger: logger, client: client} + g := w.Group("/api/v1/uploader") + g.Use(auth.Auth(), targetActive.TargetActive()) + g.POST("", web.BindHandler(h.Upload)) + g.POST("/presign", web.BindHandler(h.Presign)) + return h, nil +} + +func (h *UploaderHandler) Upload(c *web.Context, req domain.UploadReq) error { + if h == nil || h.client == nil { + return errcode.ErrBadRequest.Wrap(fmt.Errorf("object storage is disabled")) + } + user := middleware.GetUser(c) + if user == nil { + return errcode.ErrUnauthorized + } + if req.File == nil { + return errcode.ErrBadRequest.Wrap(fmt.Errorf("file is required")) + } + maxSize := h.cfg.ObjectStorage.MaxSize + if maxSize <= 0 { + maxSize = defaultUploadMaxSize + } + if req.File.Size > maxSize { + return errcode.ErrBadRequest.Wrap(fmt.Errorf("file exceeds limit")) + } + file, err := req.File.Open() + if err != nil { + return err + } + defer file.Close() + fileData, err := io.ReadAll(io.LimitReader(file, maxSize+1)) + if err != nil { + return err + } + if int64(len(fileData)) > maxSize { + return errcode.ErrBadRequest.Wrap(fmt.Errorf("file exceeds limit")) + } + ext := strings.ToLower(filepath.Ext(req.File.Filename)) + if ext != "" && !allowedExtension(ext) { + return errcode.ErrBadRequest.Wrap(fmt.Errorf("unsupported file extension")) + } + filename := fmt.Sprintf("%s_%s%s", user.ID.String(), fileMD5(fileData), ext) + prefix, err := h.uploadPrefix(req.Usage) + if err != nil { + return err + } + client := h.requestClient(c.Request()) + if err := client.PutFile(c.Request().Context(), prefix, filename, bytes.NewReader(fileData)); err != nil { + h.logger.With("error", err).ErrorContext(c.Request().Context(), "upload object failed") + return err + } + return c.Success(client.GetURL(prefix, filename)) +} + +func (h *UploaderHandler) Presign(c *web.Context, req domain.PresignReq) error { + if h == nil || h.client == nil { + return errcode.ErrBadRequest.Wrap(fmt.Errorf("object storage is disabled")) + } + user := middleware.GetUser(c) + if user == nil { + return errcode.ErrUnauthorized + } + filename, err := presignFilename(user.ID, req.Filename) + if err != nil { + return err + } + presign, err := h.requestClient(c.Request()).Presign(c.Request().Context(), h.cfg.ObjectStorage.TempPrefix, filename, parsePresignExpires(h.cfg.ObjectStorage.PresignExpires)) + if err != nil { + h.logger.With("error", err).ErrorContext(c.Request().Context(), "presign object failed") + return err + } + return c.Success(domain.PresignResp{UploadURL: presign.UploadURL, AccessURL: presign.AccessURL}) +} + +func (h *UploaderHandler) requestClient(r *http.Request) *oss.Client { + return h.client.WithAccessEndpoint(h.cfg.ObjectStorage.AccessEndpoint) +} + +func (h *UploaderHandler) uploadPrefix(usage consts.UploadUsage) (string, error) { + switch usage { + case consts.UploadUsageAvatar: + return h.cfg.ObjectStorage.AvatarPrefix, nil + case consts.UploadUsageSpec: + return h.cfg.ObjectStorage.SpecPrefix, nil + case consts.UploadUsageRepo: + return h.cfg.ObjectStorage.RepoPrefix, nil + default: + return "", errcode.ErrBadRequest.Wrap(fmt.Errorf("unsupported upload usage")) + } +} + +func allowedExtension(ext string) bool { + return allowedExtensions[strings.ToLower(ext)] +} + +func presignFilename(userID uuid.UUID, original string) (string, error) { + ext := strings.ToLower(filepath.Ext(original)) + hash := md5.Sum([]byte(original)) + return fmt.Sprintf("%s_%s%s", userID.String(), hex.EncodeToString(hash[:]), ext), nil +} + +func fileMD5(fileb []byte) string { + hash := md5.New() + hash.Write(fileb) + return hex.EncodeToString(hash.Sum(nil)) +} + +func parsePresignExpires(raw string) time.Duration { + expires, err := time.ParseDuration(strings.TrimSpace(raw)) + if err != nil || expires <= 0 { + return 7 * 24 * time.Hour + } + if expires > 7*24*time.Hour { + return 7 * 24 * time.Hour + } + return expires +} diff --git a/backend/biz/uploader/handler/http/v1/uploader_test.go b/backend/biz/uploader/handler/http/v1/uploader_test.go new file mode 100644 index 00000000..cda8f682 --- /dev/null +++ b/backend/biz/uploader/handler/http/v1/uploader_test.go @@ -0,0 +1,85 @@ +package v1 + +import ( + "context" + "net/http/httptest" + "testing" + "time" + + "github.com/chaitin/MonkeyCode/backend/config" + "github.com/chaitin/MonkeyCode/backend/consts" + "github.com/chaitin/MonkeyCode/backend/pkg/oss" + "github.com/google/uuid" +) + +func TestPresignFilenameKeepsLowercaseExtension(t *testing.T) { + userID := uuid.MustParse("11111111-1111-1111-1111-111111111111") + got, err := presignFilename(userID, "archive.ZIP") + if err != nil { + t.Fatal(err) + } + want := "11111111-1111-1111-1111-111111111111_670070ac98fc89f453cdd612492fc0df.zip" + if got != want { + t.Fatalf("filename = %q, want %q", got, want) + } +} + +func TestAllowedExtensionAllowsMarkdown(t *testing.T) { + if !allowedExtension(".MD") { + t.Fatal("expected .MD allowed") + } +} + +func TestAllowedExtensionRejectsScript(t *testing.T) { + if allowedExtension(".sh") { + t.Fatal("expected .sh rejected") + } +} + +func TestParsePresignExpiresDefaultsToSevenDays(t *testing.T) { + got := parsePresignExpires("") + if got != 7*24*time.Hour { + t.Fatalf("expires = %s", got) + } +} + +func TestParsePresignExpiresClampsToSevenDays(t *testing.T) { + got := parsePresignExpires("240h") + if got != 7*24*time.Hour { + t.Fatalf("expires = %s", got) + } +} + +func TestUploadPrefixSelectsRepoPrefix(t *testing.T) { + h := &UploaderHandler{cfg: &config.Config{}} + h.cfg.ObjectStorage.RepoPrefix = "repo" + got, err := h.uploadPrefix(consts.UploadUsageRepo) + if err != nil { + t.Fatal(err) + } + if got != "repo" { + t.Fatalf("prefix = %q", got) + } +} + +func TestRequestObjectStorageClientUsesConfiguredAccessEndpoint(t *testing.T) { + h := &UploaderHandler{cfg: &config.Config{}} + h.cfg.ObjectStorage.AccessEndpoint = "https://monkeycode.example.com/oss" + h.cfg.ObjectStorage.Bucket = "monkeycode-private" + client, err := oss.NewS3Compatible(context.Background(), config.ObjectStorageConfig{ + Endpoint: "http://internal:9000", + AccessKey: "ak", + AccessKeySecret: "sk", + Bucket: "monkeycode-private", + }, oss.S3Option{ForcePathStyle: true}) + if err != nil { + t.Fatal(err) + } + h.client = client + req := httptest.NewRequest("POST", "http://internal:8888/api/v1/uploader/presign", nil) + + got := h.requestClient(req).GetURL("tmp", "a.txt") + if got != "https://monkeycode.example.com/oss/monkeycode-private/tmp/a.txt" { + t.Fatalf("url = %q", got) + } +} diff --git a/backend/biz/uploader/register.go b/backend/biz/uploader/register.go new file mode 100644 index 00000000..5c7539ba --- /dev/null +++ b/backend/biz/uploader/register.go @@ -0,0 +1,20 @@ +package uploader + +import ( + "github.com/samber/do" + + v1 "github.com/chaitin/MonkeyCode/backend/biz/uploader/handler/http/v1" + "github.com/chaitin/MonkeyCode/backend/config" +) + +func ProvideUploader(i *do.Injector) { + do.Provide(i, v1.NewUploaderHandler) +} + +func InvokeUploader(i *do.Injector) { + cfg := do.MustInvoke[*config.Config](i) + if !cfg.ObjectStorage.Enabled { + return + } + do.MustInvoke[*v1.UploaderHandler](i) +} diff --git a/backend/biz/uploader/register_test.go b/backend/biz/uploader/register_test.go new file mode 100644 index 00000000..2c62b499 --- /dev/null +++ b/backend/biz/uploader/register_test.go @@ -0,0 +1,12 @@ +package uploader + +import ( + "testing" + + "github.com/samber/do" +) + +func TestProvideUploader(t *testing.T) { + i := do.New() + ProvideUploader(i) +} diff --git a/backend/biz/user/handler/v1/auth.go b/backend/biz/user/handler/v1/auth.go index d1a83fa4..bfbb20c2 100644 --- a/backend/biz/user/handler/v1/auth.go +++ b/backend/biz/user/handler/v1/auth.go @@ -57,6 +57,7 @@ func NewAuthHandler(i *do.Injector) (*AuthHandler, error) { // 密码登录 v1.POST("/password-login", web.BindHandler(h.PasswordLogin), targetActive.TargetActive()) + v1.PUT("", web.BindHandler(h.Update), auth.Auth(), targetActive.TargetActive()) v1.PUT("/passwords/change", web.BindHandler(h.ChangePassword), auth.Check(), targetActive.TargetActive()) v1.GET("/status", web.BaseHandler(h.Status), auth.Check(), targetActive.TargetActive()) v1.POST("/logout", web.BaseHandler(h.Logout), auth.Auth(), targetActive.TargetActive()) @@ -102,6 +103,37 @@ func (h *AuthHandler) PasswordLogin(c *web.Context, req domain.TeamLoginReq) err return c.Success(user) } +// Update 更新用户信息 +// +// @Summary 更新用户信息 +// @Description 更新用户昵称和头像 +// @Tags 【用户】用户 +// @Accept multipart/form-data +// @Produce json +// @Security MonkeyCodeAIAuth +// @Param name formData string false "昵称" +// @Param avatar_url formData string false "OSS 头像地址" +// @Success 200 {object} web.Resp{data=domain.UpdateUserResp} +// @Router /api/v1/users [put] +func (h *AuthHandler) Update(c *web.Context, req domain.UpdateUserReq) error { + user := middleware.GetUser(c) + if user == nil { + return errcode.ErrUnauthorized + } + + updated, err := h.usecase.Update(c.Request().Context(), user.ID, req.AvatarURL, req) + if err != nil { + h.logger.ErrorContext(c.Request().Context(), "update user failed", "error", err, "user_id", user.ID) + return err + } + + return c.Success(domain.UpdateUserResp{ + User: updated, + Message: "success", + Success: true, + }) +} + // ChangePassword 修改密码接口 // // @Summary 修改密码 diff --git a/backend/biz/user/handler/v1/auth_update_test.go b/backend/biz/user/handler/v1/auth_update_test.go new file mode 100644 index 00000000..c0edfc63 --- /dev/null +++ b/backend/biz/user/handler/v1/auth_update_test.go @@ -0,0 +1,122 @@ +package v1 + +import ( + "context" + "encoding/json" + "io" + "log/slog" + "mime/multipart" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/GoYoko/web" + "github.com/google/uuid" + "github.com/labstack/echo/v4" + + "github.com/chaitin/MonkeyCode/backend/domain" + "github.com/chaitin/MonkeyCode/backend/middleware" +) + +func TestUpdateUserRoute(t *testing.T) { + userID := uuid.MustParse("11111111-1111-1111-1111-111111111111") + w := web.New() + h := &AuthHandler{ + logger: slog.New(slog.NewTextHandler(io.Discard, nil)), + usecase: &updateUserUsecaseStub{userID: userID}, + } + g := w.Group("/api/v1/users") + g.PUT("", web.BindHandler(h.Update), setUserMiddleware(&domain.User{ID: userID})) + + body := &strings.Builder{} + mw := multipart.NewWriter(body) + if err := mw.WriteField("name", "新昵称"); err != nil { + t.Fatal(err) + } + if err := mw.WriteField("avatar_url", "https://example.com/avatar.png"); err != nil { + t.Fatal(err) + } + if err := mw.Close(); err != nil { + t.Fatal(err) + } + req := httptest.NewRequest(http.MethodPut, "/api/v1/users", strings.NewReader(body.String())) + req.Header.Set(echo.HeaderContentType, mw.FormDataContentType()) + rec := httptest.NewRecorder() + + w.Echo().ServeHTTP(rec, req) + + if rec.Code == http.StatusNotFound { + t.Fatal("PUT /api/v1/users route is not registered") + } + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String()) + } + var resp struct { + Code int `json:"code"` + Data domain.UpdateUserResp `json:"data"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatal(err) + } + if resp.Code != 0 { + t.Fatalf("code = %d", resp.Code) + } + if resp.Data.User == nil || resp.Data.User.Name != "新昵称" || resp.Data.User.AvatarURL != "https://example.com/avatar.png" { + t.Fatalf("data = %#v", resp.Data) + } +} + +func setUserMiddleware(user *domain.User) echo.MiddlewareFunc { + return func(next echo.HandlerFunc) echo.HandlerFunc { + return func(c echo.Context) error { + middleware.SetUser(c, user) + return next(c) + } + } +} + +type updateUserUsecaseStub struct { + domain.UserUsecase + userID uuid.UUID +} + +func (s *updateUserUsecaseStub) Update(ctx context.Context, uid uuid.UUID, avatarURL string, req domain.UpdateUserReq) (*domain.User, error) { + return &domain.User{ + ID: uid, + Name: req.Name, + AvatarURL: avatarURL, + }, nil +} + +func (s *updateUserUsecaseStub) Get(ctx context.Context, uid uuid.UUID) (*domain.User, error) { + return &domain.User{ID: uid}, nil +} + +func (s *updateUserUsecaseStub) GetUserWithTeams(ctx context.Context, userID uuid.UUID) (*domain.TeamUserInfo, error) { + return &domain.TeamUserInfo{}, nil +} + +func (s *updateUserUsecaseStub) PasswordLogin(ctx context.Context, req *domain.TeamLoginReq) (*domain.User, error) { + return nil, nil +} + +func (s *updateUserUsecaseStub) ChangePassword(ctx context.Context, userID uuid.UUID, req *domain.ChangePasswordReq, isReset bool) error { + return nil +} + +func (s *updateUserUsecaseStub) SendResetPasswordEmail(ctx context.Context, req *domain.ResetUserPasswordEmailReq) error { + return nil +} + +func (s *updateUserUsecaseStub) GetUserByEmail(ctx context.Context, emails []string) ([]*domain.User, error) { + return nil, nil +} + +func (s *updateUserUsecaseStub) SendBindEmailVerification(ctx context.Context, userID uuid.UUID, req *domain.SendBindEmailVerificationReq) error { + return nil +} + +func (s *updateUserUsecaseStub) VerifyBindEmail(ctx context.Context, token string) error { + return nil +} diff --git a/backend/bridge.go b/backend/bridge.go index b921c0f7..add075b2 100644 --- a/backend/bridge.go +++ b/backend/bridge.go @@ -121,5 +121,7 @@ func Register(e *echo.Echo, dir string, opts ...BridgeOption) error { opt(injector) } - return biz.RegisterAll(injector) + biz.RegisterAll(injector) + biz.InvokeAll(injector) + return nil } diff --git a/backend/build/nginx.conf b/backend/build/nginx.conf index 28476b4b..c2964ee8 100644 --- a/backend/build/nginx.conf +++ b/backend/build/nginx.conf @@ -5,29 +5,34 @@ events { http { include /etc/nginx/mime.types; default_type application/octet-stream; - + # 日志格式 log_format main '$remote_addr - $remote_user [$time_local] "$request" ' '$status $body_bytes_sent "$http_referer" ' '"$http_user_agent" "$http_x_forwarded_for"'; - + # 基本设置 sendfile on; tcp_nopush on; tcp_nodelay on; keepalive_timeout 65; types_hash_max_size 2048; - + # Gzip 压缩 gzip on; gzip_vary on; gzip_min_length 1024; gzip_types text/plain text/css text/xml text/javascript application/javascript application/xml+rss application/json; + upstream rustfs { + least_conn; + server monkeycode-ai-rustfs:9000; # S3 API 服务端口 + } + server { listen 80; server_name _; - + # 处理前端路由(SPA) location / { proxy_pass http://monkeycode-ai-frontend; @@ -36,10 +41,11 @@ http { location /api/ { client_max_body_size 10m; proxy_pass http://monkeycode-ai-backend:8888; - proxy_set_header Host $host; + proxy_set_header Host $http_host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Host $http_host; # 支持 WebSocket proxy_http_version 1.1; @@ -47,16 +53,62 @@ http { proxy_set_header Connection "upgrade"; } + location /v1/ { + client_max_body_size 20m; + proxy_pass http://monkeycode-ai-backend:8888; + proxy_set_header Host $http_host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Host $http_host; + proxy_http_version 1.1; + proxy_set_header Connection ""; + proxy_buffering off; + proxy_cache off; + proxy_read_timeout 24h; + proxy_connect_timeout 24h; + proxy_send_timeout 24h; + } + + location /static/ { + client_max_body_size 1024m; + proxy_pass http://monkeycode-ai-backend:8888; + proxy_set_header Host $http_host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Host $http_host; + } + location /api/v1/users/files/upload { client_max_body_size 10m; proxy_pass http://monkeycode-ai-backend:8888; - proxy_set_header Host $host; + proxy_set_header Host $http_host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Host $http_host; proxy_read_timeout 10m; proxy_send_timeout 60s; } + + location /oss/ { + client_max_body_size 100m; + proxy_set_header Host $http_host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Host $http_host; + + proxy_cache_convert_head off; + proxy_buffering off; + proxy_request_buffering off; + proxy_connect_timeout 300; + proxy_http_version 1.1; + proxy_set_header Connection ""; + + proxy_pass http://rustfs/; + } } upstream grpcservers { @@ -65,10 +117,12 @@ http { } server { - listen 50051; + listen 50443 ssl; server_name _; http2 on; + ssl_certificate /etc/tls/server.crt; + ssl_certificate_key /etc/tls/server.key; underscores_in_headers on; location / { @@ -97,35 +151,4 @@ http { grpc_set_header X-Real-IP $remote_addr; } } - - upstream rustfs { - least_conn; - server monkeycode-ai-rustfs:9000; # S3 API 服务端口 - } - - server { - listen 8000; - http2 on; - server_name _; # 替换为你的 S3 API 域名 - - add_header Strict-Transport-Security "max-age=31536000"; - - # 反向代理 RustFS S3 API - location / { - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - proxy_set_header X-Forwarded-Port $server_port; - proxy_set_header X-Forwarded-Host $host; - - # 关键配置:禁用 HEAD 请求转换,避免 S3 V4 签名失效 - proxy_cache_convert_head off; - proxy_connect_timeout 300; - proxy_http_version 1.1; - proxy_set_header Connection ""; - - proxy_pass http://rustfs; # 代理到 S3 API - } - } } diff --git a/backend/cmd/installer/main.go b/backend/cmd/installer/main.go new file mode 100644 index 00000000..6d118588 --- /dev/null +++ b/backend/cmd/installer/main.go @@ -0,0 +1,115 @@ +package main + +import ( + "fmt" + "os" + + "github.com/chaitin/MonkeyCode/backend/pkg/installer/app" + "github.com/chaitin/MonkeyCode/backend/pkg/installer/logging" + "github.com/chaitin/MonkeyCode/backend/pkg/installer/steps" +) + +type mode string + +const ( + modeHost mode = "host" + modeCenter mode = "center" +) + +func parseMode(args []string) (mode, error) { + if len(args) <= 1 { + return modeHost, nil + } + switch mode(args[1]) { + case modeHost: + return modeHost, nil + case modeCenter: + return modeCenter, nil + default: + return "", fmt.Errorf("未知模式 %q(支持 host / center)", args[1]) + } +} + +func main() { + m, err := parseMode(os.Args) + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + + logger, err := logging.New() + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + defer logger.Close() + + a := buildApp(m, logger) + os.Exit(a.Run()) +} + +const centerBanner = ` + █ █ + █▒ ▒█ █ ░███▒ █ ██ █████ + ██ ██ █ ░█▒ ░█ █ ██ █ + ██░░██ ███ █▒██▒ █ ▒█ ███ █░ █ █▒ ███ ██▓█ ███ ▒██▒ █ + █▒▓▓▒█ █▓ ▓█ █▓ ▒█ █ ▒█ ▓▓ ▒█ ▓▒ ▒▓ █ █▓ ▓█ █▓ ▓█ ▓▓ ▒█ ▓▒▒▓ █ + █ ██ █ █ █ █ █ █▒█ █ █ ▒█ █▒ █ █ █ █ █ █ █ █░░█ █ + █ █▓ █ █ █ █ █ ██▓ █████ █ █ █ █ █ █ █ █████ ███ █ █ █ + █ █ █ █ █ █ █░█░ █ █▓▓ █▒ █ █ █ █ █ ▒████▒ █ + █ █ █▓ ▓█ █ █ █ ░█ ▓▓ █ ▓█▒ ░█▒ ░▓ █▓ ▓█ █▓ ▓█ ▓▓ █ ▓▒ ▒▓ █ + █ █ ███ █ █ █ ▒█ ███▒ ▒█ ▒███▒ ███ ██▓█ ███▒ █░ ░█ █████ + ▒█ + █▒ + ██ +` + +const hostBanner = ` + █ █ + █▒ ▒█ █ ░███▒ █ █████ + ██ ██ █ ░█▒ ░█ █ █ ▓█ + ██░░██ ███ █▒██▒ █ ▒█ ███ █░ █ █▒ ███ ██▓█ ███ █ █ █ █ █▒██▒ █▒██▒ ███ █▒██▒ + █▒▓▓▒█ █▓ ▓█ █▓ ▒█ █ ▒█ ▓▓ ▒█ ▓▒ ▒▓ █ █▓ ▓█ █▓ ▓█ ▓▓ ▒█ █ ▒█ █ █ █▓ ▒█ █▓ ▒█ ▓▓ ▒█ ██ █ + █ ██ █ █ █ █ █ █▒█ █ █ ▒█ █▒ █ █ █ █ █ █ █ █████ █ █ █ █ █ █ █ █ █ + █ █▓ █ █ █ █ █ ██▓ █████ █ █ █ █ █ █ █ █████ █ ░█▒ █ █ █ █ █ █ █████ █ + █ █ █ █ █ █ █░█░ █ █▓▓ █▒ █ █ █ █ █ █ ░█ █ █ █ █ █ █ █ █ + █ █ █▓ ▓█ █ █ █ ░█ ▓▓ █ ▓█▒ ░█▒ ░▓ █▓ ▓█ █▓ ▓█ ▓▓ █ █ █ █▒ ▓█ █ █ █ █ ▓▓ █ █ + █ █ ███ █ █ █ ▒█ ███▒ ▒█ ▒███▒ ███ ██▓█ ███▒ █ ▒ ▒██▒█ █ █ █ █ ███▒ █ + ▒█ + █▒ + ██ +` + +func buildApp(m mode, logger *logging.Logger) *app.App { + switch m { + case modeCenter: + return &app.App{ + Title: "MonkeyCode AI Installer", + Banner: centerBanner, + Logger: logger, + Actions: []app.Action{ + {Label: "安装", Value: "install", Steps: []steps.Step{ + &steps.CheckDocker{}, + &steps.InstallDocker{}, + &steps.ServiceForm{}, + &steps.InstallService{}, + }}, + }, + } + default: + return &app.App{ + Title: "MonkeyCode Runner Installer", + Banner: hostBanner, + Logger: logger, + Actions: []app.Action{ + {Label: "安装", Value: "install", Steps: []steps.Step{ + &steps.CheckDocker{}, + &steps.HostInstall{}, + }}, + {Label: "卸载", Value: "uninstall", Steps: []steps.Step{ + &steps.HostUninstall{}, + }}, + }, + } + } +} diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index a8131621..e06b0c80 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -47,10 +47,10 @@ func main() { } // 注册业务模块 - if err := biz.RegisterAll(injector); err != nil { - l.Error("failed to register biz", "error", err) - os.Exit(1) - } + biz.RegisterAll(injector) + biz.RegisterOpenSource(injector) + biz.InvokeAll(injector) + biz.InvokeOpenSource(injector) // 获取 web 实例并启动服务 w := do.MustInvoke[*web.Web](injector) diff --git a/backend/config/config.go b/backend/config/config.go index 88c1ec1e..26c59afe 100644 --- a/backend/config/config.go +++ b/backend/config/config.go @@ -43,17 +43,20 @@ type Config struct { AdminToken string `mapstructure:"admin_token"` Proxies []string `mapstructure:"proxies"` - TaskFlow TaskFlow `mapstructure:"taskflow"` - MCPHub MCPHub `mapstructure:"mcp_hub"` - PublicHost PublicHost `mapstructure:"public_host"` - Task Task `mapstructure:"task"` - TaskSummary TaskSummary `mapstructure:"task_summary"` - Loki Loki `mapstructure:"loki"` - ClickHouse ClickHouse `mapstructure:"clickhouse"` - LLM LLM `mapstructure:"llm"` - Notify Notify `mapstructure:"notify"` - VMIdle VMIdle `mapstructure:"vm_idle"` - Attachment Attachment `mapstructure:"attachment"` + TaskFlow TaskFlow `mapstructure:"taskflow"` + MCPHub MCPHub `mapstructure:"mcp_hub"` + PublicHost PublicHost `mapstructure:"public_host"` + Task Task `mapstructure:"task"` + TaskSummary TaskSummary `mapstructure:"task_summary"` + Loki Loki `mapstructure:"loki"` + ClickHouse ClickHouse `mapstructure:"clickhouse"` + LLM LLM `mapstructure:"llm"` + Notify Notify `mapstructure:"notify"` + VMIdle VMIdle `mapstructure:"vm_idle"` + Attachment Attachment `mapstructure:"attachment"` + ObjectStorage ObjectStorageConfig `mapstructure:"object_storage"` + StaticFiles StaticFilesConfig `mapstructure:"static_files"` + HostInstaller HostInstaller `mapstructure:"host_installer"` // Context7 API 配置 Context7ApiKey string `mapstructure:"context7_api_key"` @@ -81,6 +84,7 @@ type InitTeam struct { Email string `mapstructure:"email"` Password string `mapstructure:"password"` Name string `mapstructure:"name"` + Image string `mapstructure:"image"` } type TaskFlow struct { @@ -105,6 +109,36 @@ type Attachment struct { AllowedURLPrefixes []string `mapstructure:"allowed_url_prefixes"` } +type ObjectStorageConfig struct { + Enabled bool `mapstructure:"enabled"` + Provider string `mapstructure:"provider"` + ForcePathStyle bool `mapstructure:"force_path_style"` + InitBucket bool `mapstructure:"init_bucket"` + PresignExpires string `mapstructure:"presign_expires"` + Endpoint string `mapstructure:"endpoint"` + AccessEndpoint string `mapstructure:"access_endpoint"` + AccessKey string `mapstructure:"access_key"` + AccessKeySecret string `mapstructure:"access_key_secret"` + Bucket string `mapstructure:"bucket"` + Region string `mapstructure:"region"` + MaxSize int64 `mapstructure:"max_size"` + AvatarPrefix string `mapstructure:"avatar_prefix"` + SpecPrefix string `mapstructure:"spec_prefix"` + RepoPrefix string `mapstructure:"repo_prefix"` + TempPrefix string `mapstructure:"temp_prefix"` +} + +type StaticFilesConfig struct { + Enabled bool `mapstructure:"enabled"` + Dir string `mapstructure:"dir"` + RoutePrefix string `mapstructure:"route_prefix"` +} + +type HostInstaller struct { + Mode string `mapstructure:"mode"` + BundlePath string `mapstructure:"bundle_path"` +} + // Task 任务相关配置 type Task struct { LogLimit int `mapstructure:"log_limit"` // Loki tail 日志 limit @@ -193,7 +227,7 @@ func Init(dir string) (*Config, error) { v.SetDefault("debug", false) v.SetDefault("server.addr", ":8888") - v.SetDefault("server.base_url", "http://localhost:8888") + v.SetDefault("server.base_url", "") v.SetDefault("loki.addr", "http://monkeycode-ai-loki:3100") v.SetDefault("clickhouse.addr", "") v.SetDefault("clickhouse.database", "") @@ -229,6 +263,7 @@ func Init(dir string) (*Config, error) { v.SetDefault("init_team.email", "") v.SetDefault("init_team.name", "") v.SetDefault("init_team.password", "") + v.SetDefault("init_team.image", "") v.SetDefault("taskflow.grpc_url", "") v.SetDefault("task.at_keyword", "") v.SetDefault("task.host_ids", []string{}) @@ -236,6 +271,28 @@ func Init(dir string) (*Config, error) { v.SetDefault("mcp_hub.url", "") v.SetDefault("mcp_hub.token", "") v.SetDefault("attachment.allowed_url_prefixes", []string{}) + v.SetDefault("object_storage.enabled", false) + v.SetDefault("object_storage.provider", "s3") + v.SetDefault("object_storage.force_path_style", true) + v.SetDefault("object_storage.init_bucket", false) + v.SetDefault("object_storage.presign_expires", "168h") + v.SetDefault("object_storage.endpoint", "http://monkeycode-ai-rustfs:9000") + v.SetDefault("object_storage.access_endpoint", "") + v.SetDefault("object_storage.access_key", "") + v.SetDefault("object_storage.access_key_secret", "") + v.SetDefault("object_storage.bucket", "monkeycode-ai") + v.SetDefault("object_storage.region", "us-east-1") + v.SetDefault("object_storage.max_size", 50<<20) + v.SetDefault("object_storage.avatar_prefix", "avatar") + v.SetDefault("object_storage.spec_prefix", "spec") + v.SetDefault("object_storage.repo_prefix", "repo") + v.SetDefault("object_storage.temp_prefix", "temp") + v.SetDefault("static_files.enabled", true) + v.SetDefault("static_files.dir", "/app/static") + v.SetDefault("static_files.route_prefix", "/static") + v.SetDefault("host_installer.mode", "online") + v.SetDefault("host_installer.bundle_path", "installer/{{.arch}}/host.tgz") + v.SetDefault("llm_proxy.base_url", "") v.SetConfigType("yaml") v.AddConfigPath(dir) diff --git a/backend/config/oss_config_test.go b/backend/config/oss_config_test.go new file mode 100644 index 00000000..65df99f3 --- /dev/null +++ b/backend/config/oss_config_test.go @@ -0,0 +1,60 @@ +package config + +import "testing" + +func TestObjectStorageDefaults(t *testing.T) { + t.Setenv("MCAI_OBJECT_STORAGE_ENABLED", "") + t.Setenv("MCAI_OBJECT_STORAGE_PROVIDER", "") + t.Setenv("MCAI_OBJECT_STORAGE_FORCE_PATH_STYLE", "") + t.Setenv("MCAI_OBJECT_STORAGE_PRESIGN_EXPIRES", "") + t.Setenv("MCAI_OBJECT_STORAGE_MAX_SIZE", "") + t.Setenv("MCAI_OBJECT_STORAGE_TEMP_PREFIX", "") + t.Setenv("MCAI_TASKFLOW_GRPC_URL", "") + + cfg, err := Init(t.TempDir()) + if err != nil { + t.Fatal(err) + } + if cfg.ObjectStorage.Enabled { + t.Fatal("object_storage.enabled default = true, want false") + } + if cfg.Server.BaseURL != "" { + t.Fatalf("server.base_url = %q, want empty", cfg.Server.BaseURL) + } + if cfg.ObjectStorage.Provider != "s3" { + t.Fatalf("provider = %q, want s3", cfg.ObjectStorage.Provider) + } + if !cfg.ObjectStorage.ForcePathStyle { + t.Fatal("force_path_style default = false, want true") + } + if cfg.ObjectStorage.PresignExpires != "168h" { + t.Fatalf("presign_expires = %q, want 168h", cfg.ObjectStorage.PresignExpires) + } + if cfg.ObjectStorage.AccessEndpoint != "" { + t.Fatalf("access_endpoint = %q, want empty", cfg.ObjectStorage.AccessEndpoint) + } + if cfg.ObjectStorage.MaxSize != 50<<20 { + t.Fatalf("max_size = %d, want %d", cfg.ObjectStorage.MaxSize, 50<<20) + } + if cfg.ObjectStorage.TempPrefix != "temp" { + t.Fatalf("temp_prefix = %q", cfg.ObjectStorage.TempPrefix) + } + if cfg.TaskFlow.GrpcURL != "" { + t.Fatalf("taskflow.grpc_url = %q, want empty", cfg.TaskFlow.GrpcURL) + } + if !cfg.StaticFiles.Enabled { + t.Fatal("static_files.enabled default = false, want true") + } + if cfg.StaticFiles.Dir != "/app/static" { + t.Fatalf("static_files.dir = %q", cfg.StaticFiles.Dir) + } + if cfg.StaticFiles.RoutePrefix != "/static" { + t.Fatalf("static_files.route_prefix = %q", cfg.StaticFiles.RoutePrefix) + } + if cfg.HostInstaller.Mode != "online" { + t.Fatalf("host_installer.mode = %q", cfg.HostInstaller.Mode) + } + if cfg.HostInstaller.BundlePath != "installer/{{.arch}}/host.tgz" { + t.Fatalf("host_installer.bundle_path = %q", cfg.HostInstaller.BundlePath) + } +} diff --git a/backend/config/server/config.yaml.example b/backend/config/server/config.yaml.example index b92d3a39..d448f6d8 100644 --- a/backend/config/server/config.yaml.example +++ b/backend/config/server/config.yaml.example @@ -1,6 +1,7 @@ debug: true server: addr: ":8888" + # 必须手动配置为外部可访问地址,用于安装脚本、回调、对象存储公开 URL 等场景。 base_url: "http://localhost:8888" database: @@ -34,6 +35,12 @@ admin_token: "change-me" logger: level: "info" +init_team: + email: "" + password: "" + name: "" + image: "" + mcp_hub: enabled: false url: "http://mcp-hub:8897/mcp" @@ -55,3 +62,32 @@ clickhouse: max_open_conns: 64 max_idle_conns: 32 conn_max_lifetime: 3600 + +object_storage: + enabled: false + provider: "s3" + force_path_style: true + init_bucket: false + presign_expires: "168h" + + endpoint: "" + # 外部访问入口,例如 http://example.com/oss。留空时回退到 endpoint。 + access_endpoint: "" + access_key: "" + access_key_secret: "" + bucket: "" + region: "us-east-1" + max_size: 52428800 + avatar_prefix: "avatar" + spec_prefix: "spec" + repo_prefix: "repo" + temp_prefix: "temp" + +static_files: + enabled: true + dir: "/app/static" + route_prefix: "/static" + +host_installer: + mode: "online" + bundle_path: "installer/{{.arch}}/host.tgz" diff --git a/backend/consts/uploader.go b/backend/consts/uploader.go new file mode 100644 index 00000000..106aac4b --- /dev/null +++ b/backend/consts/uploader.go @@ -0,0 +1,9 @@ +package consts + +type UploadUsage string + +const ( + UploadUsageAvatar UploadUsage = "avatar" + UploadUsageSpec UploadUsage = "spec" + UploadUsageRepo UploadUsage = "repo" +) diff --git a/backend/docker-compose.yml b/backend/docker-compose.yml index e8b4db2c..cb894895 100644 --- a/backend/docker-compose.yml +++ b/backend/docker-compose.yml @@ -1,21 +1,188 @@ -version: '3.8' - +name: monkeycode-ai services: - postgres: - image: postgres:16-alpine + db: + image: ${POSTGRES_IMAGE} + container_name: monkeycode-ai-db + restart: always + init: true environment: - POSTGRES_USER: monkeycode - POSTGRES_PASSWORD: monkeycode - POSTGRES_DB: monkeycode - ports: - - "5432:5432" + POSTGRES_DB: ${POSTGRES_DB} + POSTGRES_USER: ${POSTGRES_USER} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} volumes: - - pgdata:/var/lib/postgresql/data + - ${INSTALL_DIR}/data/postgres:/var/lib/postgresql/data + networks: + monkeycode: + ipv4_address: "${SUBNET_PREFIX:-10.100.50}.11" + healthcheck: + test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER"] + interval: 5s + timeout: 5s + retries: 5 redis: - image: redis:7-alpine + image: ${REDIS_IMAGE} + container_name: monkeycode-ai-redis + restart: always + command: ["redis-server", "--requirepass", "${REDIS_PASSWORD}", "--appendonly", "yes", "--appendfilename", "appendonly.aof", "--save", "900 1", "--save", "300 10", "--save", "60 10000"] + volumes: + - ${INSTALL_DIR}/data/redis:/data + networks: + monkeycode: + ipv4_address: "${SUBNET_PREFIX:-10.100.50}.12" + + clickhouse: + image: ${CLICKHOUSE_IMAGE} + container_name: monkeycode-ai-clickhouse + restart: always + environment: + CLICKHOUSE_DB: ${CLICKHOUSE_DB} + CLICKHOUSE_USER: ${CLICKHOUSE_USER} + CLICKHOUSE_PASSWORD: ${CLICKHOUSE_PASSWORD} + CLICKHOUSE_DEFAULT_ACCESS_MANAGEMENT: '1' + volumes: + - ${INSTALL_DIR}/data/clickhouse:/var/lib/clickhouse + - ${INSTALL_DIR}/logs/clickhouse:/var/log/clickhouse-server + ulimits: + nofile: + soft: 262144 + hard: 262144 + cap_add: + - SYS_NICE + - IPC_LOCK + healthcheck: + test: ["CMD-SHELL", "clickhouse-client --query 'SELECT 1' >/dev/null 2>&1 || exit 1"] + interval: 30s + timeout: 5s + retries: 5 + start_period: 20s + stop_grace_period: 2m + networks: + monkeycode: + ipv4_address: "${SUBNET_PREFIX:-10.100.50}.13" + + rustfs: + image: ${RUSTFS_IMAGE} + container_name: monkeycode-ai-rustfs + security_opt: + - "no-new-privileges:true" + environment: + # API 和控制台监听地址 + - RUSTFS_ADDRESS=0.0.0.0:9000 + - RUSTFS_CONSOLE_ADDRESS=0.0.0.0:9001 + - RUSTFS_CONSOLE_ENABLE=true + # CORS 设置,控制台与 S3 API 都放开来源 + - RUSTFS_CORS_ALLOWED_ORIGINS=* + - RUSTFS_CONSOLE_CORS_ALLOWED_ORIGINS=* + - RUSTFS_ACCESS_KEY=${RUSTFS_ACCESS_KEY} + - RUSTFS_SECRET_KEY=${RUSTFS_SECRET_KEY} + # 日志级别 + - RUSTFS_OBS_LOGGER_LEVEL=info + volumes: + - ${INSTALL_DIR}/data/rustfs:/data + - ${INSTALL_DIR}/logs/rustfs:/app/logs + networks: + monkeycode: + ipv4_address: "${SUBNET_PREFIX:-10.100.50}.14" + restart: always + healthcheck: + test: ["CMD", "sh", "-c", "curl -f http://localhost:9000/health && curl -f http://localhost:9001/health"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 40s + + ingress: + image: ${INGRESS_IMAGE} + container_name: monkeycode-ai-ingress + restart: always ports: - - "6379:6379" + - ${NGINX_PORT:-80}:80 + - 50443:50443 + volumes: + - ${INSTALL_DIR}/tls:/etc/tls + networks: + monkeycode: + ipv4_address: "${SUBNET_PREFIX:-10.100.50}.15" + + taskflow: + image: ${TASKFLOW_IMAGE} + container_name: monkeycode-ai-taskflow + restart: always + environment: + MCAI_DATABASE_MASTER: postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@monkeycode-ai-db:5432/${POSTGRES_DB}?sslmode=disable&timezone=Asia/Shanghai + MCAI_REDIS_HOST: monkeycode-ai-redis + MCAI_REDIS_PASS: ${REDIS_PASSWORD} + MCAI_CLICKHOUSE_ADDR: monkeycode-ai-clickhouse:9000 + MCAI_CLICKHOUSE_DATABASE: ${CLICKHOUSE_DB} + MCAI_CLICKHOUSE_USERNAME: ${CLICKHOUSE_USER} + MCAI_CLICKHOUSE_PASSWORD: ${CLICKHOUSE_PASSWORD} + MCAI_TEMPLATE_PROJECT_ZIP_URL: http://${REMOTE_IP}:${NGINX_PORT:-80}/static/project-tpl.zip + MCAI_JWT_SECRET: ${RELAY_SECRET} + MCAI_JWT_RELAY_HOST: ${REMOTE_IP} + MCAI_JWT_RELAY_PORT: 7000 + MCAI_JWT_RELAY_USE_TLS: false + networks: + monkeycode: + ipv4_address: "${SUBNET_PREFIX:-10.100.50}.16" + + frontend: + image: ${FRONTEND_IMAGE} + container_name: monkeycode-ai-frontend + restart: always + networks: + monkeycode: + ipv4_address: "${SUBNET_PREFIX:-10.100.50}.17" + + backend: + image: ${BACKEND_IMAGE} + container_name: monkeycode-ai-backend + restart: always + environment: + MCAI_LOGGER_LEVEL: debug + TASKFLOW_SERVER: http://monkeycode-ai-taskflow:8888 + MCAI_SERVER_BASE_URL: http://${REMOTE_IP}:${NGINX_PORT:-80} + MCAI_LLM_PROXY_BASE_URL: http://${REMOTE_IP}:${NGINX_PORT:-80} + MCAI_HOST_INSTALLER_MODE: offline + MCAI_DATABASE_MASTER: postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@monkeycode-ai-db:5432/${POSTGRES_DB}?sslmode=disable&timezone=Asia/Shanghai + MCAI_REDIS_HOST: monkeycode-ai-redis + MCAI_REDIS_PASS: ${REDIS_PASSWORD} + MCAI_CLICKHOUSE_INIT_ENABLED: true + MCAI_CLICKHOUSE_ADDR: monkeycode-ai-clickhouse:9000 + MCAI_CLICKHOUSE_DATABASE: ${CLICKHOUSE_DB} + MCAI_CLICKHOUSE_USERNAME: ${CLICKHOUSE_USER} + MCAI_CLICKHOUSE_PASSWORD: ${CLICKHOUSE_PASSWORD} + MCAI_INIT_TEAM_EMAIL: ${TEAM_EMAIL} + MCAI_INIT_TEAM_NAME: ${TEAM_NAME} + MCAI_INIT_TEAM_PASSWORD: ${TEAM_PASSWORD} + MCAI_INIT_TEAM_IMAGE: ${INIT_TEAM_IMAGE:-} + MCAI_OBJECT_STORAGE_ENABLED: true + MCAI_OBJECT_STORAGE_INIT_BUCKET: true + MCAI_OBJECT_STORAGE_ACCESS_KEY: ${RUSTFS_ACCESS_KEY} + MCAI_OBJECT_STORAGE_ACCESS_KEY_SECRET: ${RUSTFS_SECRET_KEY} + MCAI_TASK_LLM_PROXY_ENABLED: false + MCAI_OBJECT_STORAGE_ACCESS_ENDPOINT: http://${REMOTE_IP}:${NGINX_PORT:-80}/oss + MCAI_TASKFLOW_GRPC_URL: ${REMOTE_IP}:50443 + volumes: + - ${INSTALL_DIR}/static:/app/static + networks: + monkeycode: + ipv4_address: "${SUBNET_PREFIX:-10.100.50}.18" + + preview: + image: ${PREVIEW_IMAGE} + container_name: monkeycode-ai-preview + restart: always + network_mode: host + environment: + RELAY_DOMAIN: ${REMOTE_IP} + RELAY_TUNNEL_PORT_RANGES: 30000-50000 + RELAY_JWT_SECRET: ${RELAY_SECRET} + RELAY_LISTEN_HTTP: ":9080" -volumes: - pgdata: +networks: + monkeycode: + ipam: + driver: default + config: + - subnet: "${SUBNET_PREFIX:-10.100.50}.0/24" diff --git a/backend/domain/team.go b/backend/domain/team.go index e1acac6b..1523d1ab 100644 --- a/backend/domain/team.go +++ b/backend/domain/team.go @@ -18,6 +18,7 @@ type TeamGroupUserUsecase interface { List(ctx context.Context, teamUser *TeamUser) (*ListTeamGroupsResp, error) Add(ctx context.Context, teamUser *TeamUser, req *AddTeamGroupReq) (*TeamGroup, error) AddUser(ctx context.Context, teamUser *TeamUser, req *AddTeamUserReq) (*AddTeamUserResp, error) + AddUserWithPassword(ctx context.Context, teamUser *TeamUser, req *AddTeamUserReq) (*AddTeamUserWithPasswordResp, error) AddAdmin(ctx context.Context, teamUser *TeamUser, req *AddTeamAdminReq) (*AddTeamAdminResp, error) Update(ctx context.Context, req *UpdateTeamGroupReq) (*TeamGroup, error) Delete(ctx context.Context, teamUser *TeamUser, req *DeleteTeamGroupReq) error @@ -36,6 +37,7 @@ type TeamGroupUserRepo interface { Get(ctx context.Context, groupID uuid.UUID) (*db.TeamGroup, error) Create(ctx context.Context, teamID uuid.UUID, req *AddTeamGroupReq) (*db.TeamGroup, error) CreateUsers(ctx context.Context, teamID uuid.UUID, req *AddTeamUserReq) ([]*db.User, error) + CreateUsersWithPassword(ctx context.Context, teamID uuid.UUID, req *AddTeamUserWithPasswordReq) ([]*db.User, error) CreateAdmin(ctx context.Context, teamID uuid.UUID, req *AddTeamAdminReq) (*db.User, error) Update(ctx context.Context, req *UpdateTeamGroupReq) (*db.TeamGroup, error) Delete(ctx context.Context, teamID, groupID uuid.UUID) error @@ -49,7 +51,7 @@ type TeamGroupUserRepo interface { UpdateUser(ctx context.Context, userID uuid.UUID, req *UpdateTeamUserReq) (*db.User, error) GetMembersByIDs(ctx context.Context, teamID uuid.UUID, userIDs []uuid.UUID) ([]*db.TeamMember, error) GetMember(ctx context.Context, teamID, userID uuid.UUID) (*db.TeamMember, error) - InitTeam(ctx context.Context, email, name, password string) error + InitTeam(ctx context.Context, email, name, password, image string) error } type Team struct { @@ -254,11 +256,27 @@ type AddTeamUserReq struct { GroupID uuid.UUID `json:"group_id" validate:"omitempty"` // 团队组ID } +type AddTeamUserWithPasswordReq struct { + Emails []string `json:"emails" validate:"required"` + GroupID uuid.UUID `json:"group_id" validate:"omitempty"` + Passwords map[string]string `json:"-" swaggerignore:"true"` +} + // AddTeamUserResp 创建团队成员响应 type AddTeamUserResp struct { Users []*TeamUser `json:"users"` } +type TeamUserPassword struct { + Email string `json:"email"` + Password string `json:"password"` +} + +type AddTeamUserWithPasswordResp struct { + Users []*TeamUser `json:"users"` + Passwords []*TeamUserPassword `json:"passwords"` +} + // AddTeamAdminReq 创建团队管理员请求 type AddTeamAdminReq struct { Email string `json:"email" validate:"required,email"` // 邮箱 diff --git a/backend/domain/uploader.go b/backend/domain/uploader.go new file mode 100644 index 00000000..641e4f62 --- /dev/null +++ b/backend/domain/uploader.go @@ -0,0 +1,21 @@ +package domain + +import ( + "mime/multipart" + + "github.com/chaitin/MonkeyCode/backend/consts" +) + +type UploadReq struct { + Usage consts.UploadUsage `json:"usage" form:"usage" validate:"required,oneof=avatar spec repo"` + File *multipart.FileHeader `json:"file" form:"file"` +} + +type PresignReq struct { + Filename string `json:"filename" form:"filename" validate:"required"` +} + +type PresignResp struct { + UploadURL string `json:"upload_url"` + AccessURL string `json:"access_url"` +} diff --git a/backend/domain/user.go b/backend/domain/user.go index 15df2e52..67d87b61 100644 --- a/backend/domain/user.go +++ b/backend/domain/user.go @@ -56,6 +56,13 @@ type User struct { HasPassword bool `json:"has_password"` } +type SubscriptionResp struct { + Plan string `json:"plan"` + Source string `json:"source,omitempty"` + ExpiresAt *time.Time `json:"expires_at,omitempty"` + AutoRenew bool `json:"auto_renew"` +} + func (u *User) From(src *db.User) *User { if src == nil { return u diff --git a/backend/errcode/errcode.go b/backend/errcode/errcode.go index a6e63c57..ea4a2a25 100644 --- a/backend/errcode/errcode.go +++ b/backend/errcode/errcode.go @@ -43,7 +43,7 @@ var ( ErrVMIDRequired = web.NewErr(http.StatusOK, 10200, "err-vm-id-required") ErrVMNotBelongToUser = web.NewErr(http.StatusOK, 10201, "err-vm-not-belong-to-user") ErrPermisionDenied = web.NewErr(http.StatusOK, 10202, "err-permision-denied") - ErrInvalidInstallToken = web.NewErr(http.StatusOK, 10203, "err-host-id-required") + ErrInvalidInstallToken = web.NewErr(http.StatusOK, 10203, "err-invalid-token") ErrPublicHostNotFound = web.NewErr(http.StatusOK, 10204, "err-public-host-not-found") ErrHostOffline = web.NewErr(http.StatusOK, 10205, "err-host-offline") ErrVMExpired = web.NewErr(http.StatusOK, 10206, "err-vm-expired") diff --git a/backend/go.mod b/backend/go.mod index 843b6de9..12b6822c 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -11,6 +11,13 @@ require ( github.com/alicebob/miniredis/v2 v2.35.0 github.com/aliyun/alibabacloud-nls-go-sdk v1.1.1 github.com/anthropics/anthropic-sdk-go v1.40.0 + github.com/aws/aws-sdk-go-v2 v1.41.7 + github.com/aws/aws-sdk-go-v2/config v1.32.17 + github.com/aws/aws-sdk-go-v2/credentials v1.19.16 + github.com/aws/aws-sdk-go-v2/service/s3 v1.101.0 + github.com/charmbracelet/bubbles v1.0.0 + github.com/charmbracelet/bubbletea v1.3.10 + github.com/charmbracelet/lipgloss v1.1.0 github.com/coder/websocket v1.8.14 github.com/gogo/protobuf v1.3.2 github.com/golang-migrate/migrate/v4 v4.19.0 @@ -40,12 +47,36 @@ require ( github.com/aliyun/alibaba-cloud-sdk-go v1.61.1376 // indirect github.com/andybalholm/brotli v1.2.0 // indirect github.com/apparentlymart/go-textseg/v15 v15.0.0 // indirect + github.com/atotto/clipboard v0.1.4 // indirect + github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.10 // indirect + github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.23 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.23 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.23 // indirect + github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.24 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.9 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.15 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.23 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.23 // indirect + github.com/aws/aws-sdk-go-v2/service/signin v1.0.11 // indirect + github.com/aws/aws-sdk-go-v2/service/sso v1.30.17 // indirect + github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.21 // indirect + github.com/aws/aws-sdk-go-v2/service/sts v1.42.1 // indirect + github.com/aws/smithy-go v1.25.1 // indirect + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/bahlo/generic-list-go v0.2.0 // indirect github.com/bmatcuk/doublestar v1.3.4 // indirect github.com/bradleyfalzon/ghinstallation/v2 v2.17.0 // indirect github.com/buger/jsonparser v1.1.2 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/charmbracelet/colorprofile v0.4.1 // indirect + github.com/charmbracelet/x/ansi v0.11.6 // indirect + github.com/charmbracelet/x/cellbuf v0.0.15 // indirect + github.com/charmbracelet/x/term v0.2.2 // indirect + github.com/clipperhouse/displaywidth v0.9.0 // indirect + github.com/clipperhouse/stringish v0.1.1 // indirect + github.com/clipperhouse/uax29/v2 v2.5.0 // indirect github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/gabriel-vasile/mimetype v1.4.13 // indirect github.com/go-faster/city v1.0.1 // indirect @@ -75,18 +106,25 @@ require ( github.com/klauspost/compress v1.18.3 // indirect github.com/labstack/gommon v0.4.2 // indirect github.com/leodido/go-urn v1.4.0 // indirect + github.com/lucasb-eyer/go-colorful v1.3.0 // indirect github.com/mailru/easyjson v0.7.7 // indirect github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-localereader v0.0.1 // indirect + github.com/mattn/go-runewidth v0.0.19 // indirect github.com/mitchellh/go-wordwrap v1.0.1 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect + github.com/muesli/termenv v0.16.0 // indirect github.com/nicksnyder/go-i18n/v2 v2.6.1 // indirect github.com/paulmach/orb v0.12.0 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect github.com/pierrec/lz4/v4 v4.1.25 // indirect github.com/pkg/errors v0.9.1 // indirect github.com/rcrowley/go-metrics v0.0.0-20250401214520-65e299d6c5c9 // indirect + github.com/rivo/uniseg v0.4.7 // indirect github.com/rs/zerolog v1.34.0 // indirect github.com/sagikazarmark/locafero v0.11.0 // indirect github.com/satori/go.uuid v1.2.0 // indirect @@ -106,6 +144,7 @@ require ( github.com/valyala/bytebufferpool v1.0.0 // indirect github.com/valyala/fasttemplate v1.2.2 // indirect github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect github.com/yuin/gopher-lua v1.1.1 // indirect github.com/zclconf/go-cty v1.14.4 // indirect github.com/zclconf/go-cty-yaml v1.1.0 // indirect diff --git a/backend/go.sum b/backend/go.sum index b02956bd..1b65431f 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -32,6 +32,46 @@ github.com/anthropics/anthropic-sdk-go v1.40.0 h1:+lhHU2LdeRlVsazVXHswFMpWr2Q11S github.com/anthropics/anthropic-sdk-go v1.40.0/go.mod h1:d288C1L+m74OYuYBvc4UFtR1Q8J0gC55oYDh2t+XxdI= github.com/apparentlymart/go-textseg/v15 v15.0.0 h1:uYvfpb3DyLSCGWnctWKGj857c6ew1u1fNQOlOtuGxQY= github.com/apparentlymart/go-textseg/v15 v15.0.0/go.mod h1:K8XmNZdhEBkdlyDdvbmmsvpAG721bKi0joRfFdHIWJ4= +github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= +github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= +github.com/aws/aws-sdk-go-v2 v1.41.7 h1:DWpAJt66FmnnaRIOT/8ASTucrvuDPZASqhhLey6tLY8= +github.com/aws/aws-sdk-go-v2 v1.41.7/go.mod h1:4LAfZOPHNVNQEckOACQx60Y8pSRjIkNZQz1w92xpMJc= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.10 h1:gx1AwW1Iyk9Z9dD9F4akX5gnN3QZwUB20GGKH/I+Rho= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.10/go.mod h1:qqY157uZoqm5OXq/amuaBJyC9hgBCBQnsaWnPe905GY= +github.com/aws/aws-sdk-go-v2/config v1.32.17 h1:FpL4/758/diKwqbytU0prpuiu60fgXKUWCpDJtApclU= +github.com/aws/aws-sdk-go-v2/config v1.32.17/go.mod h1:OXqUMzgXytfoF9JaKkhrOYsyh72t9G+MJH8mMRaexOE= +github.com/aws/aws-sdk-go-v2/credentials v1.19.16 h1:r3RJBuU7X9ibt8RHbMjWE6y60QbKBiII6wSrXnapxSU= +github.com/aws/aws-sdk-go-v2/credentials v1.19.16/go.mod h1:6cx7zqDENJDbBIIWX6P8s0h6hqHC8Avbjh9Dseo27ug= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.23 h1:UuSfcORqNSz/ey3VPRS8TcVH2Ikf0/sC+Hdj400QI6U= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.23/go.mod h1:+G/OSGiOFnSOkYloKj/9M35s74LgVAdJBSD5lsFfqKg= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.23 h1:GpT/TrnBYuE5gan2cZbTtvP+JlHsutdmlV2YfEyNde0= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.23/go.mod h1:xYWD6BS9ywC5bS3sz9Xh04whO/hzK2plt2Zkyrp4JuA= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.23 h1:bpd8vxhlQi2r1hiueOw02f/duEPTMK59Q4QMAoTTtTo= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.23/go.mod h1:15DfR2nw+CRHIk0tqNyifu3G1YdAOy68RftkhMDDwYk= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.24 h1:OQqn11BtaYv1WLUowvcA30MpzIu8Ti4pcLPIIyoKZrA= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.24/go.mod h1:X5ZJyfwVrWA96GzPmUCWFQaEARPR7gCrpq2E92PJwAE= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.9 h1:FLudkZLt5ci0ozzgkVo8BJGwvqNaZbTWb3UcucAateA= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.9/go.mod h1:w7wZ/s9qK7c8g4al+UyoF1Sp/Z45UwMGcqIzLWVQHWk= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.15 h1:ieLCO1JxUWuxTZ1cRd0GAaeX7O6cIxnwk7tc1LsQhC4= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.15/go.mod h1:e3IzZvQ3kAWNykvE0Tr0RDZCMFInMvhku3qNpcIQXhM= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.23 h1:pbrxO/kuIwgEsOPLkaHu0O+m4fNgLU8B3vxQ+72jTPw= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.23/go.mod h1:/CMNUqoj46HpS3MNRDEDIwcgEnrtZlKRaHNaHxIFpNA= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.23 h1:03xatSQO4+AM1lTAbnRg5OK528EUg744nW7F73U8DKw= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.23/go.mod h1:M8l3mwgx5ToK7wot2sBBce/ojzgnPzZXUV445gTSyE8= +github.com/aws/aws-sdk-go-v2/service/s3 v1.101.0 h1:etqBTKY581iwLL/H/S2sVgk3C9lAsTJFeXWFDsDcWOU= +github.com/aws/aws-sdk-go-v2/service/s3 v1.101.0/go.mod h1:L2dcoOgS2VSgbPLvpak2NyUPsO1TBN7M45Z4H7DlRc4= +github.com/aws/aws-sdk-go-v2/service/signin v1.0.11 h1:TdJ+HdzOBhU8+iVAOGUTU63VXopcumCOF1paFulHWZc= +github.com/aws/aws-sdk-go-v2/service/signin v1.0.11/go.mod h1:R82ZRExE/nheo0N+T8zHPcLRTcH8MGsnR3BiVGX0TwI= +github.com/aws/aws-sdk-go-v2/service/sso v1.30.17 h1:7byT8HUWrgoRp6sXjxtZwgOKfhss5fW6SkLBtqzgRoE= +github.com/aws/aws-sdk-go-v2/service/sso v1.30.17/go.mod h1:xNWknVi4Ezm1vg1QsB/5EWpAJURq22uqd38U8qKvOJc= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.21 h1:+1Kl1zx6bWi4X7cKi3VYh29h8BvsCoHQEQ6ST9X8w7w= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.21/go.mod h1:4vIRDq+CJB2xFAXZ+YgGUTiEft7oAQlhIs71xcSeuVg= +github.com/aws/aws-sdk-go-v2/service/sts v1.42.1 h1:F/M5Y9I3nwr2IEpshZgh1GeHpOItExNM9L1euNuh/fk= +github.com/aws/aws-sdk-go-v2/service/sts v1.42.1/go.mod h1:mTNxImtovCOEEuD65mKW7DCsL+2gjEH+RPEAexAzAio= +github.com/aws/smithy-go v1.25.1 h1:J8ERsGSU7d+aCmdQur5Txg6bVoYelvQJgtZehD12GkI= +github.com/aws/smithy-go v1.25.1/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk= github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg= github.com/bmatcuk/doublestar v1.3.4 h1:gPypJ5xD31uhX6Tf54sDPUOBXTqKH4c9aPY66CyQrS0= @@ -46,6 +86,26 @@ github.com/buger/jsonparser v1.1.2 h1:frqHqw7otoVbk5M8LlE/L7HTnIq2v9RX6EJ48i9AxJ github.com/buger/jsonparser v1.1.2/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc= +github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E= +github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= +github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= +github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk= +github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk= +github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= +github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= +github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8= +github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ= +github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI= +github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q= +github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= +github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= +github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA= +github.com/clipperhouse/displaywidth v0.9.0/go.mod h1:aCAAqTlh4GIVkhQnJpbL0T/WfcrJXHcj8C0yjYcjOZA= +github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs= +github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA= +github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U= +github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g= github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg= github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= @@ -70,6 +130,8 @@ github.com/docker/go-connections v0.6.0 h1:LlMG9azAe1TqfR7sO+NJttz1gy6KO7VJBh+pM github.com/docker/go-connections v0.6.0/go.mod h1:AahvXYshr6JgfUJGdDCs2b5EZG/vmaMAntpSFH5BFKE= github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM= github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= @@ -187,6 +249,8 @@ github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= +github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= +github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= @@ -196,6 +260,10 @@ github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/ github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= +github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= +github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= +github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/mitchellh/go-wordwrap v1.0.1 h1:TLuKupo69TCn6TQSyGxwI1EblZZEsQ0vMlAFQflz0v0= @@ -213,6 +281,12 @@ github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjY github.com/montanaflynn/stats v0.0.0-20171201202039-1bf9dbcd8cbe/go.mod h1:wL8QJuTMNUDYhXwkmfOly8iTdp5TEcJFWZD2D7SIkUc= github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= +github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= github.com/nicksnyder/go-i18n/v2 v2.6.1 h1:JDEJraFsQE17Dut9HFDHzCoAWGEQJom5s0TRd17NIEQ= github.com/nicksnyder/go-i18n/v2 v2.6.1/go.mod h1:Vee0/9RD3Quc/NmwEjzzD7VTZ+Ir7QbXocrkhOzmUKA= github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= @@ -238,6 +312,8 @@ github.com/rcrowley/go-metrics v0.0.0-20250401214520-65e299d6c5c9 h1:bsUq1dX0N8A github.com/rcrowley/go-metrics v0.0.0-20250401214520-65e299d6c5c9/go.mod h1:bCqnVzQkZxMG4s8nGwiZ5l3QUCyqpo9Y+/ZMZ9VjZe4= github.com/redis/go-redis/v9 v9.18.0 h1:pMkxYPkEbMPwRdenAzUNyFNrDgHx9U+DrBabWNfSRQs= github.com/redis/go-redis/v9 v9.18.0/go.mod h1:k3ufPphLU5YXwNTUcCRXGxUoF1fqxnhFQmscfkCoDA0= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= @@ -301,6 +377,8 @@ github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+x github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI= github.com/xdg-go/scram v1.1.1/go.mod h1:RaEWvsqvNKKvBPvcKeFjrG2cJqOkHTiyTpzz23ni57g= github.com/xdg-go/stringprep v1.0.3/go.mod h1:W3f5j4i+9rC0kuIEJL0ky1VpHXQU3ocBgklLGvcBnW8= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= github.com/youmark/pkcs8 v0.0.0-20181117223130-1be2e3e5546d/go.mod h1:rHwXgn7JulP+udvsHwJoVG1YGAP6VLg4y9I5dyZdqmA= @@ -347,6 +425,8 @@ golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPh golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= +golang.org/x/exp v0.0.0-20250813145105-42675adae3e6 h1:SbTAbRFnd5kjQXbczszQ0hdk3ctwYf3qBNH9jIsGclE= +golang.org/x/exp v0.0.0-20250813145105-42675adae3e6/go.mod h1:4QTo5u+SEIbbKW1RacMZq1YEfOBqeXa19JeshGi+zc4= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8= @@ -373,6 +453,7 @@ golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= diff --git a/backend/installation/center/.env.example b/backend/installation/center/.env.example new file mode 100644 index 00000000..d296884b --- /dev/null +++ b/backend/installation/center/.env.example @@ -0,0 +1,27 @@ +INSTALL_DIR=/data/monkeycode-ai +REMOTE_IP= +NGINX_PORT=80 +POSTGRES_DB=monkeycode-ai +POSTGRES_USER=monkeycode-ai +POSTGRES_PASSWORD= +REDIS_PASSWORD= +CLICKHOUSE_DB=monkeycode-ai +CLICKHOUSE_USER=monkeycode-ai +CLICKHOUSE_PASSWORD= +RUSTFS_ACCESS_KEY= +RUSTFS_SECRET_KEY= +TEAM_EMAIL= +TEAM_NAME=MonkeyCode +TEAM_PASSWORD= +INIT_TEAM_IMAGE= +SUBNET_PREFIX=10.100.50 +RELAY_SECRET= +POSTGRES_IMAGE= +REDIS_IMAGE= +CLICKHOUSE_IMAGE= +RUSTFS_IMAGE= +INGRESS_IMAGE= +TASKFLOW_IMAGE= +PREVIEW_IMAGE= +FRONTEND_IMAGE= +BACKEND_IMAGE= diff --git a/backend/installation/center/install.sh b/backend/installation/center/install.sh new file mode 100644 index 00000000..6d413b10 --- /dev/null +++ b/backend/installation/center/install.sh @@ -0,0 +1,11 @@ +#!/bin/sh +set -eu + +DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) + +if [ "$(id -u)" -ne 0 ]; then + echo "installer must run as root" + exit 1 +fi + +exec "$DIR/installer" center diff --git a/backend/installation/runner/docker-compose.yml b/backend/installation/runner/docker-compose.yml new file mode 100644 index 00000000..248741d1 --- /dev/null +++ b/backend/installation/runner/docker-compose.yml @@ -0,0 +1,19 @@ +name: monkeycode_runner +services: + orchestrator: + image: ${ORCHESTRATOR_IMAGE} + container_name: monkeycode-orchestrator + restart: always + labels: + - "com.centurylinklabs.watchtower.enable=true" + environment: + - ORCHESTRATOR_GRPC_URL=${GRPC_URL} + - ORCHESTRATOR_AUTH_TOKEN=${TOKEN} + - ORCHESTRATOR_TYPE=docker + - ORCHESTRATOR_LOG_LEVEL=debug + - ORCHESTRATOR_GRPC_PROXY=${GRPC_PROXY:-} + - WATCHTOWER_HTTP_API_TOKEN=${WATCHTOWER_API_TOKEN:-change-this-token} + - TZ=Asia/Shanghai + volumes: + - ./data:/app/data + - /var/run/docker.sock:/var/run/docker.sock diff --git a/backend/pkg/installer/app/app.go b/backend/pkg/installer/app/app.go new file mode 100644 index 00000000..2d5a229a --- /dev/null +++ b/backend/pkg/installer/app/app.go @@ -0,0 +1,152 @@ +package app + +import ( + "strings" + + "github.com/chaitin/MonkeyCode/backend/pkg/installer/deploy" + "github.com/chaitin/MonkeyCode/backend/pkg/installer/logging" + "github.com/chaitin/MonkeyCode/backend/pkg/installer/steps" + "github.com/chaitin/MonkeyCode/backend/pkg/installer/tui" +) + +type Action struct { + Label string + Value string + Steps []steps.Step +} + +type App struct { + Title string + Banner string + Description string + Actions []Action + Logger *logging.Logger +} + +func (a *App) Run() int { + runner := tui.NewRunner(a.Logger.Plain()) + reporter := runner.Reporter() + + go func() { + if a.Banner != "" { + for _, line := range splitBanner(a.Banner) { + reporter.Log("%s", centerLine(line, 80)) + } + } + if a.Description != "" { + reporter.Log("%s", centerLine(a.Description, 80)) + reporter.Log("") + } + + reporter.Log("开始记录日志: %s", a.Logger.Path()) + + options := make([]steps.MenuOption, 0, len(a.Actions)+1) + for _, ac := range a.Actions { + options = append(options, steps.MenuOption{Label: ac.Label, Value: ac.Value}) + } + options = append(options, steps.MenuOption{Label: "退出", Value: "quit"}) + + choice, err := reporter.AskMenu(a.Title, options) + if err != nil || choice == "quit" { + runner.Quit() + return + } + + var picked Action + for _, ac := range a.Actions { + if ac.Value == choice { + picked = ac + break + } + } + if picked.Value == "" { + runner.Quit() + return + } + + ctx := &steps.Context{ + Runner: deploy.CommandRunner{Log: func(line string) { reporter.Log(" %s", line) }}, + Reporter: reporter, + LogPath: a.Logger.Path(), + } + var failedStep string + for i, s := range picked.Steps { + ctx.Progress = steps.Progress{Current: i + 1, Total: len(picked.Steps)} + if err := s.Run(ctx); err != nil { + failedStep = s.Name() + printFailure(reporter, runner, failedStep, err, a.Logger.Path()) + return + } + } + printSuccess(reporter, runner, ctx.Result, a.Logger.Path()) + }() + + if err := runner.Run(); err != nil { + return 1 + } + if runner.BizErr() != nil { + return 1 + } + return 0 +} + +func printSuccess(r steps.Reporter, runner *tui.Runner, result deploy.InstallResult, logPath string) { + r.Log("安装完成") + if result.URL != "" { + r.Log(" 访问地址: %s", result.URL) + } + if result.AdminEmail != "" { + r.Log(" 管理员账号: %s", result.AdminEmail) + } + if result.AdminPassword != "" { + r.LogScreen(" 管理员密码: %s", result.AdminPassword) + r.LogFile(" 管理员密码: ********") + } + r.Log(" 完整日志: %s", logPath) + runner.Done(nil) +} + +func printFailure(r steps.Reporter, runner *tui.Runner, stepName string, err error, logPath string) { + r.Log("操作失败") + r.Log(" 失败步骤: %s", stepName) + r.Log(" 错误信息: %v", err) + r.Log(" 完整日志: %s", logPath) + runner.Done(err) +} + +func splitBanner(b string) []string { + out := []string{} + cur := "" + for _, ch := range b { + if ch == '\n' { + out = append(out, cur) + cur = "" + continue + } + cur += string(ch) + } + if cur != "" { + out = append(out, cur) + } + return out +} + +func centerLine(line string, width int) string { + width -= appVisualWidth(line) + if width <= 0 { + return line + } + return strings.Repeat(" ", width/2) + line +} + +func appVisualWidth(line string) int { + n := 0 + for _, r := range line { + if r >= 0x4E00 && r <= 0x9FFF { + n += 2 + continue + } + n++ + } + return n +} diff --git a/backend/pkg/installer/deploy/center.go b/backend/pkg/installer/deploy/center.go new file mode 100644 index 00000000..cf2c17ed --- /dev/null +++ b/backend/pkg/installer/deploy/center.go @@ -0,0 +1,288 @@ +package deploy + +import ( + "context" + "crypto/rand" + "encoding/hex" + "fmt" + "io" + "io/fs" + "net" + "os" + "path/filepath" + "strings" + + "github.com/google/uuid" +) + +const DefaultDevboxImage = "ghcr.io/chaitin/monkeycode-runner/devbox:latest" + +type CenterEnvInput struct { + InstallDir string + AccessHost string + NginxPort string + TeamEmail string + TeamName string + TeamPassword string +} + +type CenterEnv struct { + InstallDir string + AccessHost string + NginxPort string + PostgresDB string + PostgresUser string + PostgresPassword string + RedisPassword string + ClickHouseDB string + ClickHouseUser string + ClickHousePassword string + RustFSAccessKey string + RustFSSecretKey string + TeamEmail string + TeamName string + TeamPassword string + InitTeamImage string + SubnetPrefix string + RelaySecret string +} + +type CenterInstallPlan struct { + WorkDir string + ComposeFile string + EnvFile string + TLS TLSPlan + Images []ImageArchive +} + +type ImageArchive struct { + Path string + Compressed bool +} + +type InstallResult struct { + URL string + AdminEmail string + AdminPassword string +} + +func NewCenterEnv(input CenterEnvInput) (CenterEnv, error) { + env := CenterEnv{ + InstallDir: fallback(input.InstallDir, "/data/monkeycode-ai"), + AccessHost: input.AccessHost, + NginxPort: fallback(input.NginxPort, "80"), + PostgresDB: "monkeycode-ai", + PostgresUser: "monkeycode-ai", + ClickHouseDB: "monkeycode-ai", + ClickHouseUser: "monkeycode-ai", + TeamEmail: input.TeamEmail, + TeamName: fallback(input.TeamName, "MonkeyCode"), + TeamPassword: input.TeamPassword, + InitTeamImage: DefaultDevboxImage, + SubnetPrefix: "10.100.50", + } + var err error + if env.TeamPassword == "" { + env.TeamPassword, err = randomSecret(24) + if err != nil { + return CenterEnv{}, err + } + } + if env.PostgresPassword, err = randomSecret(24); err != nil { + return CenterEnv{}, err + } + if env.RedisPassword, err = randomSecret(24); err != nil { + return CenterEnv{}, err + } + if env.ClickHousePassword, err = randomSecret(24); err != nil { + return CenterEnv{}, err + } + if env.RustFSAccessKey, err = randomSecret(24); err != nil { + return CenterEnv{}, err + } + if env.RustFSSecretKey, err = randomSecret(32); err != nil { + return CenterEnv{}, err + } + if env.RelaySecret, err = randomUUID(); err != nil { + return CenterEnv{}, err + } + return env, nil +} + +func RenderCenterEnv(template string, env CenterEnv) string { + values := map[string]string{ + "INSTALL_DIR": env.InstallDir, + "REMOTE_IP": env.AccessHost, + "NGINX_PORT": env.NginxPort, + "POSTGRES_DB": env.PostgresDB, + "POSTGRES_USER": env.PostgresUser, + "POSTGRES_PASSWORD": env.PostgresPassword, + "REDIS_PASSWORD": env.RedisPassword, + "CLICKHOUSE_DB": env.ClickHouseDB, + "CLICKHOUSE_USER": env.ClickHouseUser, + "CLICKHOUSE_PASSWORD": env.ClickHousePassword, + "RUSTFS_ACCESS_KEY": env.RustFSAccessKey, + "RUSTFS_SECRET_KEY": env.RustFSSecretKey, + "TEAM_EMAIL": env.TeamEmail, + "TEAM_NAME": env.TeamName, + "TEAM_PASSWORD": env.TeamPassword, + "INIT_TEAM_IMAGE": env.InitTeamImage, + "SUBNET_PREFIX": env.SubnetPrefix, + "RELAY_SECRET": env.RelaySecret, + } + lines := strings.Split(template, "\n") + for i, line := range lines { + key, _, ok := strings.Cut(line, "=") + if !ok { + continue + } + if value, exists := values[strings.TrimSpace(key)]; exists { + lines[i] = strings.TrimSpace(key) + "=" + value + } + } + return strings.Join(lines, "\n") +} + +func PrepareCenterFiles(workDir, packageDir string, env CenterEnv) (CenterInstallPlan, error) { + if err := os.MkdirAll(workDir, 0o755); err != nil { + return CenterInstallPlan{}, fmt.Errorf("create install dir: %w", err) + } + composePath := filepath.Join(workDir, "docker-compose.yml") + if err := copyFile(filepath.Join(packageDir, "docker-compose.yml"), composePath); err != nil { + return CenterInstallPlan{}, fmt.Errorf("copy compose file: %w", err) + } + if err := copyTreeIfExists(filepath.Join(packageDir, "static"), filepath.Join(workDir, "static")); err != nil { + return CenterInstallPlan{}, fmt.Errorf("copy static: %w", err) + } + if err := copyTreeIfExists(filepath.Join(packageDir, "images"), filepath.Join(workDir, "images")); err != nil { + return CenterInstallPlan{}, fmt.Errorf("copy images: %w", err) + } + envTemplate, err := os.ReadFile(filepath.Join(packageDir, ".env.example")) + if err != nil { + return CenterInstallPlan{}, fmt.Errorf("read .env.example: %w", err) + } + if env.InstallDir == "" { + env.InstallDir = workDir + } + rendered := RenderCenterEnv(string(envTemplate), env) + envPath := filepath.Join(workDir, ".env") + if err := os.WriteFile(envPath, []byte(rendered), 0o600); err != nil { + return CenterInstallPlan{}, fmt.Errorf("write .env: %w", err) + } + return CenterInstallPlan{ + WorkDir: workDir, + ComposeFile: composePath, + EnvFile: envPath, + }, nil +} + +func copyFile(src, dst string) error { + in, err := os.Open(src) + if err != nil { + return err + } + defer in.Close() + if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil { + return err + } + out, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o644) + if err != nil { + return err + } + defer out.Close() + _, err = io.Copy(out, in) + return err +} + +func copyTreeIfExists(src, dst string) error { + if _, err := os.Stat(src); err != nil { + if os.IsNotExist(err) { + return nil + } + return err + } + return filepath.WalkDir(src, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + rel, err := filepath.Rel(src, path) + if err != nil { + return err + } + target := filepath.Join(dst, rel) + if d.IsDir() { + return os.MkdirAll(target, 0o755) + } + return copyFile(path, target) + }) +} + +func PrepareCenterDirs(ctx context.Context, r Runner, workDir string) error { + postgresData := filepath.Join(workDir, "data", "postgres") + redisData := filepath.Join(workDir, "data", "redis") + clickhouseData := filepath.Join(workDir, "data", "clickhouse") + clickhouseLogs := filepath.Join(workDir, "logs", "clickhouse") + rustfsData := filepath.Join(workDir, "data", "rustfs") + rustfsLogs := filepath.Join(workDir, "logs", "rustfs") + + if err := run(ctx, r, "mkdir", "-p", postgresData, redisData, clickhouseData, clickhouseLogs, rustfsData, rustfsLogs); err != nil { + return fmt.Errorf("create center data dirs: %w", err) + } + if err := run(ctx, r, "chown", "-R", "101:101", clickhouseData, clickhouseLogs); err != nil { + return fmt.Errorf("prepare clickhouse data dirs: %w", err) + } + if err := run(ctx, r, "chown", "-R", "10001:10001", rustfsData, rustfsLogs); err != nil { + return fmt.Errorf("prepare rustfs data dirs: %w", err) + } + return nil +} + +func InstallCenter(ctx context.Context, r Runner, plan CenterInstallPlan) error { + args := []string{"compose", "-f", plan.ComposeFile} + if plan.EnvFile != "" { + args = append(args, "--env-file", plan.EnvFile) + } + args = append(args, "up", "-d") + if err := run(ctx, r, "docker", args...); err != nil { + return fmt.Errorf("start center services: %w", err) + } + return nil +} + +func CenterAccessURL(host, port string) string { + host = strings.TrimSpace(host) + port = strings.TrimSpace(port) + if host == "" { + host = "localhost" + } + if ip := net.ParseIP(host); ip != nil && strings.Contains(host, ":") { + host = "[" + host + "]" + } + if port == "" || port == "80" { + return "http://" + host + } + return "http://" + host + ":" + port +} + +func randomSecret(n int) (string, error) { + b := make([]byte, n/2+1) + if _, err := rand.Read(b); err != nil { + return "", err + } + return hex.EncodeToString(b)[:n], nil +} + +func randomUUID() (string, error) { + id, err := uuid.NewRandom() + if err != nil { + return "", err + } + return id.String(), nil +} + +func fallback(v, def string) string { + if strings.TrimSpace(v) == "" { + return def + } + return v +} diff --git a/backend/pkg/installer/deploy/center_test.go b/backend/pkg/installer/deploy/center_test.go new file mode 100644 index 00000000..8344cf5e --- /dev/null +++ b/backend/pkg/installer/deploy/center_test.go @@ -0,0 +1,35 @@ +package deploy + +import ( + "regexp" + "strings" + "testing" +) + +func TestCenterAccessURLFormatsIPv6Host(t *testing.T) { + got := CenterAccessURL("2001:db8::1", "8080") + want := "http://[2001:db8::1]:8080" + if got != want { + t.Fatalf("CenterAccessURL() = %q, want %q", got, want) + } +} + +func TestNewCenterEnvGeneratesRelaySecretUUID(t *testing.T) { + env, err := NewCenterEnv(CenterEnvInput{}) + if err != nil { + t.Fatal(err) + } + if !regexp.MustCompile(`^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$`).MatchString(env.RelaySecret) { + t.Fatalf("RelaySecret = %q, want uuid v4", env.RelaySecret) + } +} + +func TestRenderCenterEnvWritesRelaySecret(t *testing.T) { + rendered := RenderCenterEnv("RELAY_SECRET=\nTEAM_NAME=\n", CenterEnv{ + RelaySecret: "11111111-1111-4111-8111-111111111111", + TeamName: "MonkeyCode", + }) + if !strings.Contains(rendered, "RELAY_SECRET=11111111-1111-4111-8111-111111111111") { + t.Fatalf("rendered env missing relay secret: %q", rendered) + } +} diff --git a/backend/pkg/installer/deploy/docker.go b/backend/pkg/installer/deploy/docker.go new file mode 100644 index 00000000..3e71bd33 --- /dev/null +++ b/backend/pkg/installer/deploy/docker.go @@ -0,0 +1,58 @@ +package deploy + +import ( + "context" + "strings" +) + +type DockerStatus struct { + DockerInstalled bool + DockerVersion string + ComposeInstalled bool + ComposeVersion string + DaemonRunning bool + DaemonVersion string +} + +func (s DockerStatus) Ready() bool { + return s.DockerInstalled && s.ComposeInstalled && s.DaemonRunning +} + +func CheckDockerStatus(ctx context.Context, r Runner) DockerStatus { + status := DockerStatus{} + if res := r.Run(ctx, "docker", "--version"); res.Err == nil { + status.DockerInstalled = true + status.DockerVersion = parseDockerVersion(res.Stdout) + } + if res := r.Run(ctx, "docker", "compose", "version"); res.Err == nil { + status.ComposeInstalled = true + status.ComposeVersion = parseComposeVersion(res.Stdout) + } + if res := r.Run(ctx, "docker", "info", "--format", "{{.ServerVersion}}"); res.Err == nil { + status.DaemonRunning = true + status.DaemonVersion = strings.TrimSpace(res.Stdout) + } + return status +} + +func parseDockerVersion(out string) string { + out = strings.TrimSpace(out) + parts := strings.Fields(out) + for i, p := range parts { + if p == "version" && i+1 < len(parts) { + return strings.TrimSuffix(parts[i+1], ",") + } + } + return out +} + +func parseComposeVersion(out string) string { + out = strings.TrimSpace(out) + parts := strings.Fields(out) + for i, p := range parts { + if p == "version" && i+1 < len(parts) { + return parts[i+1] + } + } + return out +} diff --git a/backend/pkg/installer/deploy/docker_offline.go b/backend/pkg/installer/deploy/docker_offline.go new file mode 100644 index 00000000..8ebcf697 --- /dev/null +++ b/backend/pkg/installer/deploy/docker_offline.go @@ -0,0 +1,107 @@ +package deploy + +import ( + "context" + "fmt" + "os" +) + +const dockerService = `[Unit] +Description=Docker Application Container Engine +Documentation=https://docs.docker.com +After=network-online.target firewalld.service +Wants=network-online.target + +[Service] +Type=notify +ExecStart=/usr/local/bin/dockerd +ExecReload=/bin/kill -s HUP $MAINPID +TimeoutStartSec=0 +Restart=on-failure +StartLimitBurst=3 +StartLimitInterval=60s +LimitNOFILE=infinity +LimitNPROC=infinity +LimitCORE=infinity +TasksMax=infinity +Delegate=yes +KillMode=process +OOMScoreAdjust=-500 + +[Install] +WantedBy=multi-user.target +` + +type DockerInstallPlan struct { + WorkDir string + BundleFile string + BundleURL string +} + +func InstallDockerWithProgress(ctx context.Context, r Runner, plan DockerInstallPlan, progress ProgressFunc) error { + if err := prepareDockerInstallDir(plan.WorkDir); err != nil { + return fmt.Errorf("create docker install dir: %w", err) + } + if plan.BundleURL != "" { + if err := DownloadFile(ctx, plan.BundleURL, plan.BundleFile, progress); err != nil { + return fmt.Errorf("download docker bundle: %w", err) + } + } + return installDockerBundle(ctx, r, plan) +} + +func InstallDockerFromLocalBundle(ctx context.Context, r Runner, plan DockerInstallPlan) error { + if err := prepareDockerInstallDir(plan.WorkDir); err != nil { + return fmt.Errorf("create docker install dir: %w", err) + } + return installDockerBundle(ctx, r, plan) +} + +func prepareDockerInstallDir(dir string) error { + info, err := os.Stat(dir) + if err == nil { + if info.IsDir() { + return nil + } + if err := os.Remove(dir); err != nil { + return err + } + } else if !os.IsNotExist(err) { + return err + } + return os.MkdirAll(dir, 0o755) +} + +func installDockerBundle(ctx context.Context, r Runner, plan DockerInstallPlan) error { + if err := run(ctx, r, "tar", "-zxf", plan.BundleFile, "-C", plan.WorkDir); err != nil { + return fmt.Errorf("extract docker bundle: %w", err) + } + if err := runShell(ctx, r, fmt.Sprintf("install -m 0755 '%s'/docker/* /usr/local/bin/", plan.WorkDir)); err != nil { + return fmt.Errorf("install docker binaries: %w", err) + } + if err := run(ctx, r, "mkdir", "-p", "/usr/local/lib/docker/cli-plugins"); err != nil { + return fmt.Errorf("create docker cli plugin dir: %w", err) + } + if err := run(ctx, r, "install", "-m", "0755", plan.WorkDir+"/docker-compose", "/usr/local/lib/docker/cli-plugins/docker-compose"); err != nil { + return fmt.Errorf("install docker compose: %w", err) + } + if err := run(ctx, r, "ln", "-sf", "/usr/local/lib/docker/cli-plugins/docker-compose", "/usr/local/bin/docker-compose"); err != nil { + return fmt.Errorf("link docker compose: %w", err) + } + if err := run(ctx, r, "mkdir", "-p", "/etc/systemd/system"); err != nil { + return fmt.Errorf("create systemd unit dir: %w", err) + } + if err := runShell(ctx, r, "cat > /etc/systemd/system/docker.service <<'EOF'\n"+dockerService+"EOF\n"); err != nil { + return fmt.Errorf("write docker service: %w", err) + } + if err := run(ctx, r, "systemctl", "daemon-reload"); err != nil { + return fmt.Errorf("reload systemd: %w", err) + } + if err := run(ctx, r, "systemctl", "enable", "--now", "docker"); err != nil { + _ = r.Run(ctx, "systemctl", "status", "--no-pager", "containerd") + _ = r.Run(ctx, "systemctl", "status", "--no-pager", "docker") + _ = r.Run(ctx, "journalctl", "-u", "containerd", "-u", "docker", "--no-pager", "-n", "120") + return fmt.Errorf("start docker: %w", err) + } + return nil +} diff --git a/backend/pkg/installer/deploy/docker_offline_test.go b/backend/pkg/installer/deploy/docker_offline_test.go new file mode 100644 index 00000000..a1493f27 --- /dev/null +++ b/backend/pkg/installer/deploy/docker_offline_test.go @@ -0,0 +1,37 @@ +package deploy + +import ( + "os" + "path/filepath" + "testing" +) + +func TestPrepareDockerInstallDirReplacesFile(t *testing.T) { + dir := filepath.Join(t.TempDir(), "monkeycode-installer") + if err := os.WriteFile(dir, []byte("stale"), 0o644); err != nil { + t.Fatal(err) + } + + if err := prepareDockerInstallDir(dir); err != nil { + t.Fatal(err) + } + + info, err := os.Stat(dir) + if err != nil { + t.Fatal(err) + } + if !info.IsDir() { + t.Fatalf("%s should be directory", dir) + } +} + +func TestPrepareDockerInstallDirKeepsDirectory(t *testing.T) { + dir := filepath.Join(t.TempDir(), "monkeycode-installer") + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + + if err := prepareDockerInstallDir(dir); err != nil { + t.Fatal(err) + } +} diff --git a/backend/pkg/installer/deploy/download.go b/backend/pkg/installer/deploy/download.go new file mode 100644 index 00000000..5adbda55 --- /dev/null +++ b/backend/pkg/installer/deploy/download.go @@ -0,0 +1,113 @@ +package deploy + +import ( + "context" + "fmt" + "io" + "net/http" + "net/url" + "os" + "path" + "runtime" + "strings" +) + +type DownloadProgress struct { + Downloaded int64 + Total int64 +} + +func (p DownloadProgress) Percent() float64 { + if p.Total <= 0 { + return 0 + } + v := float64(p.Downloaded) / float64(p.Total) + if v < 0 { + return 0 + } + if v > 1 { + return 1 + } + return v +} + +type ProgressFunc func(DownloadProgress) + +func InstallerArch() string { + switch runtime.GOARCH { + case "arm64": + return "aarch64" + default: + return "x86_64" + } +} + +func BundleURL(baseURL, bundlePath string) (string, error) { + u, err := url.Parse(strings.TrimRight(baseURL, "/")) + if err != nil { + return "", err + } + u.Path = path.Join(u.Path, bundlePath) + return u.String(), nil +} + +func DownloadFile(ctx context.Context, sourceURL, dest string, progress ProgressFunc) error { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, sourceURL, nil) + if err != nil { + return err + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices { + return fmt.Errorf("unexpected status: %s", resp.Status) + } + + if err := os.MkdirAll(parentDir(dest), 0o755); err != nil { + return err + } + out, err := os.Create(dest) + if err != nil { + return err + } + defer out.Close() + + reader := &progressReader{reader: resp.Body, total: resp.ContentLength, progress: progress} + if _, err := io.Copy(out, reader); err != nil { + return err + } + if progress != nil { + progress(DownloadProgress{Downloaded: reader.downloaded, Total: resp.ContentLength}) + } + return nil +} + +type progressReader struct { + reader io.Reader + downloaded int64 + total int64 + progress ProgressFunc +} + +func (r *progressReader) Read(p []byte) (int, error) { + n, err := r.reader.Read(p) + if n > 0 { + r.downloaded += int64(n) + if r.progress != nil { + r.progress(DownloadProgress{Downloaded: r.downloaded, Total: r.total}) + } + } + return n, err +} + +func parentDir(p string) string { + for i := len(p) - 1; i >= 0; i-- { + if p[i] == '/' { + return p[:i] + } + } + return "." +} diff --git a/backend/pkg/installer/deploy/fake.go b/backend/pkg/installer/deploy/fake.go new file mode 100644 index 00000000..c70a7238 --- /dev/null +++ b/backend/pkg/installer/deploy/fake.go @@ -0,0 +1,37 @@ +package deploy + +import "context" + +type FakeRunner struct { + Calls []string + OnRun func(name string, args ...string) RunResult + OnShell func(script string) RunResult +} + +func (f *FakeRunner) Run(ctx context.Context, name string, args ...string) RunResult { + full := append([]string{name}, args...) + f.Calls = append(f.Calls, joinArgs(full)) + if f.OnRun != nil { + return f.OnRun(name, args...) + } + return RunResult{} +} + +func (f *FakeRunner) RunShell(ctx context.Context, script string) RunResult { + f.Calls = append(f.Calls, "sh: "+script) + if f.OnShell != nil { + return f.OnShell(script) + } + return RunResult{} +} + +func joinArgs(parts []string) string { + out := "" + for i, p := range parts { + if i > 0 { + out += " " + } + out += p + } + return out +} diff --git a/backend/pkg/installer/deploy/host.go b/backend/pkg/installer/deploy/host.go new file mode 100644 index 00000000..5753d32d --- /dev/null +++ b/backend/pkg/installer/deploy/host.go @@ -0,0 +1,86 @@ +package deploy + +import ( + "context" + "fmt" + "strings" +) + +func shellQuote(s string) string { + return "'" + strings.ReplaceAll(s, "'", "'\\''") + "'" +} + +type HostBundlePlan struct { + BundleURL string + BundleFile string + WorkDir string +} + +type HostInstallPlan struct { + WorkDir string + ComposeFile string + EnvFile string + Token string + GrpcURL string + Images []ImageArchive +} + +func PrepareHostBundleWithProgress(ctx context.Context, r Runner, plan HostBundlePlan, progress ProgressFunc) error { + if err := run(ctx, r, "mkdir", "-p", plan.WorkDir); err != nil { + return fmt.Errorf("create host install dir: %w", err) + } + if err := DownloadFile(ctx, plan.BundleURL, plan.BundleFile, progress); err != nil { + return fmt.Errorf("download host bundle: %w", err) + } + if err := run(ctx, r, "tar", "-zxf", plan.BundleFile, "-C", plan.WorkDir); err != nil { + return fmt.Errorf("extract host bundle: %w", err) + } + return nil +} + +func InstallHost(ctx context.Context, r Runner, plan HostInstallPlan) error { + if err := LoadImages(ctx, r, plan.Images); err != nil { + return err + } + if plan.EnvFile != "" && (plan.Token != "" || plan.GrpcURL != "") { + var lines []string + if plan.Token != "" { + lines = append(lines, "TOKEN="+plan.Token) + } + if plan.GrpcURL != "" { + lines = append(lines, "GRPC_URL="+plan.GrpcURL) + } + quoted := make([]string, len(lines)) + for i, line := range lines { + quoted[i] = shellQuote(line) + } + script := fmt.Sprintf("printf '%%s\\n' %s >> '%s'", strings.Join(quoted, " "), plan.EnvFile) + if err := runShell(ctx, r, script); err != nil { + return fmt.Errorf("write host env: %w", err) + } + } + args := []string{"compose", "-f", plan.ComposeFile} + if plan.EnvFile != "" { + args = append(args, "--env-file", plan.EnvFile) + } + args = append(args, "up", "-d") + if err := run(ctx, r, "docker", args...); err != nil { + return fmt.Errorf("start host services: %w", err) + } + return nil +} + +func UninstallHost(ctx context.Context, r Runner, plan HostInstallPlan) error { + args := []string{"compose", "-f", plan.ComposeFile} + if plan.EnvFile != "" { + args = append(args, "--env-file", plan.EnvFile) + } + args = append(args, "down") + if err := run(ctx, r, "docker", args...); err != nil { + return fmt.Errorf("stop host services: %w", err) + } + if err := run(ctx, r, "rm", "-rf", plan.WorkDir); err != nil { + return fmt.Errorf("remove host install dir: %w", err) + } + return nil +} diff --git a/backend/pkg/installer/deploy/images.go b/backend/pkg/installer/deploy/images.go new file mode 100644 index 00000000..63fe5995 --- /dev/null +++ b/backend/pkg/installer/deploy/images.go @@ -0,0 +1,69 @@ +package deploy + +import ( + "context" + "errors" + "fmt" + "io/fs" + "os" + "path/filepath" + "strings" +) + +func ScanImages(imagesDir string) ([]ImageArchive, error) { + if _, err := os.Stat(imagesDir); err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, err + } + var out []ImageArchive + err := filepath.WalkDir(imagesDir, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if d.IsDir() { + return nil + } + name := strings.ToLower(d.Name()) + switch { + case strings.HasSuffix(name, ".tar.gz"), strings.HasSuffix(name, ".tgz"): + out = append(out, ImageArchive{Path: path, Compressed: true}) + case strings.HasSuffix(name, ".tar"): + out = append(out, ImageArchive{Path: path, Compressed: false}) + } + return nil + }) + if err != nil { + return nil, err + } + return out, nil +} + +func LoadImages(ctx context.Context, r Runner, images []ImageArchive) error { + for _, image := range images { + if image.Compressed { + res := r.RunShell(ctx, fmt.Sprintf("gzip -dc '%s' | docker load", image.Path)) + if err := dockerLoadError(res); err != nil { + return fmt.Errorf("load image %s: %w", image.Path, err) + } + continue + } + res := r.Run(ctx, "docker", "load", "-i", image.Path) + if err := dockerLoadError(res); err != nil { + return fmt.Errorf("load image %s: %w", image.Path, err) + } + } + return nil +} + +func dockerLoadError(res RunResult) error { + output := strings.TrimSpace(res.Stdout + res.Stderr) + if err := runError(res); err != nil { + return err + } + if strings.Contains(output, "Error unpacking image") { + return errors.New(output) + } + return nil +} diff --git a/backend/pkg/installer/deploy/runner.go b/backend/pkg/installer/deploy/runner.go new file mode 100644 index 00000000..3a452aa3 --- /dev/null +++ b/backend/pkg/installer/deploy/runner.go @@ -0,0 +1,121 @@ +package deploy + +import ( + "context" + "errors" + "fmt" + "io" + "os/exec" + "strings" +) + +var ErrCommandNotFound = errors.New("command not found") + +type RunResult struct { + Stdout string + Stderr string + Err error +} + +type Runner interface { + Run(ctx context.Context, name string, args ...string) RunResult + RunShell(ctx context.Context, script string) RunResult +} + +type LogFunc func(line string) + +func runError(res RunResult) error { + if res.Err == nil { + return nil + } + if msg := strings.TrimSpace(res.Stderr); msg != "" { + return fmt.Errorf("%w: %s", res.Err, msg) + } + return res.Err +} + +func run(ctx context.Context, r Runner, name string, args ...string) error { + return runError(r.Run(ctx, name, args...)) +} + +func runShell(ctx context.Context, r Runner, script string) error { + return runError(r.RunShell(ctx, script)) +} + +type CommandRunner struct { + Log LogFunc +} + +func (c CommandRunner) Run(ctx context.Context, name string, args ...string) RunResult { + cmd := exec.CommandContext(ctx, name, args...) + return c.exec(cmd, name+" "+strings.Join(args, " ")) +} + +func (c CommandRunner) RunShell(ctx context.Context, script string) RunResult { + cmd := exec.CommandContext(ctx, "sh", "-c", script) + return c.exec(cmd, script) +} + +func (c CommandRunner) exec(cmd *exec.Cmd, label string) RunResult { + stdout, err := cmd.StdoutPipe() + if err != nil { + return RunResult{Err: err} + } + stderr, err := cmd.StderrPipe() + if err != nil { + return RunResult{Err: err} + } + if err := cmd.Start(); err != nil { + return RunResult{Err: err} + } + if c.Log != nil { + c.Log("$ " + label) + } + out := streamPipe(stdout, c.Log) + errOut := streamPipe(stderr, c.Log) + runErr := cmd.Wait() + return RunResult{Stdout: out, Stderr: errOut, Err: runErr} +} + +func streamPipe(r io.Reader, log LogFunc) string { + if r == nil { + return "" + } + buf := make([]byte, 0, 1024) + tmp := make([]byte, 1024) + pending := []byte{} + for { + n, err := r.Read(tmp) + if n > 0 { + pending = append(pending, tmp[:n]...) + for { + idx := -1 + for i, b := range pending { + if b == '\n' { + idx = i + break + } + } + if idx < 0 { + break + } + line := string(pending[:idx]) + pending = pending[idx+1:] + buf = append(buf, []byte(line+"\n")...) + if log != nil { + log(line) + } + } + } + if err != nil { + break + } + } + if len(pending) > 0 { + buf = append(buf, pending...) + if log != nil { + log(string(pending)) + } + } + return string(buf) +} diff --git a/backend/pkg/installer/deploy/tls.go b/backend/pkg/installer/deploy/tls.go new file mode 100644 index 00000000..9418f84a --- /dev/null +++ b/backend/pkg/installer/deploy/tls.go @@ -0,0 +1,69 @@ +package deploy + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "math/big" + "net" + "os" + "path/filepath" + "time" +) + +type TLSPlan struct { + Host string + CertFile string + KeyFile string +} + +func GenerateSelfSignedTLS(plan TLSPlan) error { + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + return err + } + + serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128)) + if err != nil { + return err + } + + cert := &x509.Certificate{ + SerialNumber: serial, + Subject: pkix.Name{ + CommonName: plan.Host, + }, + NotBefore: time.Now().Add(-time.Hour), + NotAfter: time.Now().AddDate(10, 0, 0), + KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + BasicConstraintsValid: true, + } + if ip := net.ParseIP(plan.Host); ip != nil { + cert.IPAddresses = []net.IP{ip} + } else { + cert.DNSNames = []string{plan.Host} + } + + der, err := x509.CreateCertificate(rand.Reader, cert, cert, &key.PublicKey, key) + if err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(plan.CertFile), 0o755); err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(plan.KeyFile), 0o755); err != nil { + return err + } + if err := os.WriteFile(plan.CertFile, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}), 0o644); err != nil { + return err + } + keyBytes, err := x509.MarshalECPrivateKey(key) + if err != nil { + return err + } + return os.WriteFile(plan.KeyFile, pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyBytes}), 0o600) +} diff --git a/backend/pkg/installer/deploy/tls_test.go b/backend/pkg/installer/deploy/tls_test.go new file mode 100644 index 00000000..464bf271 --- /dev/null +++ b/backend/pkg/installer/deploy/tls_test.go @@ -0,0 +1,59 @@ +package deploy + +import ( + "crypto/ecdsa" + "crypto/x509" + "encoding/pem" + "os" + "path/filepath" + "testing" +) + +func TestGenerateSelfSignedTLSUsesECDSAKey(t *testing.T) { + dir := t.TempDir() + certFile := filepath.Join(dir, "server.crt") + keyFile := filepath.Join(dir, "server.key") + + if err := GenerateSelfSignedTLS(TLSPlan{ + Host: "10.0.0.1", + CertFile: certFile, + KeyFile: keyFile, + }); err != nil { + t.Fatal(err) + } + + keyPEM, err := os.ReadFile(keyFile) + if err != nil { + t.Fatal(err) + } + keyBlock, _ := pem.Decode(keyPEM) + if keyBlock == nil { + t.Fatal("key PEM block not found") + } + if keyBlock.Type != "EC PRIVATE KEY" { + t.Fatalf("key PEM type = %q, want EC PRIVATE KEY", keyBlock.Type) + } + key, err := x509.ParseECPrivateKey(keyBlock.Bytes) + if err != nil { + t.Fatalf("parse EC private key: %v", err) + } + if key.Curve == nil { + t.Fatal("EC private key curve is nil") + } + + certPEM, err := os.ReadFile(certFile) + if err != nil { + t.Fatal(err) + } + certBlock, _ := pem.Decode(certPEM) + if certBlock == nil { + t.Fatal("cert PEM block not found") + } + cert, err := x509.ParseCertificate(certBlock.Bytes) + if err != nil { + t.Fatalf("parse certificate: %v", err) + } + if _, ok := cert.PublicKey.(*ecdsa.PublicKey); !ok { + t.Fatalf("cert public key type = %T, want *ecdsa.PublicKey", cert.PublicKey) + } +} diff --git a/backend/pkg/installer/logging/logger.go b/backend/pkg/installer/logging/logger.go new file mode 100644 index 00000000..ca1f28e8 --- /dev/null +++ b/backend/pkg/installer/logging/logger.go @@ -0,0 +1,29 @@ +package logging + +import ( + "fmt" + "io" + "os" + "path/filepath" + "time" +) + +type Logger struct { + file *os.File + path string +} + +func New() (*Logger, error) { + name := fmt.Sprintf("installer-%s.log", time.Now().Format("20060102-150405")) + path := filepath.Join(os.TempDir(), name) + f, err := os.Create(path) + if err != nil { + return nil, fmt.Errorf("创建日志文件失败: %w", err) + } + return &Logger{file: f, path: path}, nil +} + +func (l *Logger) Path() string { return l.path } +func (l *Logger) Plain() io.Writer { return l.file } +func (l *Logger) Sync() error { return l.file.Sync() } +func (l *Logger) Close() error { return l.file.Close() } diff --git a/backend/pkg/installer/logging/logger_test.go b/backend/pkg/installer/logging/logger_test.go new file mode 100644 index 00000000..5e554d82 --- /dev/null +++ b/backend/pkg/installer/logging/logger_test.go @@ -0,0 +1,51 @@ +package logging + +import ( + "os" + "strings" + "testing" +) + +func TestLoggerCreatesFile(t *testing.T) { + dir := t.TempDir() + t.Setenv("TMPDIR", dir) + + l, err := New() + if err != nil { + t.Fatal(err) + } + defer l.Close() + + if !strings.HasPrefix(l.Path(), dir) { + t.Fatalf("log path %q not under %q", l.Path(), dir) + } + if _, err := os.Stat(l.Path()); err != nil { + t.Fatalf("log file not created: %v", err) + } +} + +func TestLoggerPlainWritesToFile(t *testing.T) { + dir := t.TempDir() + t.Setenv("TMPDIR", dir) + + l, err := New() + if err != nil { + t.Fatal(err) + } + defer l.Close() + + if _, err := l.Plain().Write([]byte("hello\n")); err != nil { + t.Fatal(err) + } + if err := l.Sync(); err != nil { + t.Fatal(err) + } + + data, err := os.ReadFile(l.Path()) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(data), "hello") { + t.Fatalf("log file should contain 'hello', got %q", string(data)) + } +} diff --git a/backend/pkg/installer/steps/check_docker.go b/backend/pkg/installer/steps/check_docker.go new file mode 100644 index 00000000..51914415 --- /dev/null +++ b/backend/pkg/installer/steps/check_docker.go @@ -0,0 +1,39 @@ +package steps + +import ( + "context" + "fmt" + + "github.com/chaitin/MonkeyCode/backend/pkg/installer/deploy" +) + +type CheckDocker struct{} + +func (s *CheckDocker) Name() string { return "环境检测" } + +func (s *CheckDocker) Run(c *Context) error { + c.Reporter.SetStep("环境检测...", "下一步: 安装 Docker") + status := deploy.CheckDockerStatus(context.Background(), c.Runner) + c.DockerStatus = status + + logCheck(c, "docker", status.DockerInstalled, status.DockerVersion) + logCheck(c, "docker compose", status.ComposeInstalled, status.ComposeVersion) + logCheck(c, "docker daemon", status.DaemonRunning, status.DaemonVersion) + + if status.DockerInstalled && !status.ComposeInstalled { + return fmt.Errorf("docker compose 缺失,需要 v2 plugin") + } + return nil +} + +func logCheck(c *Context, label string, ok bool, version string) { + if ok { + if version != "" { + c.Log("✓ %-15s %s", label, version) + } else { + c.Log("✓ %-15s ok", label) + } + return + } + c.Log("✗ %-15s 未安装/未运行", label) +} diff --git a/backend/pkg/installer/steps/check_docker_test.go b/backend/pkg/installer/steps/check_docker_test.go new file mode 100644 index 00000000..72be5c81 --- /dev/null +++ b/backend/pkg/installer/steps/check_docker_test.go @@ -0,0 +1,63 @@ +package steps + +import ( + "errors" + "strings" + "testing" + + "github.com/chaitin/MonkeyCode/backend/pkg/installer/deploy" +) + +func ctxWithFakeReporter() (*Context, *fakeReporter, *deploy.FakeRunner) { + r := &fakeReporter{} + runner := &deploy.FakeRunner{} + return &Context{Runner: runner, Reporter: r}, r, runner +} + +func TestCheckDockerAllPass(t *testing.T) { + ctx, _, runner := ctxWithFakeReporter() + runner.OnRun = func(name string, args ...string) deploy.RunResult { + switch { + case args[0] == "--version": + return deploy.RunResult{Stdout: "Docker version 24.0.5, build abc"} + case args[0] == "compose": + return deploy.RunResult{Stdout: "Docker Compose version v2.20.0"} + case args[0] == "info": + return deploy.RunResult{Stdout: "24.0.5"} + } + return deploy.RunResult{} + } + if err := (&CheckDocker{}).Run(ctx); err != nil { + t.Fatalf("expect pass, got %v", err) + } + if !ctx.DockerStatus.Ready() { + t.Fatal("status should be ready") + } +} + +func TestCheckDockerNotInstalled(t *testing.T) { + ctx, _, runner := ctxWithFakeReporter() + runner.OnRun = func(name string, args ...string) deploy.RunResult { + return deploy.RunResult{Err: errors.New("not found")} + } + if err := (&CheckDocker{}).Run(ctx); err != nil { + t.Fatalf("missing docker should not be fatal: %v", err) + } + if ctx.DockerStatus.Ready() { + t.Fatal("should not be ready") + } +} + +func TestCheckDockerComposeMissing(t *testing.T) { + ctx, _, runner := ctxWithFakeReporter() + runner.OnRun = func(name string, args ...string) deploy.RunResult { + if args[0] == "compose" { + return deploy.RunResult{Err: errors.New("not found")} + } + return deploy.RunResult{Stdout: "ok"} + } + err := (&CheckDocker{}).Run(ctx) + if err == nil || !strings.Contains(err.Error(), "compose") { + t.Fatalf("expect compose missing error, got %v", err) + } +} diff --git a/backend/pkg/installer/steps/fake_reporter_test.go b/backend/pkg/installer/steps/fake_reporter_test.go new file mode 100644 index 00000000..2654ee3f --- /dev/null +++ b/backend/pkg/installer/steps/fake_reporter_test.go @@ -0,0 +1,85 @@ +package steps + +import "fmt" + +type fakeReporter struct { + Logs []string + StepCalls []string + + InputAns []string + FormAns [][]string + MenuAns []string + ConfirmAns []bool + + inputIdx, formIdx, menuIdx, confirmIdx int +} + +func (r *fakeReporter) Log(format string, args ...any) { + r.Logs = append(r.Logs, fmt.Sprintf(format, args...)) +} + +func (r *fakeReporter) LogScreen(format string, args ...any) { + r.Logs = append(r.Logs, fmt.Sprintf(format, args...)) +} + +func (r *fakeReporter) LogFile(format string, args ...any) {} + +func (r *fakeReporter) SetStep(title, hint string) { + r.StepCalls = append(r.StepCalls, title) +} + +func (r *fakeReporter) StartProgress(label string) {} +func (r *fakeReporter) UpdateProgress(downloaded, total int64) {} +func (r *fakeReporter) EndProgress() {} + +func (r *fakeReporter) AskInput(label, def string, password bool, v Validator) (string, error) { + if r.inputIdx >= len(r.InputAns) { + return def, nil + } + a := r.InputAns[r.inputIdx] + r.inputIdx++ + if v != nil { + if err := v(a); err != nil { + return "", err + } + } + return a, nil +} + +func (r *fakeReporter) AskForm(fields []FormField) ([]string, error) { + if r.formIdx >= len(r.FormAns) { + out := make([]string, len(fields)) + for i, f := range fields { + out[i] = f.Default + } + return out, nil + } + a := r.FormAns[r.formIdx] + r.formIdx++ + for i, f := range fields { + if f.Validate != nil && i < len(a) { + if err := f.Validate(a[i]); err != nil { + return nil, err + } + } + } + return a, nil +} + +func (r *fakeReporter) AskMenu(title string, opts []MenuOption) (string, error) { + if r.menuIdx >= len(r.MenuAns) { + return opts[0].Value, nil + } + a := r.MenuAns[r.menuIdx] + r.menuIdx++ + return a, nil +} + +func (r *fakeReporter) AskConfirm(prompt string) (bool, error) { + if r.confirmIdx >= len(r.ConfirmAns) { + return false, nil + } + a := r.ConfirmAns[r.confirmIdx] + r.confirmIdx++ + return a, nil +} diff --git a/backend/pkg/installer/steps/form.go b/backend/pkg/installer/steps/form.go new file mode 100644 index 00000000..ec1e1dcc --- /dev/null +++ b/backend/pkg/installer/steps/form.go @@ -0,0 +1,45 @@ +package steps + +import ( + "strings" +) + +type ServiceForm struct{} + +func (s *ServiceForm) Name() string { return "收集安装参数" } + +func (s *ServiceForm) Run(c *Context) error { + c.Reporter.SetStep("收集安装参数...", "下一步: 服务端安装") + + values, err := c.Reporter.AskForm([]FormField{ + {Label: "安装目录", Default: "/data/monkeycode-ai", Help: "请输入绝对路径", Validate: validateAbsPath}, + {Label: "访问地址", Help: "请输入用户和宿主机能访问到的 IP 或域名,不含协议和端口", Validate: validateAccessHost}, + {Label: "访问端口", Default: "80", Validate: validatePort}, + {Label: "管理员邮箱", Validate: validateEmail}, + {Label: "团队名称", Default: "MonkeyCode"}, + {Label: "管理员密码", Password: true, Help: "留空时自动生成随机密码"}, + }) + if err != nil { + return err + } + + c.Input.InstallDir = strings.TrimSpace(values[0]) + c.Input.AccessHost = strings.TrimSpace(values[1]) + c.Input.NginxPort = strings.TrimSpace(values[2]) + c.Input.TeamEmail = strings.TrimSpace(values[3]) + c.Input.TeamName = strings.TrimSpace(values[4]) + c.Input.TeamPassword = strings.TrimSpace(values[5]) + + c.Log("安装目录 %s", c.Input.InstallDir) + c.Log("访问地址 %s", c.Input.AccessHost) + c.Log("Nginx 端口 %s", c.Input.NginxPort) + c.Log("管理员 %s", c.Input.TeamEmail) + c.Log("团队名称 %s", c.Input.TeamName) + if c.Input.TeamPassword == "" { + c.Log("管理员密码 (未提供,安装时自动生成)") + } else { + c.LogScreen("管理员密码 %s", c.Input.TeamPassword) + c.LogFile("管理员密码 ********") + } + return nil +} diff --git a/backend/pkg/installer/steps/form_test.go b/backend/pkg/installer/steps/form_test.go new file mode 100644 index 00000000..55239372 --- /dev/null +++ b/backend/pkg/installer/steps/form_test.go @@ -0,0 +1,108 @@ +package steps + +import ( + "testing" +) + +func TestServiceFormPopulatesInput(t *testing.T) { + ctx, r, _ := ctxWithFakeReporter() + r.FormAns = [][]string{{ + "/data/myapp", + "192.168.1.10", + "8080", + "admin@example.com", + "MyTeam", + "secret123", + }} + + if err := (&ServiceForm{}).Run(ctx); err != nil { + t.Fatal(err) + } + if ctx.Input.InstallDir != "/data/myapp" { + t.Fatalf("InstallDir = %q", ctx.Input.InstallDir) + } + if ctx.Input.AccessHost != "192.168.1.10" { + t.Fatalf("AccessHost = %q", ctx.Input.AccessHost) + } + if ctx.Input.NginxPort != "8080" { + t.Fatalf("NginxPort = %q", ctx.Input.NginxPort) + } + if ctx.Input.TeamEmail != "admin@example.com" { + t.Fatalf("TeamEmail = %q", ctx.Input.TeamEmail) + } + if ctx.Input.TeamName != "MyTeam" { + t.Fatalf("TeamName = %q", ctx.Input.TeamName) + } + if ctx.Input.TeamPassword != "secret123" { + t.Fatalf("TeamPassword = %q", ctx.Input.TeamPassword) + } +} + +func TestServiceFormEmptyPasswordOK(t *testing.T) { + ctx, r, _ := ctxWithFakeReporter() + r.FormAns = [][]string{{ + "/data/myapp", + "192.168.1.10", + "80", + "admin@example.com", + "MyTeam", + "", + }} + if err := (&ServiceForm{}).Run(ctx); err != nil { + t.Fatal(err) + } + if ctx.Input.TeamPassword != "" { + t.Fatal("TeamPassword should remain empty when not provided") + } +} + +func TestServiceFormRejectsInvalidPort(t *testing.T) { + ctx, r, _ := ctxWithFakeReporter() + r.FormAns = [][]string{{ + "/data/myapp", + "192.168.1.10", + "70000", + "admin@example.com", + "MyTeam", + "", + }} + + err := (&ServiceForm{}).Run(ctx) + if err == nil { + t.Fatal("expected invalid port error") + } +} + +func TestServiceFormRejectsInvalidEmail(t *testing.T) { + ctx, r, _ := ctxWithFakeReporter() + r.FormAns = [][]string{{ + "/data/myapp", + "192.168.1.10", + "8080", + "admin@", + "MyTeam", + "", + }} + + err := (&ServiceForm{}).Run(ctx) + if err == nil { + t.Fatal("expected invalid email error") + } +} + +func TestServiceFormRejectsURLAccessHost(t *testing.T) { + ctx, r, _ := ctxWithFakeReporter() + r.FormAns = [][]string{{ + "/data/myapp", + "http://example.com", + "8080", + "admin@example.com", + "MyTeam", + "", + }} + + err := (&ServiceForm{}).Run(ctx) + if err == nil { + t.Fatal("expected invalid access host error") + } +} diff --git a/backend/pkg/installer/steps/helpers.go b/backend/pkg/installer/steps/helpers.go new file mode 100644 index 00000000..8070389d --- /dev/null +++ b/backend/pkg/installer/steps/helpers.go @@ -0,0 +1,74 @@ +package steps + +import ( + "context" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/chaitin/MonkeyCode/backend/pkg/installer/deploy" +) + +var packageDir = func() (string, error) { + exe, err := os.Executable() + if err != nil { + return "", err + } + return filepath.Dir(exe), nil +} + +func locateBundleFile(name string) (string, error) { + dir, err := packageDir() + if err != nil { + return "", err + } + bundle := filepath.Join(dir, name) + if _, err := os.Stat(bundle); err != nil { + if errors.Is(err, os.ErrNotExist) { + return "", fmt.Errorf("找不到离线包: %s", bundle) + } + return "", err + } + return bundle, nil +} + +func wrapRunner(r deploy.Runner, rep Reporter, prefix string) deploy.Runner { + log := func(line string) { rep.Log("%s%s", prefix, line) } + return loggingRunnerAdapter{inner: r, log: log} +} + +type loggingRunnerAdapter struct { + inner deploy.Runner + log deploy.LogFunc +} + +func (a loggingRunnerAdapter) Run(ctx context.Context, name string, args ...string) deploy.RunResult { + if a.log != nil { + a.log("$ " + name + " " + strings.Join(args, " ")) + } + res := a.inner.Run(ctx, name, args...) + emitMultiline(a.log, res.Stdout) + emitMultiline(a.log, res.Stderr) + return res +} + +func (a loggingRunnerAdapter) RunShell(ctx context.Context, script string) deploy.RunResult { + if a.log != nil { + a.log("$ sh -c " + script) + } + res := a.inner.RunShell(ctx, script) + emitMultiline(a.log, res.Stdout) + emitMultiline(a.log, res.Stderr) + return res +} + +func emitMultiline(log deploy.LogFunc, s string) { + if log == nil || s == "" { + return + } + for _, line := range strings.Split(strings.TrimRight(s, "\n"), "\n") { + log(line) + } +} diff --git a/backend/pkg/installer/steps/host.go b/backend/pkg/installer/steps/host.go new file mode 100644 index 00000000..4019a0d8 --- /dev/null +++ b/backend/pkg/installer/steps/host.go @@ -0,0 +1,194 @@ +package steps + +import ( + "context" + "fmt" + "os" + "path/filepath" + + "github.com/chaitin/MonkeyCode/backend/pkg/installer/deploy" +) + +const ( + HostDefaultInstallDir = "/data/monkeycode_runner" + + hostDockerInstallWorkDir = "/tmp/monkeycode_runner" + hostDockerBundleFile = "/tmp/monkeycode_runner/docker.tgz" + hostBundleFile = "/tmp/monkeycode-host.tgz" + + envBaseURL = "MCAI_BASE_URL" + envHostBundlePath = "MCAI_HOST_BUNDLE_PATH" + envDockerBundlePath = "MCAI_DOCKER_BUNDLE_PATH" + envHostToken = "MCAI_HOST_TOKEN" + envTaskflowGRPC = "MCAI_TASKFLOW_GRPC_URL" +) + +// HostInstall:宿主机模式安装 +type HostInstall struct{} + +func (h *HostInstall) Name() string { return "宿主机安装" } + +func (h *HostInstall) Run(c *Context) error { + bg := context.Background() + logRunner := wrapRunner(c.Runner, c.Reporter, " ") + + cfg := loadHostConfig() + + if !c.DockerStatus.Ready() { + c.Reporter.SetStep("下载并安装 Docker...", "下一步: 收集安装参数") + dockerURL, err := cfg.dockerBundleURL() + if err != nil { + return fmt.Errorf("解析 Docker 包地址失败: %w", err) + } + c.Log("下载地址: %s", dockerURL) + plan := deploy.DockerInstallPlan{ + WorkDir: hostDockerInstallWorkDir, + BundleFile: hostDockerBundleFile, + BundleURL: dockerURL, + } + c.Reporter.StartProgress(filepath.Base(hostDockerBundleFile)) + err = deploy.InstallDockerWithProgress(bg, logRunner, plan, hostProgress(c.Reporter)) + c.Reporter.EndProgress() + if err != nil { + return fmt.Errorf("安装 Docker 失败: %w", err) + } + status := deploy.CheckDockerStatus(bg, c.Runner) + c.DockerStatus = status + if !status.Ready() { + return fmt.Errorf("Docker 环境仍未就绪") + } + c.Log("✓ Docker 安装完成") + } else { + c.Log("⊘ Docker 环境完整,跳过此步骤") + } + + c.Reporter.SetStep("收集安装参数...", "下一步: 下载安装包") + values, err := c.Reporter.AskForm([]FormField{ + {Label: "安装目录", Default: HostDefaultInstallDir, Help: "宿主机服务文件、镜像包和 docker-compose.yml 将放在该目录", Validate: validateAbsPath}, + }) + if err != nil { + return err + } + workDir := values[0] + c.Log("安装目录: %s", workDir) + + c.Reporter.SetStep("下载宿主机安装包...", "下一步: 加载镜像") + hostURL, err := cfg.hostBundleURL() + if err != nil { + return fmt.Errorf("解析宿主机包地址失败: %w", err) + } + c.Log("下载地址: %s", hostURL) + bundlePlan := deploy.HostBundlePlan{ + BundleURL: hostURL, + BundleFile: hostBundleFile, + WorkDir: workDir, + } + c.Reporter.StartProgress(filepath.Base(hostBundleFile)) + err = deploy.PrepareHostBundleWithProgress(bg, logRunner, bundlePlan, hostProgress(c.Reporter)) + c.Reporter.EndProgress() + if err != nil { + return fmt.Errorf("准备宿主机安装包失败: %w", err) + } + c.Log("✓ 安装包已就绪") + + c.Reporter.SetStep("启动宿主机服务...", "下一步: 完成") + images, err := deploy.ScanImages(filepath.Join(workDir, "images")) + if err != nil { + return fmt.Errorf("扫描镜像失败: %w", err) + } + c.Log("发现 %d 个镜像归档", len(images)) + + plan := deploy.HostInstallPlan{ + WorkDir: workDir, + ComposeFile: filepath.Join(workDir, "docker-compose.yml"), + EnvFile: filepath.Join(workDir, ".env"), + Token: os.Getenv(envHostToken), + GrpcURL: os.Getenv(envTaskflowGRPC), + Images: images, + } + if err := deploy.InstallHost(bg, logRunner, plan); err != nil { + return fmt.Errorf("启动宿主机服务失败: %w", err) + } + c.Log("✓ 服务已启动") + + c.Input.InstallDir = workDir + return nil +} + +// HostUninstall:宿主机模式卸载 +type HostUninstall struct{} + +func (h *HostUninstall) Name() string { return "宿主机卸载" } + +func (h *HostUninstall) Run(c *Context) error { + bg := context.Background() + logRunner := wrapRunner(c.Runner, c.Reporter, " ") + + c.Reporter.SetStep("收集卸载参数...", "下一步: 卸载服务") + values, err := c.Reporter.AskForm([]FormField{ + {Label: "安装目录", Default: HostDefaultInstallDir, Help: "将停止该目录下的服务并删除目录", Validate: validateAbsPath}, + }) + if err != nil { + return err + } + workDir := values[0] + + ok, err := c.Reporter.AskConfirm(fmt.Sprintf("确认卸载 %s ?", workDir)) + if err != nil { + return err + } + if !ok { + return fmt.Errorf("已取消卸载") + } + + c.Reporter.SetStep("卸载宿主机服务...", "下一步: 完成") + plan := deploy.HostInstallPlan{ + WorkDir: workDir, + ComposeFile: filepath.Join(workDir, "docker-compose.yml"), + EnvFile: filepath.Join(workDir, ".env"), + } + if err := deploy.UninstallHost(bg, logRunner, plan); err != nil { + return fmt.Errorf("卸载失败: %w", err) + } + c.Log("✓ 已卸载") + + return nil +} + +type hostConfig struct { + BaseURL string + DockerBundlePath string + HostBundlePath string +} + +func loadHostConfig() hostConfig { + arch := deploy.InstallerArch() + return hostConfig{ + BaseURL: firstNonEmpty(os.Getenv(envBaseURL), "http://localhost"), + DockerBundlePath: firstNonEmpty(os.Getenv(envDockerBundlePath), "/static/installer/"+arch+"/docker.tgz"), + HostBundlePath: firstNonEmpty(os.Getenv(envHostBundlePath), "/static/installer/"+arch+"/host.tgz"), + } +} + +func (c hostConfig) dockerBundleURL() (string, error) { + return deploy.BundleURL(c.BaseURL, c.DockerBundlePath) +} + +func (c hostConfig) hostBundleURL() (string, error) { + return deploy.BundleURL(c.BaseURL, c.HostBundlePath) +} + +func hostProgress(r Reporter) deploy.ProgressFunc { + return func(p deploy.DownloadProgress) { + r.UpdateProgress(p.Downloaded, p.Total) + } +} + +func firstNonEmpty(values ...string) string { + for _, v := range values { + if v != "" { + return v + } + } + return "" +} diff --git a/backend/pkg/installer/steps/install_docker.go b/backend/pkg/installer/steps/install_docker.go new file mode 100644 index 00000000..c1ed87ea --- /dev/null +++ b/backend/pkg/installer/steps/install_docker.go @@ -0,0 +1,48 @@ +package steps + +import ( + "context" + "fmt" + + "github.com/chaitin/MonkeyCode/backend/pkg/installer/deploy" +) + +const dockerBundleName = "docker.tgz" +const dockerInstallWorkDir = "/tmp/monkeycode-installer" + +type InstallDocker struct{} + +func (s *InstallDocker) Name() string { return "安装 Docker" } + +func (s *InstallDocker) Run(c *Context) error { + if c.DockerStatus.Ready() { + c.Log("⊘ Docker 环境完整,跳过此步骤") + return nil + } + + c.Reporter.SetStep("安装 Docker...", "下一步: 收集安装参数") + bundle, err := locateBundleFile(dockerBundleName) + if err != nil { + return err + } + c.Log("离线包: %s", bundle) + c.Log("Docker 环境不完整,使用离线包安装") + + plan := deploy.DockerInstallPlan{ + WorkDir: dockerInstallWorkDir, + BundleFile: bundle, + } + + logRunner := wrapRunner(c.Runner, c.Reporter, " ") + if err := deploy.InstallDockerFromLocalBundle(context.Background(), logRunner, plan); err != nil { + return fmt.Errorf("安装 Docker 失败: %w", err) + } + + status := deploy.CheckDockerStatus(context.Background(), c.Runner) + c.DockerStatus = status + if !status.Ready() { + return fmt.Errorf("安装脚本执行完成但 Docker 仍不可用") + } + c.Log("✓ Docker 安装完成") + return nil +} diff --git a/backend/pkg/installer/steps/install_docker_test.go b/backend/pkg/installer/steps/install_docker_test.go new file mode 100644 index 00000000..1f52f913 --- /dev/null +++ b/backend/pkg/installer/steps/install_docker_test.go @@ -0,0 +1,34 @@ +package steps + +import ( + "strings" + "testing" +) + +func mockPackageDir(t *testing.T) string { + t.Helper() + dir := t.TempDir() + old := packageDir + packageDir = func() (string, error) { return dir, nil } + t.Cleanup(func() { packageDir = old }) + return dir +} + +func TestInstallDockerSkipsWhenReady(t *testing.T) { + ctx, _, _ := ctxWithFakeReporter() + ctx.DockerStatus.DockerInstalled = true + ctx.DockerStatus.ComposeInstalled = true + ctx.DockerStatus.DaemonRunning = true + if err := (&InstallDocker{}).Run(ctx); err != nil { + t.Fatal(err) + } +} + +func TestInstallDockerMissingBundle(t *testing.T) { + mockPackageDir(t) + ctx, _, _ := ctxWithFakeReporter() + err := (&InstallDocker{}).Run(ctx) + if err == nil || !strings.Contains(err.Error(), "找不到离线包") { + t.Fatalf("expect missing bundle error, got %v", err) + } +} diff --git a/backend/pkg/installer/steps/install_svc.go b/backend/pkg/installer/steps/install_svc.go new file mode 100644 index 00000000..ce373a6e --- /dev/null +++ b/backend/pkg/installer/steps/install_svc.go @@ -0,0 +1,97 @@ +package steps + +import ( + "context" + "fmt" + "os" + "path/filepath" + + "github.com/chaitin/MonkeyCode/backend/pkg/installer/deploy" +) + +type InstallService struct{} + +func (s *InstallService) Name() string { return "服务端安装" } + +func (s *InstallService) Run(c *Context) error { + bg := context.Background() + + c.Reporter.SetStep("生成服务端环境配置...", "下一步: 复制安装文件") + env, err := deploy.NewCenterEnv(c.Input) + if err != nil { + return fmt.Errorf("生成环境配置失败: %w", err) + } + c.Log("✓ 环境变量已生成") + + c.Reporter.SetStep("复制安装文件...", "下一步: 生成 TLS 证书") + pkgDir, err := packageDir() + if err != nil { + return err + } + if err := assertCenterPackage(pkgDir); err != nil { + return err + } + plan, err := deploy.PrepareCenterFiles(env.InstallDir, pkgDir, env) + if err != nil { + return err + } + c.Log("✓ 配置已生成 %s", plan.ComposeFile) + + c.Reporter.SetStep("生成 TLS 证书...", "下一步: 准备数据目录") + plan.TLS = deploy.TLSPlan{ + Host: c.Input.AccessHost, + CertFile: filepath.Join(env.InstallDir, "tls", "server.crt"), + KeyFile: filepath.Join(env.InstallDir, "tls", "server.key"), + } + if err := deploy.GenerateSelfSignedTLS(plan.TLS); err != nil { + return fmt.Errorf("生成 TLS 证书失败: %w", err) + } + c.Log("✓ TLS 证书已生成 %s", plan.TLS.CertFile) + + logRunner := wrapRunner(c.Runner, c.Reporter, " ") + + c.Reporter.SetStep("准备数据目录...", "下一步: 加载镜像") + if err := deploy.PrepareCenterDirs(bg, logRunner, env.InstallDir); err != nil { + return fmt.Errorf("准备数据目录失败: %w", err) + } + c.Log("✓ 数据目录已准备") + + c.Reporter.SetStep("加载离线镜像...", "下一步: 启动服务") + images, err := deploy.ScanImages(filepath.Join(env.InstallDir, "images")) + if err != nil { + return fmt.Errorf("扫描镜像失败: %w", err) + } + if len(images) == 0 { + c.Log("⊘ 未发现离线镜像") + } else { + c.Log("发现 %d 个镜像归档", len(images)) + if err := deploy.LoadImages(bg, logRunner, images); err != nil { + return fmt.Errorf("加载镜像失败: %w", err) + } + c.Log("✓ 所有镜像已加载") + } + plan.Images = images + + c.Reporter.SetStep("启动服务...", "下一步: 完成") + if err := deploy.InstallCenter(bg, logRunner, plan); err != nil { + return fmt.Errorf("启动服务失败: %w", err) + } + c.Log("✓ 服务已启动") + + c.Result = deploy.InstallResult{ + URL: deploy.CenterAccessURL(c.Input.AccessHost, env.NginxPort), + AdminEmail: env.TeamEmail, + AdminPassword: env.TeamPassword, + } + return nil +} + +func assertCenterPackage(pkgDir string) error { + must := []string{"docker-compose.yml", ".env.example"} + for _, name := range must { + if _, err := os.Stat(filepath.Join(pkgDir, name)); err != nil { + return fmt.Errorf("找不到安装包文件 %s/%s: %w", pkgDir, name, err) + } + } + return nil +} diff --git a/backend/pkg/installer/steps/install_svc_test.go b/backend/pkg/installer/steps/install_svc_test.go new file mode 100644 index 00000000..5d6a7e3e --- /dev/null +++ b/backend/pkg/installer/steps/install_svc_test.go @@ -0,0 +1,87 @@ +package steps + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +const fakeCompose = `version: "3.8" +services: + app: + image: example/app:latest +` + +const fakeEnv = `INSTALL_DIR=/data/monkeycode-ai +REMOTE_IP= +NGINX_PORT=80 +POSTGRES_PASSWORD= +REDIS_PASSWORD= +CLICKHOUSE_PASSWORD= +RUSTFS_ACCESS_KEY= +RUSTFS_SECRET_KEY= +TEAM_EMAIL= +TEAM_NAME=MonkeyCode +TEAM_PASSWORD= +INIT_TEAM_IMAGE= +SUBNET_PREFIX=10.100.50 +` + +func writeFakePackage(t *testing.T) string { + t.Helper() + dir := mockPackageDir(t) + if err := os.WriteFile(filepath.Join(dir, "docker-compose.yml"), []byte(fakeCompose), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, ".env.example"), []byte(fakeEnv), 0o644); err != nil { + t.Fatal(err) + } + return dir +} + +func TestInstallServiceWritesFiles(t *testing.T) { + writeFakePackage(t) + dir := t.TempDir() + ctx, _, _ := ctxWithFakeReporter() + ctx.Input.InstallDir = dir + ctx.Input.AccessHost = "10.0.0.1" + ctx.Input.NginxPort = "8080" + ctx.Input.TeamEmail = "admin@example.com" + ctx.Input.TeamName = "MyTeam" + + if err := (&InstallService{}).Run(ctx); err != nil { + t.Fatal(err) + } + if ctx.Result.URL != "http://10.0.0.1:8080" { + t.Fatalf("URL = %q", ctx.Result.URL) + } + if ctx.Result.AdminEmail != "admin@example.com" { + t.Fatalf("AdminEmail = %q", ctx.Result.AdminEmail) + } + if ctx.Result.AdminPassword == "" { + t.Fatal("AdminPassword should be auto-generated") + } + if _, err := os.Stat(filepath.Join(dir, "tls", "server.crt")); err != nil { + t.Fatalf("TLS cert not generated: %v", err) + } + if _, err := os.Stat(filepath.Join(dir, ".env")); err != nil { + t.Fatalf(".env not written: %v", err) + } +} + +func TestInstallServiceUsesDefaultPort80(t *testing.T) { + writeFakePackage(t) + dir := t.TempDir() + ctx, _, _ := ctxWithFakeReporter() + ctx.Input.InstallDir = dir + ctx.Input.AccessHost = "example.com" + ctx.Input.NginxPort = "80" + ctx.Input.TeamEmail = "a@b.com" + if err := (&InstallService{}).Run(ctx); err != nil { + t.Fatal(err) + } + if !strings.HasPrefix(ctx.Result.URL, "http://example.com") || strings.Contains(ctx.Result.URL, ":80") { + t.Fatalf("URL should not include :80 suffix, got %q", ctx.Result.URL) + } +} diff --git a/backend/pkg/installer/steps/progress_test.go b/backend/pkg/installer/steps/progress_test.go new file mode 100644 index 00000000..a37251c5 --- /dev/null +++ b/backend/pkg/installer/steps/progress_test.go @@ -0,0 +1,33 @@ +package steps + +import ( + "strings" + "testing" +) + +func TestContextLogAddsStepProgressPrefix(t *testing.T) { + ctx, r, _ := ctxWithFakeReporter() + ctx.Progress = Progress{Current: 2, Total: 4} + + ctx.Log("安装目录 %s", "/data/myapp") + + if len(r.Logs) != 1 { + t.Fatalf("Logs len = %d", len(r.Logs)) + } + if !strings.HasPrefix(r.Logs[0], "[2/4] 安装目录 /data/myapp") { + t.Fatalf("unexpected log: %q", r.Logs[0]) + } +} + +func TestContextLogSkipsPrefixWithoutProgress(t *testing.T) { + ctx, r, _ := ctxWithFakeReporter() + + ctx.Log("安装目录 %s", "/data/myapp") + + if len(r.Logs) != 1 { + t.Fatalf("Logs len = %d", len(r.Logs)) + } + if r.Logs[0] != "安装目录 /data/myapp" { + t.Fatalf("unexpected log: %q", r.Logs[0]) + } +} diff --git a/backend/pkg/installer/steps/reporter_writer.go b/backend/pkg/installer/steps/reporter_writer.go new file mode 100644 index 00000000..2236146a --- /dev/null +++ b/backend/pkg/installer/steps/reporter_writer.go @@ -0,0 +1,24 @@ +package steps + +import "bytes" + +type reporterWriter struct { + r Reporter + prefix string + buf bytes.Buffer +} + +func (w *reporterWriter) Write(p []byte) (int, error) { + w.buf.Write(p) + for { + raw := w.buf.Bytes() + idx := bytes.IndexByte(raw, '\n') + if idx < 0 { + break + } + line := string(raw[:idx]) + w.buf.Next(idx + 1) + w.r.Log("%s%s", w.prefix, line) + } + return len(p), nil +} diff --git a/backend/pkg/installer/steps/step.go b/backend/pkg/installer/steps/step.go new file mode 100644 index 00000000..0d854066 --- /dev/null +++ b/backend/pkg/installer/steps/step.go @@ -0,0 +1,84 @@ +package steps + +import ( + "fmt" + + "github.com/chaitin/MonkeyCode/backend/pkg/installer/deploy" +) + +type Validator func(string) error + +type MenuOption struct { + Label string + Value string +} + +type FormField struct { + Label string + Default string + Password bool + Help string + Validate Validator +} + +type Reporter interface { + Log(format string, args ...any) + LogScreen(format string, args ...any) + LogFile(format string, args ...any) + SetStep(title, nextHint string) + StartProgress(label string) + UpdateProgress(downloaded, total int64) + EndProgress() + AskInput(label, defaultVal string, password bool, validate Validator) (string, error) + AskForm(fields []FormField) ([]string, error) + AskMenu(title string, options []MenuOption) (string, error) + AskConfirm(prompt string) (bool, error) +} + +type Context struct { + Runner deploy.Runner + Reporter Reporter + LogPath string + Progress Progress + DockerStatus deploy.DockerStatus + Input deploy.CenterEnvInput + Result deploy.InstallResult +} + +type Progress struct { + Current int + Total int +} + +func (p Progress) Prefix() string { + if p.Current <= 0 || p.Total <= 0 { + return "" + } + return fmt.Sprintf("[%d/%d]", p.Current, p.Total) +} + +func (c *Context) Log(format string, args ...any) { + c.logWith(c.Reporter.Log, format, args...) +} + +func (c *Context) LogScreen(format string, args ...any) { + c.logWith(c.Reporter.LogScreen, format, args...) +} + +func (c *Context) LogFile(format string, args ...any) { + c.logWith(c.Reporter.LogFile, format, args...) +} + +func (c *Context) logWith(log func(string, ...any), format string, args ...any) { + prefix := c.Progress.Prefix() + if prefix == "" { + log(format, args...) + return + } + log(prefix+" "+format, args...) +} + +type Step interface { + Name() string + Run(ctx *Context) error +} diff --git a/backend/pkg/installer/steps/validators.go b/backend/pkg/installer/steps/validators.go new file mode 100644 index 00000000..7773bd33 --- /dev/null +++ b/backend/pkg/installer/steps/validators.go @@ -0,0 +1,98 @@ +package steps + +import ( + "errors" + "fmt" + "net" + "net/mail" + "path/filepath" + "strconv" + "strings" +) + +func validateAbsPath(v string) error { + v = strings.TrimSpace(v) + if v == "" { + return fmt.Errorf("请输入路径") + } + if !filepath.IsAbs(v) { + return fmt.Errorf("请输入绝对路径") + } + return nil +} + +func validateAccessHost(v string) error { + v = strings.TrimSpace(v) + if v == "" { + return fmt.Errorf("请输入服务端访问地址") + } + if net.ParseIP(v) != nil { + return nil + } + if strings.Contains(v, "://") || strings.ContainsAny(v, "/?#@") || strings.ContainsAny(v, " \t\r\n") { + return fmt.Errorf("请输入 IP 或域名,不要包含协议、路径或端口") + } + if strings.Contains(v, ":") { + return fmt.Errorf("请输入 IP 或域名,端口请填写到访问端口") + } + if isDomain(v) { + return nil + } + return fmt.Errorf("请输入有效 IP 或域名") +} + +func validatePort(v string) error { + v = strings.TrimSpace(v) + if v == "" { + return fmt.Errorf("请输入端口") + } + p, err := strconv.Atoi(v) + if err != nil || p < 1 || p > 65535 { + return fmt.Errorf("请输入 1-65535 之间的端口") + } + return nil +} + +func validateEmail(v string) error { + v = strings.TrimSpace(v) + if v == "" { + return fmt.Errorf("请输入管理员邮箱") + } + addr, err := mail.ParseAddress(v) + if err != nil || addr.Address != v { + return fmt.Errorf("请输入有效邮箱地址") + } + return nil +} + +func required(msg string) Validator { + err := errors.New(msg) + return func(v string) error { + if strings.TrimSpace(v) == "" { + return err + } + return nil + } +} + +func isDomain(v string) bool { + if len(v) > 253 || strings.HasPrefix(v, ".") || strings.HasSuffix(v, ".") { + return false + } + parts := strings.Split(v, ".") + if len(parts) < 2 { + return false + } + for _, part := range parts { + if part == "" || len(part) > 63 || strings.HasPrefix(part, "-") || strings.HasSuffix(part, "-") { + return false + } + for _, r := range part { + if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '-' { + continue + } + return false + } + } + return true +} diff --git a/backend/pkg/installer/steps/validators_test.go b/backend/pkg/installer/steps/validators_test.go new file mode 100644 index 00000000..9030a253 --- /dev/null +++ b/backend/pkg/installer/steps/validators_test.go @@ -0,0 +1,75 @@ +package steps + +import "testing" + +func TestValidateAccessHost(t *testing.T) { + tests := []struct { + name string + value string + wantErr bool + }{ + {name: "ipv4", value: "192.168.1.10"}, + {name: "ipv6", value: "2001:db8::1"}, + {name: "domain", value: "monkeycode.example.com"}, + {name: "empty", value: "", wantErr: true}, + {name: "url", value: "http://monkeycode.example.com", wantErr: true}, + {name: "host port", value: "monkeycode.example.com:8080", wantErr: true}, + {name: "path", value: "monkeycode.example.com/app", wantErr: true}, + {name: "single label", value: "monkeycode", wantErr: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validateAccessHost(tt.value) + if (err != nil) != tt.wantErr { + t.Fatalf("validateAccessHost(%q) err = %v, wantErr %v", tt.value, err, tt.wantErr) + } + }) + } +} + +func TestValidatePort(t *testing.T) { + tests := []struct { + name string + value string + wantErr bool + }{ + {name: "min", value: "1"}, + {name: "default", value: "80"}, + {name: "max", value: "65535"}, + {name: "zero", value: "0", wantErr: true}, + {name: "too large", value: "65536", wantErr: true}, + {name: "not number", value: "http", wantErr: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validatePort(tt.value) + if (err != nil) != tt.wantErr { + t.Fatalf("validatePort(%q) err = %v, wantErr %v", tt.value, err, tt.wantErr) + } + }) + } +} + +func TestValidateEmail(t *testing.T) { + tests := []struct { + name string + value string + wantErr bool + }{ + {name: "valid", value: "admin@example.com"}, + {name: "display name", value: "Admin ", wantErr: true}, + {name: "missing domain", value: "admin@", wantErr: true}, + {name: "empty", value: "", wantErr: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validateEmail(tt.value) + if (err != nil) != tt.wantErr { + t.Fatalf("validateEmail(%q) err = %v, wantErr %v", tt.value, err, tt.wantErr) + } + }) + } +} diff --git a/backend/pkg/installer/tui/components_test.go b/backend/pkg/installer/tui/components_test.go new file mode 100644 index 00000000..9fb68a0a --- /dev/null +++ b/backend/pkg/installer/tui/components_test.go @@ -0,0 +1,48 @@ +package tui + +import ( + "strings" + "testing" + + "github.com/chaitin/MonkeyCode/backend/pkg/installer/steps" +) + +func TestFooterInputPasswordMasks(t *testing.T) { + out := renderFooter(footerInput{label: "密码", value: "secret", password: true, hint: "至少 8 位", cursor: true}, 40, 0) + if strings.Contains(out, "secret") { + t.Fatalf("password should be masked: %q", out) + } + if !strings.Contains(out, "●") { + t.Fatalf("missing mask char: %q", out) + } +} + +func TestFooterInputShowsHint(t *testing.T) { + out := renderFooter(footerInput{label: "端口", value: "abc", hint: "必须是数字", cursor: true}, 40, 0) + if !strings.Contains(out, "必须是数字") { + t.Fatalf("missing validation hint: %q", out) + } +} + +func TestFooterMenuHighlightsSelected(t *testing.T) { + opts := []steps.MenuOption{{Label: "安装", Value: "install"}, {Label: "退出", Value: "quit"}} + out0 := renderFooter(footerMenu{options: opts, selected: 0}, 40, 0) + out1 := renderFooter(footerMenu{options: opts, selected: 1}, 40, 0) + if !strings.Contains(out0, "安装") || !strings.Contains(out0, "退出") { + t.Fatalf("out0 missing labels: %q", out0) + } + if !strings.Contains(out1, "安装") || !strings.Contains(out1, "退出") { + t.Fatalf("out1 missing labels: %q", out1) + } +} + +func TestFooterConfirmTogglesFocus(t *testing.T) { + yesOn := renderFooter(footerConfirm{prompt: "继续?", yesFocus: true}, 40, 0) + noOn := renderFooter(footerConfirm{prompt: "继续?", yesFocus: false}, 40, 0) + if !strings.Contains(yesOn, "是") || !strings.Contains(yesOn, "否") { + t.Fatalf("yesOn missing options: %q", yesOn) + } + if !strings.Contains(noOn, "是") || !strings.Contains(noOn, "否") { + t.Fatalf("noOn missing options: %q", noOn) + } +} diff --git a/backend/pkg/installer/tui/footer.go b/backend/pkg/installer/tui/footer.go new file mode 100644 index 00000000..b1f6e397 --- /dev/null +++ b/backend/pkg/installer/tui/footer.go @@ -0,0 +1,359 @@ +package tui + +import ( + "fmt" + "strings" + "unicode/utf8" + + "github.com/chaitin/MonkeyCode/backend/pkg/installer/steps" + "github.com/charmbracelet/lipgloss" +) + +const chaseLen = 3 + +var ( + borderStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("240")) + hintStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("244")).Faint(true).Italic(true) + titleStyle = lipgloss.NewStyle().Bold(true) + chaseStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("238")).Faint(true) + selectStyle = lipgloss.NewStyle().Bold(true) + dimStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("244")) +) + +type footerState interface { + render(width, tick int) string +} + +type footerMenu struct { + options []steps.MenuOption + selected int +} + +type footerStatus struct { + title string + nextHint string +} + +type footerInput struct { + label string + value string + password bool + hint string + cursor bool +} + +type footerConfirm struct { + prompt string + yesFocus bool +} + +type footerExit struct { + message string +} + +type formSummaryRow struct { + label string + value string + password bool +} + +type footerFormConfirm struct { + rows []formSummaryRow + selected int // 0 = 确认, 1 = 重填 +} + +type footerForm struct { + fields []steps.FormField + values []string + cur int + inputView string + hint string +} + +func renderFooter(s footerState, width, tick int) string { + if width < 20 { + width = 20 + } + return s.render(width, tick) +} + +func line(width int) string { + return borderStyle.Render(strings.Repeat("─", width)) +} + +func wrapBox(width int, mainLine, subLine string) string { + var b strings.Builder + b.WriteString(line(width)) + b.WriteByte('\n') + b.WriteString(mainLine) + b.WriteByte('\n') + b.WriteString(line(width)) + if subLine != "" { + b.WriteByte('\n') + b.WriteString(subLine) + } + return b.String() +} + +func chasingPositions(textLen, tick int) []int { + if textLen < 1 { + textLen = 1 + } + span := textLen + chaseLen + out := make([]int, chaseLen) + for i := 0; i < chaseLen; i++ { + out[i] = (tick + i) % span + } + return out +} + +func renderChase(text string, tick int) string { + runes := []rune(text) + n := len(runes) + pos := chasingPositions(n, tick) + highlight := map[int]bool{} + for _, p := range pos { + if p < n { + highlight[p] = true + } + } + var b strings.Builder + for i, r := range runes { + if highlight[i] { + b.WriteString(chaseStyle.Render(string(r))) + } else { + b.WriteRune(r) + } + } + return b.String() +} + +func renderVerticalMenu(width int, opts []string, selected int) string { + n := len(opts) + above := selected + below := n - 1 - selected + + var b strings.Builder + for pad := below; pad > 0; pad-- { + b.WriteByte('\n') + } + for i := 0; i < above; i++ { + b.WriteString(dimStyle.Render(" " + opts[i])) + b.WriteByte('\n') + } + b.WriteString(line(width)) + b.WriteByte('\n') + b.WriteString(selectStyle.Render(" " + opts[selected])) + b.WriteByte('\n') + b.WriteString(line(width)) + for i := selected + 1; i < n; i++ { + b.WriteByte('\n') + b.WriteString(dimStyle.Render(" " + opts[i])) + } + for pad := above; pad > 0; pad-- { + b.WriteByte('\n') + } + b.WriteByte('\n') + b.WriteString(hintStyle.Render(" ↑↓ 切换 enter 确认")) + return b.String() +} + +func (s footerMenu) render(width, _ int) string { + labels := make([]string, len(s.options)) + for i, o := range s.options { + labels[i] = o.Label + } + return renderVerticalMenu(width, labels, s.selected) +} + +func (s footerStatus) render(width, tick int) string { + main := " " + renderChase(s.title, tick) + sub := hintStyle.Render(" " + s.nextHint) + return wrapBox(width, main, sub) +} + +func (s footerInput) render(width, _ int) string { + display := s.value + if s.password { + display = strings.Repeat("●", utf8.RuneCountInString(s.value)) + } + cursor := "" + if s.cursor { + cursor = "▌" + } + main := fmt.Sprintf(" %s ▏ %s%s", titleStyle.Render(s.label), display, cursor) + sub := hintStyle.Render(" " + s.hint) + return wrapBox(width, main, sub) +} + +func (s footerConfirm) render(width, _ int) string { + sel := 1 + if s.yesFocus { + sel = 0 + } + return renderVerticalMenu(width, []string{"是", "否"}, sel) +} + +func renderProgress(label string, done, total int64, width, tick int) string { + const barWidth = 30 + guide := strings.Repeat(" ", hintGuideStart) + "┌─── " + head := borderStyle.Render(guide) + hintStyle.Render(label) + + if total <= 0 { + return fmt.Sprintf("%s %s %s", head, renderChase("...", tick), formatBytes(done)) + } + pct := float64(done) / float64(total) + if pct < 0 { + pct = 0 + } + if pct > 1 { + pct = 1 + } + filled := int(float64(barWidth) * pct) + barStr := strings.Repeat("█", filled) + dimStyle.Render(strings.Repeat("░", barWidth-filled)) + return fmt.Sprintf("%s %s %3d%% %s/%s", head, barStr, int(pct*100), formatBytes(done), formatBytes(total)) +} + +func formatBytes(n int64) string { + const ( + kb = 1024 + mb = kb * 1024 + gb = mb * 1024 + ) + switch { + case n >= gb: + return fmt.Sprintf("%.1fGB", float64(n)/gb) + case n >= mb: + return fmt.Sprintf("%.1fMB", float64(n)/mb) + case n >= kb: + return fmt.Sprintf("%.1fKB", float64(n)/kb) + default: + return fmt.Sprintf("%dB", n) + } +} + +func (s footerExit) render(width, _ int) string { + main := selectStyle.Render(" 退出") + if s.message != "" { + main = " " + s.message + } + sub := hintStyle.Render(" enter 退出") + return wrapBox(width, main, sub) +} + +func (s footerFormConfirm) render(width, _ int) string { + var b strings.Builder + b.WriteString(titleStyle.Render(" 请确认配置")) + b.WriteByte('\n') + for _, row := range s.rows { + b.WriteString(dimStyle.Render(" " + row.label + ": ")) + b.WriteString(row.value) + b.WriteByte('\n') + } + b.WriteByte('\n') + b.WriteString(renderVerticalMenu(width, []string{"确认", "重填"}, s.selected)) + return b.String() +} + +func (s footerForm) render(width, _ int) string { + var b strings.Builder + above := s.cur + below := len(s.fields) - 1 - s.cur + n := len(s.fields) + + // 第一项时不补空白;进入第二项后才需要 below 行空白维持框位置稳定 + if s.cur > 0 { + for pad := below; pad > 0; pad-- { + b.WriteByte('\n') + } + } + for i := 0; i < s.cur; i++ { + b.WriteString(dimStyle.Render(fmt.Sprintf(" %s: %s", s.fields[i].Label, s.values[i]))) + b.WriteByte('\n') + } + + // hint 行(始终占 1 行高度,让框位置稳定) + b.WriteString(renderHintRow(s.hint, width)) + b.WriteByte('\n') + + // 上框线(hint 落点处用 ┴ 接住) + b.WriteString(topBoxLine(s.hint != "", width)) + b.WriteByte('\n') + + // 当前输入行 + b.WriteString(fmt.Sprintf(" %s: %s", titleStyle.Render(s.fields[s.cur].Label), s.inputView)) + b.WriteByte('\n') + + // 下框线 + b.WriteString(line(width)) + + // 未填项 + for i := s.cur + 1; i < n; i++ { + b.WriteByte('\n') + b.WriteString(dimStyle.Render(fmt.Sprintf(" %s: ", s.fields[i].Label))) + } + for pad := above; pad > 0; pad-- { + b.WriteByte('\n') + } + + // "↑ 上一项" 提示(仅当不是第一项时) + b.WriteByte('\n') + if s.cur > 0 { + b.WriteString(hintStyle.Render(" ↑ 上一项")) + } + return b.String() +} + +const hintGuideStart = 3 + +func renderHintRow(hint string, width int) string { + if hint == "" { + return "" + } + guide := strings.Repeat(" ", hintGuideStart) + "┌─── " + return borderStyle.Render(guide) + hintStyle.Render(hint) +} + +func topBoxLine(hasHint bool, width int) string { + if !hasHint { + return line(width) + } + // 引导线落点对齐:hintGuideStart 个 ─ + ┴ + 剩余 ─ + left := strings.Repeat("─", hintGuideStart) + rest := width - hintGuideStart - 1 + if rest < 0 { + rest = 0 + } + return borderStyle.Render(left + "┴" + strings.Repeat("─", rest)) +} + +func renderHintLine(left, hint string, width int) string { + if hint == "" { + return dimStyle.Render(left) + } + hintRendered := hintStyle.Render(hint) + if left == "" { + pad := width - visualWidth(hint) + if pad < 1 { + return hintRendered + } + return strings.Repeat(" ", pad) + hintRendered + } + pad := width - visualWidth(left) - visualWidth(hint) + if pad < 2 { + pad = 2 + } + return dimStyle.Render(left) + strings.Repeat(" ", pad) + hintRendered +} + +func visualWidth(s string) int { + n := 0 + for _, r := range s { + if r >= 0x4E00 && r <= 0x9FFF { + n += 2 + continue + } + n++ + } + return n +} diff --git a/backend/pkg/installer/tui/footer_test.go b/backend/pkg/installer/tui/footer_test.go new file mode 100644 index 00000000..c39810ee --- /dev/null +++ b/backend/pkg/installer/tui/footer_test.go @@ -0,0 +1,74 @@ +package tui + +import ( + "strings" + "testing" + + "github.com/chaitin/MonkeyCode/backend/pkg/installer/steps" +) + +func TestChasingPositions(t *testing.T) { + got := chasingPositions(5, 0) + want := []int{0, 1, 2} + if len(got) != 3 { + t.Fatalf("want 3 positions, got %d", len(got)) + } + for i, w := range want { + if got[i] != w { + t.Fatalf("tick=0 pos[%d]=%d, want %d", i, got[i], w) + } + } + + got = chasingPositions(5, 1) + want = []int{1, 2, 3} + for i, w := range want { + if got[i] != w { + t.Fatalf("tick=1 pos[%d]=%d, want %d", i, got[i], w) + } + } + + span := 5 + chaseLen + got = chasingPositions(5, 6) + want = []int{6 % span, (6 + 1) % span, (6 + 2) % span} + for i, w := range want { + if got[i] != w { + t.Fatalf("tick=6 pos[%d]=%d, want %d", i, got[i], w) + } + } +} + +func TestRenderFooterMenu(t *testing.T) { + out := renderFooter(footerMenu{ + options: []steps.MenuOption{{Label: "安装", Value: "install"}, {Label: "退出", Value: "quit"}}, + selected: 0, + }, 40, 0) + if !strings.Contains(out, "安装") { + t.Fatalf("missing 安装: %q", out) + } + if !strings.Contains(out, "退出") { + t.Fatalf("missing 退出: %q", out) + } + if strings.Count(out, "─") < 20 { + t.Fatalf("missing border lines: %q", out) + } +} + +func TestRenderFooterStatusContainsText(t *testing.T) { + out := renderFooter(footerStatus{title: "检查 Docker...", nextHint: "下一步: 安装 Docker"}, 40, 0) + if !strings.Contains(out, "检查 Docker") { + t.Fatalf("missing title: %q", out) + } + if !strings.Contains(out, "下一步") { + t.Fatalf("missing hint: %q", out) + } +} + +func TestRenderFooterExit(t *testing.T) { + out := renderFooter(footerExit{}, 40, 0) + if !strings.Contains(out, "退出") { + t.Fatalf("missing 退出: %q", out) + } + if !strings.Contains(out, "enter") { + t.Fatalf("missing enter hint: %q", out) + } +} diff --git a/backend/pkg/installer/tui/messages.go b/backend/pkg/installer/tui/messages.go new file mode 100644 index 00000000..03bcd28f --- /dev/null +++ b/backend/pkg/installer/tui/messages.go @@ -0,0 +1,60 @@ +package tui + +import ( + "time" + + tea "github.com/charmbracelet/bubbletea" + + "github.com/chaitin/MonkeyCode/backend/pkg/installer/steps" +) + +type tickMsg time.Time + +type logMsg string +type setStepMsg struct{ title, hint string } +type askInputMsg struct { + label string + defaultV string + password bool + validate steps.Validator +} +type askMenuMsg struct { + title string + options []steps.MenuOption +} +type askConfirmMsg struct{ prompt string } +type askFormMsg struct{ fields []steps.FormField } +type doneMsg struct{ err error } + +type startProgressMsg struct{ label string } +type updateProgressMsg struct { + downloaded int64 + total int64 +} +type endProgressMsg struct{} + +type inputResult struct { + value string + err error +} +type formResult struct { + values []string + err error +} +type menuResult struct { + value string + err error +} +type confirmResult struct { + value bool + err error +} + +type attachInputRespMsg struct{ ch chan inputResult } +type attachFormRespMsg struct{ ch chan formResult } +type attachMenuRespMsg struct{ ch chan menuResult } +type attachConfirmRespMsg struct{ ch chan confirmResult } + +func tickEvery() tea.Cmd { + return tea.Tick(80*time.Millisecond, func(t time.Time) tea.Msg { return tickMsg(t) }) +} diff --git a/backend/pkg/installer/tui/model.go b/backend/pkg/installer/tui/model.go new file mode 100644 index 00000000..505498c9 --- /dev/null +++ b/backend/pkg/installer/tui/model.go @@ -0,0 +1,382 @@ +package tui + +import ( + "fmt" + "strings" + "time" + + "github.com/charmbracelet/bubbles/textinput" + tea "github.com/charmbracelet/bubbletea" +) + +type Model struct { + logs []string + state footerState + tick int + width int + + progressActive bool + progressLabel string + progressDone int64 + progressTotal int64 + progressStart time.Time + + pendingInput *askInputMsg + pendingMenu *askMenuMsg + pendingConfirm *askConfirmMsg + pendingForm *askFormMsg + formIdx int // 当前正在填的字段索引 + formValues []string // 已填字段 + formPhase int // 0=填字段 1=确认页 + formConfirmSel int // 0=确认 1=重填 + input textinput.Model + + menuSel int + confirmYes bool + + respInput chan inputResult + respForm chan formResult + respMenu chan menuResult + respConfirm chan confirmResult + + doneErr error + finished bool +} + +func NewModel() *Model { + ti := textinput.New() + ti.Prompt = "" + return &Model{ + state: footerExit{message: "准备中..."}, + input: ti, + width: 80, + } +} + +func (m *Model) Init() tea.Cmd { + return tickEvery() +} + +func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.width = msg.Width + return m, nil + + case tickMsg: + m.tick++ + return m, tickEvery() + + case logMsg: + m.logs = append(m.logs, string(msg)) + return m, nil + + case setStepMsg: + m.state = footerStatus{title: msg.title, nextHint: msg.hint} + return m, nil + + case startProgressMsg: + m.progressActive = true + m.progressLabel = msg.label + m.progressDone = 0 + m.progressTotal = 0 + m.progressStart = time.Now() + return m, nil + + case updateProgressMsg: + if m.progressActive { + m.progressDone = msg.downloaded + m.progressTotal = msg.total + } + return m, nil + + case endProgressMsg: + if m.progressActive { + elapsed := time.Since(m.progressStart) + m.logs = append(m.logs, fmt.Sprintf("✓ %s 完成 · 耗时 %s", m.progressLabel, formatDuration(elapsed))) + m.progressActive = false + m.progressLabel = "" + } + return m, nil + + case attachInputRespMsg: + m.respInput = msg.ch + return m, nil + case attachFormRespMsg: + m.respForm = msg.ch + return m, nil + case attachMenuRespMsg: + m.respMenu = msg.ch + return m, nil + case attachConfirmRespMsg: + m.respConfirm = msg.ch + return m, nil + + case askFormMsg: + m.pendingForm = &msg + m.formIdx = 0 + m.formValues = make([]string, len(msg.fields)) + m.formPhase = 0 + m.startFormField() + return m, textinput.Blink + + case askInputMsg: + m.pendingInput = &msg + m.input.SetValue(msg.defaultV) + m.input.EchoMode = textinput.EchoNormal + if msg.password { + m.input.EchoMode = textinput.EchoPassword + } + m.input.Focus() + m.state = footerInput{label: msg.label, value: msg.defaultV, password: msg.password, cursor: true} + return m, textinput.Blink + + case askMenuMsg: + m.pendingMenu = &msg + m.menuSel = 0 + m.state = footerMenu{options: msg.options, selected: 0} + return m, nil + + case askConfirmMsg: + m.pendingConfirm = &msg + m.confirmYes = false + m.logs = append(m.logs, " "+msg.prompt) + m.state = footerConfirm{prompt: msg.prompt, yesFocus: false} + return m, nil + + case doneMsg: + m.finished = true + m.doneErr = msg.err + message := "退出" + if msg.err != nil { + message = "退出 (出错)" + } + m.state = footerExit{message: message} + return m, nil + + case tea.KeyMsg: + return m.handleKey(msg) + } + return m, nil +} + +func (m *Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + if msg.Type == tea.KeyCtrlC { + return m, tea.Quit + } + + switch { + case m.pendingForm != nil: + return m.handleFormKey(msg) + case m.pendingInput != nil: + var cmd tea.Cmd + m.input, cmd = m.input.Update(msg) + if msg.Type == tea.KeyEnter { + val := m.input.Value() + if val == "" { + val = m.pendingInput.defaultV + } + if m.pendingInput.validate != nil { + if err := m.pendingInput.validate(val); err != nil { + m.state = footerInput{ + label: m.pendingInput.label, + value: val, + password: m.pendingInput.password, + hint: err.Error(), + cursor: true, + } + return m, nil + } + } + respCh := m.respInput + m.pendingInput = nil + m.respInput = nil + respCh <- inputResult{value: val} + return m, nil + } + m.state = footerInput{ + label: m.pendingInput.label, + value: m.input.Value(), + password: m.pendingInput.password, + cursor: true, + } + return m, cmd + + case m.pendingMenu != nil: + opts := m.pendingMenu.options + switch msg.String() { + case "up", "k": + if m.menuSel > 0 { + m.menuSel-- + } + case "down", "j": + if m.menuSel < len(opts)-1 { + m.menuSel++ + } + case "enter": + val := opts[m.menuSel].Value + respCh := m.respMenu + m.pendingMenu = nil + m.respMenu = nil + respCh <- menuResult{value: val} + return m, nil + } + m.state = footerMenu{options: opts, selected: m.menuSel} + return m, nil + + case m.pendingConfirm != nil: + switch msg.String() { + case "up", "k": + m.confirmYes = true + case "down", "j": + m.confirmYes = false + case "enter": + respCh := m.respConfirm + val := m.confirmYes + m.pendingConfirm = nil + m.respConfirm = nil + respCh <- confirmResult{value: val} + return m, nil + } + m.state = footerConfirm{prompt: m.pendingConfirm.prompt, yesFocus: m.confirmYes} + return m, nil + + case m.finished: + if msg.Type == tea.KeyEnter { + return m, tea.Quit + } + } + return m, nil +} + +func (m *Model) startFormField() { + field := m.pendingForm.fields[m.formIdx] + cur := m.formValues[m.formIdx] + if cur == "" { + cur = field.Default + } + m.input.SetValue(cur) + m.input.EchoMode = textinput.EchoNormal + m.input.Focus() + m.refreshFormState("") +} + +func (m *Model) refreshFormState(hintErr string) { + field := m.pendingForm.fields[m.formIdx] + hint := field.Help + if hintErr != "" { + hint = hintErr + } + m.state = footerForm{ + fields: m.pendingForm.fields, + values: m.formValues, + cur: m.formIdx, + inputView: m.input.View(), + hint: hint, + } +} + +func (m *Model) showFormConfirm() { + rows := make([]formSummaryRow, len(m.pendingForm.fields)) + for i, f := range m.pendingForm.fields { + rows[i] = formSummaryRow{label: f.Label, value: m.formValues[i], password: f.Password} + } + m.formConfirmSel = 0 + m.state = footerFormConfirm{rows: rows, selected: 0} +} + +func (m *Model) handleFormKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + if m.formPhase == 1 { + switch msg.String() { + case "up", "k": + m.formConfirmSel = 0 + case "down", "j": + m.formConfirmSel = 1 + case "enter": + if m.formConfirmSel == 0 { + respCh := m.respForm + vals := m.formValues + m.pendingForm = nil + m.respForm = nil + m.formValues = nil + respCh <- formResult{values: vals} + return m, nil + } + // 重填 + m.formIdx = 0 + m.formValues = make([]string, len(m.pendingForm.fields)) + m.formPhase = 0 + m.startFormField() + return m, nil + } + m.state = footerFormConfirm{ + rows: m.state.(footerFormConfirm).rows, + selected: m.formConfirmSel, + } + return m, nil + } + + field := m.pendingForm.fields[m.formIdx] + var cmd tea.Cmd + m.input, cmd = m.input.Update(msg) + + switch msg.Type { + case tea.KeyEnter: + val := m.input.Value() + if val == "" { + val = field.Default + } + if field.Validate != nil { + if err := field.Validate(val); err != nil { + m.refreshFormState(err.Error()) + return m, nil + } + } + m.formValues[m.formIdx] = val + if m.formIdx+1 < len(m.pendingForm.fields) { + m.formIdx++ + m.startFormField() + return m, textinput.Blink + } + m.formPhase = 1 + m.showFormConfirm() + return m, nil + case tea.KeyUp: + if m.formIdx > 0 { + m.formValues[m.formIdx] = m.input.Value() + m.formIdx-- + m.startFormField() + return m, textinput.Blink + } + } + + m.refreshFormState("") + return m, cmd +} + +func (m *Model) View() string { + var b strings.Builder + for _, line := range m.logs { + b.WriteString(line) + b.WriteByte('\n') + } + b.WriteByte('\n') + if m.progressActive { + b.WriteString(renderProgress(m.progressLabel, m.progressDone, m.progressTotal, m.width, m.tick)) + b.WriteByte('\n') + } + b.WriteString(renderFooter(m.state, m.width, m.tick)) + return b.String() +} + +func formatDuration(d time.Duration) string { + if d < time.Second { + return fmt.Sprintf("%dms", d.Milliseconds()) + } + if d < time.Minute { + return fmt.Sprintf("%.1fs", d.Seconds()) + } + min := int(d / time.Minute) + sec := int((d % time.Minute) / time.Second) + return fmt.Sprintf("%dm%ds", min, sec) +} diff --git a/backend/pkg/installer/tui/reporter.go b/backend/pkg/installer/tui/reporter.go new file mode 100644 index 00000000..8383a877 --- /dev/null +++ b/backend/pkg/installer/tui/reporter.go @@ -0,0 +1,91 @@ +package tui + +import ( + "fmt" + "io" + + tea "github.com/charmbracelet/bubbletea" + + "github.com/chaitin/MonkeyCode/backend/pkg/installer/steps" +) + +type Reporter struct { + prog *tea.Program + logW io.Writer + + respInput chan inputResult + respForm chan formResult + respMenu chan menuResult + respConfirm chan confirmResult +} + +func newReporter(prog *tea.Program, logW io.Writer) *Reporter { + return &Reporter{ + prog: prog, + logW: logW, + respInput: make(chan inputResult, 1), + respForm: make(chan formResult, 1), + respMenu: make(chan menuResult, 1), + respConfirm: make(chan confirmResult, 1), + } +} + +func (r *Reporter) Log(format string, args ...any) { + line := fmt.Sprintf(format, args...) + fmt.Fprintln(r.logW, line) + r.prog.Send(logMsg(line)) +} + +func (r *Reporter) LogScreen(format string, args ...any) { + line := fmt.Sprintf(format, args...) + r.prog.Send(logMsg(line)) +} + +func (r *Reporter) LogFile(format string, args ...any) { + line := fmt.Sprintf(format, args...) + fmt.Fprintln(r.logW, line) +} + +func (r *Reporter) SetStep(title, nextHint string) { + r.prog.Send(setStepMsg{title: title, hint: nextHint}) +} + +func (r *Reporter) StartProgress(label string) { + r.prog.Send(startProgressMsg{label: label}) +} + +func (r *Reporter) UpdateProgress(downloaded, total int64) { + r.prog.Send(updateProgressMsg{downloaded: downloaded, total: total}) +} + +func (r *Reporter) EndProgress() { + r.prog.Send(endProgressMsg{}) +} + +func (r *Reporter) AskInput(label, defaultVal string, password bool, validate steps.Validator) (string, error) { + r.prog.Send(attachInputRespMsg{ch: r.respInput}) + r.prog.Send(askInputMsg{label: label, defaultV: defaultVal, password: password, validate: validate}) + res := <-r.respInput + return res.value, res.err +} + +func (r *Reporter) AskForm(fields []steps.FormField) ([]string, error) { + r.prog.Send(attachFormRespMsg{ch: r.respForm}) + r.prog.Send(askFormMsg{fields: fields}) + res := <-r.respForm + return res.values, res.err +} + +func (r *Reporter) AskMenu(title string, options []steps.MenuOption) (string, error) { + r.prog.Send(attachMenuRespMsg{ch: r.respMenu}) + r.prog.Send(askMenuMsg{title: title, options: options}) + res := <-r.respMenu + return res.value, res.err +} + +func (r *Reporter) AskConfirm(prompt string) (bool, error) { + r.prog.Send(attachConfirmRespMsg{ch: r.respConfirm}) + r.prog.Send(askConfirmMsg{prompt: prompt}) + res := <-r.respConfirm + return res.value, res.err +} diff --git a/backend/pkg/installer/tui/runner.go b/backend/pkg/installer/tui/runner.go new file mode 100644 index 00000000..9925e1e4 --- /dev/null +++ b/backend/pkg/installer/tui/runner.go @@ -0,0 +1,45 @@ +package tui + +import ( + "io" + + tea "github.com/charmbracelet/bubbletea" + + "github.com/chaitin/MonkeyCode/backend/pkg/installer/steps" +) + +type Runner struct { + prog *tea.Program + model *Model + reporter *Reporter + bizErr error +} + +func NewRunner(logW io.Writer) *Runner { + model := NewModel() + prog := tea.NewProgram(model) + r := &Runner{ + prog: prog, + model: model, + } + r.reporter = newReporter(prog, logW) + return r +} + +func (r *Runner) Reporter() steps.Reporter { return r.reporter } +func (r *Runner) Program() *tea.Program { return r.prog } +func (r *Runner) BizErr() error { return r.bizErr } + +func (r *Runner) Done(err error) { + r.bizErr = err + r.prog.Send(doneMsg{err: err}) +} + +func (r *Runner) Quit() { + r.prog.Quit() +} + +func (r *Runner) Run() error { + _, err := r.prog.Run() + return err +} diff --git a/backend/pkg/oss/oss.go b/backend/pkg/oss/oss.go new file mode 100644 index 00000000..12dec82a --- /dev/null +++ b/backend/pkg/oss/oss.go @@ -0,0 +1,343 @@ +package oss + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/url" + "path" + "strings" + "time" + + "github.com/aws/aws-sdk-go-v2/aws" + awsconfig "github.com/aws/aws-sdk-go-v2/config" + "github.com/aws/aws-sdk-go-v2/credentials" + "github.com/aws/aws-sdk-go-v2/service/s3" + s3types "github.com/aws/aws-sdk-go-v2/service/s3/types" + + "github.com/chaitin/MonkeyCode/backend/config" +) + +const ( + defaultExpires = 10 * time.Minute + maxExpires = 7 * 24 * time.Hour +) + +type S3Option struct { + ForcePathStyle bool + InitBucket bool +} + +type Client struct { + cfg config.ObjectStorageConfig + region string + s3 *s3.Client + presigner *s3.PresignClient + pathStyle bool +} + +type Presign struct { + UploadURL string + AccessURL string +} + +func NewS3Compatible(ctx context.Context, cfg config.ObjectStorageConfig, opt S3Option) (*Client, error) { + if err := validateConfig(cfg); err != nil { + return nil, err + } + region := normalizeRegion(cfg.Region) + awsCfg, err := awsconfig.LoadDefaultConfig(ctx, + awsconfig.WithRegion(region), + awsconfig.WithCredentialsProvider(credentials.NewStaticCredentialsProvider(cfg.AccessKey, cfg.AccessKeySecret, "")), + ) + if err != nil { + return nil, err + } + client := s3.NewFromConfig(awsCfg, func(o *s3.Options) { + o.BaseEndpoint = aws.String(cfg.Endpoint) + o.UsePathStyle = opt.ForcePathStyle + }) + c := &Client{ + cfg: cfg, + region: region, + s3: client, + pathStyle: opt.ForcePathStyle, + presigner: s3.NewPresignClient(client, s3.WithPresignClientFromClientOptions(func(o *s3.Options) { + o.BaseEndpoint = aws.String(presignSigningEndpoint(cfg)) + o.UsePathStyle = opt.ForcePathStyle + })), + } + if opt.InitBucket { + if err := c.initBucket(ctx); err != nil { + return nil, err + } + } + return c, nil +} + +func validateConfig(cfg config.ObjectStorageConfig) error { + if strings.TrimSpace(cfg.Endpoint) == "" { + return errors.New("oss endpoint is empty") + } + if strings.TrimSpace(cfg.AccessKey) == "" { + return errors.New("oss access key is empty") + } + if strings.TrimSpace(cfg.AccessKeySecret) == "" { + return errors.New("oss access key secret is empty") + } + if strings.TrimSpace(cfg.Bucket) == "" { + return errors.New("oss bucket is empty") + } + return nil +} + +func (c *Client) initBucket(ctx context.Context) error { + input := &s3.CreateBucketInput{ + Bucket: aws.String(c.cfg.Bucket), + } + if c.region != "" && c.region != "us-east-1" { + input.CreateBucketConfiguration = &s3types.CreateBucketConfiguration{ + LocationConstraint: s3types.BucketLocationConstraint(c.region), + } + } + _, err := c.s3.CreateBucket(ctx, input) + if err != nil { + var bucketOwned *s3types.BucketAlreadyOwnedByYou + if !errors.As(err, &bucketOwned) { + return err + } + } + return c.initPublicReadPolicy(ctx) +} + +func (c *Client) initPublicReadPolicy(ctx context.Context) error { + resources := publicReadResources(c.cfg) + if len(resources) == 0 { + return nil + } + policy := bucketPolicy{ + Version: "2012-10-17", + Statement: []bucketPolicyStatement{ + { + Effect: "Allow", + Principal: bucketPolicyPrincipal{AWS: []string{"*"}}, + Action: []string{"s3:GetObject"}, + Resource: resources, + }, + }, + } + data, err := json.Marshal(policy) + if err != nil { + return err + } + _, err = c.s3.PutBucketPolicy(ctx, &s3.PutBucketPolicyInput{ + Bucket: aws.String(c.cfg.Bucket), + Policy: aws.String(string(data)), + }) + return err +} + +func (c *Client) PutFile(ctx context.Context, prefix, filename string, r io.Reader) error { + _, err := c.s3.PutObject(ctx, &s3.PutObjectInput{ + Bucket: aws.String(c.cfg.Bucket), + Key: aws.String(objectKey(prefix, filename)), + Body: r, + }) + return err +} + +func (c *Client) HeadFile(ctx context.Context, prefix, filename string) (bool, error) { + _, err := c.s3.HeadObject(ctx, &s3.HeadObjectInput{ + Bucket: aws.String(c.cfg.Bucket), + Key: aws.String(objectKey(prefix, filename)), + }) + if err == nil { + return true, nil + } + var notFound *s3types.NotFound + if errors.As(err, ¬Found) { + return false, nil + } + return false, err +} + +func (c *Client) WithAccessEndpoint(endpoint string) *Client { + endpoint = strings.TrimSpace(endpoint) + if c == nil || endpoint == "" { + return c + } + next := *c + next.cfg.AccessEndpoint = endpoint + if c.s3 != nil { + next.presigner = s3.NewPresignClient(c.s3, s3.WithPresignClientFromClientOptions(func(o *s3.Options) { + o.BaseEndpoint = aws.String(presignSigningEndpoint(next.cfg)) + o.UsePathStyle = c.pathStyle + })) + } + return &next +} + +func (c *Client) GetURL(prefix, filename string) string { + base := objectAccessBase(c.cfg) + return appendURLPath(base, objectKey(prefix, filename)) +} + +func (c *Client) Presign(ctx context.Context, prefix, filename string, expires time.Duration) (*Presign, error) { + expires = normalizeExpires(expires) + key := objectKey(prefix, filename) + putURL, err := c.presigner.PresignPutObject(ctx, &s3.PutObjectInput{ + Bucket: aws.String(c.cfg.Bucket), + Key: aws.String(key), + }, s3.WithPresignExpires(expires)) + if err != nil { + return nil, fmt.Errorf("presign put object: %w", err) + } + getURL, err := c.presigner.PresignGetObject(ctx, &s3.GetObjectInput{ + Bucket: aws.String(c.cfg.Bucket), + Key: aws.String(key), + }, s3.WithPresignExpires(expires)) + if err != nil { + return nil, fmt.Errorf("presign get object: %w", err) + } + return &Presign{ + UploadURL: c.publicPresignURL(putURL.URL, key), + AccessURL: c.publicPresignURL(getURL.URL, key), + }, nil +} + +func objectKey(prefix, filename string) string { + cleanPrefix := cleanObjectPrefix(prefix) + name := path.Base(strings.Trim(filename, "/")) + if name == "." || name == ".." || name == "/" { + name = "" + } + return strings.Trim(path.Join(cleanPrefix, name), "/") +} + +func normalizeExpires(expires time.Duration) time.Duration { + if expires <= 0 { + return defaultExpires + } + if expires > maxExpires { + return maxExpires + } + return expires +} + +func normalizeRegion(region string) string { + region = strings.TrimSpace(region) + if region == "" { + return "us-east-1" + } + return region +} + +func cleanObjectPrefix(prefix string) string { + cleanPrefix := strings.Trim(path.Clean(strings.Trim(prefix, "/")), "/") + for cleanPrefix == ".." || strings.HasPrefix(cleanPrefix, "../") { + cleanPrefix = strings.TrimPrefix(cleanPrefix, "../") + if cleanPrefix == ".." { + return "" + } + } + if cleanPrefix == "." { + return "" + } + return cleanPrefix +} + +type bucketPolicy struct { + Version string `json:"Version"` + Statement []bucketPolicyStatement `json:"Statement"` +} + +type bucketPolicyStatement struct { + Effect string `json:"Effect"` + Principal bucketPolicyPrincipal `json:"Principal"` + Action []string `json:"Action"` + Resource []string `json:"Resource"` +} + +type bucketPolicyPrincipal struct { + AWS []string `json:"AWS"` +} + +func publicReadResources(cfg config.ObjectStorageConfig) []string { + prefixes := []string{cfg.AvatarPrefix, cfg.SpecPrefix, cfg.RepoPrefix} + resources := make([]string, 0, len(prefixes)) + seen := make(map[string]struct{}, len(prefixes)) + bucket := strings.Trim(cfg.Bucket, "/") + for _, prefix := range prefixes { + prefix = cleanObjectPrefix(prefix) + if prefix == "" { + continue + } + if _, ok := seen[prefix]; ok { + continue + } + seen[prefix] = struct{}{} + resources = append(resources, fmt.Sprintf("arn:aws:s3:::%s/%s/*", bucket, prefix)) + } + return resources +} + +func appendURLPath(base, key string) string { + u, err := url.Parse(base) + if err != nil { + return strings.TrimRight(base, "/") + "/" + key + } + u.Path = strings.TrimRight(u.Path, "/") + "/" + key + return u.String() +} + +func objectAccessBase(cfg config.ObjectStorageConfig) string { + base := strings.TrimRight(strings.TrimSpace(cfg.AccessEndpoint), "/") + if base == "" { + base = strings.TrimRight(cfg.Endpoint, "/") + } + bucket := strings.Trim(cfg.Bucket, "/") + if bucket == "" { + return base + } + u, err := url.Parse(base) + if err != nil { + return strings.TrimRight(base, "/") + "/" + bucket + } + parts := strings.Split(strings.Trim(u.Path, "/"), "/") + if len(parts) == 0 || parts[len(parts)-1] != bucket { + u.Path = strings.TrimRight(u.Path, "/") + "/" + bucket + } + return u.String() +} + +func presignSigningEndpoint(cfg config.ObjectStorageConfig) string { + endpoint := strings.TrimSpace(cfg.AccessEndpoint) + if endpoint == "" { + return cfg.Endpoint + } + u, err := url.Parse(endpoint) + if err != nil { + return endpoint + } + u.Path = "" + u.RawPath = "" + u.RawQuery = "" + u.Fragment = "" + return strings.TrimRight(u.String(), "/") +} + +func (c *Client) publicPresignURL(signedURL, key string) string { + publicURL := appendURLPath(objectAccessBase(c.cfg), key) + public, err := url.Parse(publicURL) + if err != nil { + return signedURL + } + signed, err := url.Parse(signedURL) + if err != nil { + return publicURL + } + public.RawQuery = signed.RawQuery + return public.String() +} diff --git a/backend/pkg/oss/oss_test.go b/backend/pkg/oss/oss_test.go new file mode 100644 index 00000000..5e25d285 --- /dev/null +++ b/backend/pkg/oss/oss_test.go @@ -0,0 +1,341 @@ +package oss + +import ( + "context" + "io" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + "time" + + "github.com/chaitin/MonkeyCode/backend/config" +) + +func TestObjectKeyJoinsPrefixAndFilename(t *testing.T) { + key := objectKey("/tmp/task-attachments/", "/a.txt") + if key != "tmp/task-attachments/a.txt" { + t.Fatalf("key = %q", key) + } +} + +func TestObjectKeyKeepsFilenameInsidePrefix(t *testing.T) { + key := objectKey("tmp/task-attachments", "../a #?.txt") + if key != "tmp/task-attachments/a #?.txt" { + t.Fatalf("key = %q", key) + } +} + +func TestNormalizeExpires(t *testing.T) { + if got := normalizeExpires(0); got != 10*time.Minute { + t.Fatalf("zero expires = %s", got) + } + if got := normalizeExpires(8 * 24 * time.Hour); got != 7*24*time.Hour { + t.Fatalf("large expires = %s", got) + } +} + +func TestPublicURLUsesAccessEndpoint(t *testing.T) { + client := &Client{ + cfg: config.ObjectStorageConfig{ + AccessEndpoint: "http://localhost:9000/monkeycode-private", + }, + } + url := client.GetURL("tmp/task-attachments", "a.txt") + if url != "http://localhost:9000/monkeycode-private/tmp/task-attachments/a.txt" { + t.Fatalf("url = %q", url) + } +} + +func TestPublicURLAddsBucketWhenAccessEndpointHasNoPath(t *testing.T) { + client := &Client{ + cfg: config.ObjectStorageConfig{ + AccessEndpoint: "http://localhost:9000", + Bucket: "monkeycode-private", + }, + } + url := client.GetURL("tmp/task-attachments", "a.txt") + if url != "http://localhost:9000/monkeycode-private/tmp/task-attachments/a.txt" { + t.Fatalf("url = %q", url) + } +} + +func TestPublicURLEscapesPath(t *testing.T) { + client := &Client{ + cfg: config.ObjectStorageConfig{ + AccessEndpoint: "http://localhost:9000/monkeycode-private", + }, + } + url := client.GetURL("tmp", "a #?.txt") + if url != "http://localhost:9000/monkeycode-private/tmp/a%20%23%3F.txt" { + t.Fatalf("url = %q", url) + } +} + +func TestValidateConfigRequiresS3Fields(t *testing.T) { + err := validateConfig(config.ObjectStorageConfig{}) + if err == nil { + t.Fatal("expected config error") + } +} + +func TestHeadFileReturnsTrueWhenObjectExists(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodHead { + t.Fatalf("method = %s, want HEAD", r.Method) + } + if r.URL.Path != "/bucket/repo/project-tpl.zip" { + t.Fatalf("path = %s", r.URL.Path) + } + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + client, err := NewS3Compatible(context.Background(), config.ObjectStorageConfig{ + Endpoint: server.URL, + AccessKey: "ak", + AccessKeySecret: "sk", + Bucket: "bucket", + }, S3Option{ForcePathStyle: true}) + if err != nil { + t.Fatal(err) + } + exists, err := client.HeadFile(context.Background(), "repo", "project-tpl.zip") + if err != nil { + t.Fatal(err) + } + if !exists { + t.Fatal("exists = false, want true") + } +} + +func TestHeadFileReturnsFalseWhenObjectMissing(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`NoSuchKeymissing`)) + })) + defer server.Close() + + client, err := NewS3Compatible(context.Background(), config.ObjectStorageConfig{ + Endpoint: server.URL, + AccessKey: "ak", + AccessKeySecret: "sk", + Bucket: "bucket", + }, S3Option{ForcePathStyle: true}) + if err != nil { + t.Fatal(err) + } + exists, err := client.HeadFile(context.Background(), "repo", "project-tpl.zip") + if err != nil { + t.Fatal(err) + } + if exists { + t.Fatal("exists = true, want false") + } +} + +func TestPresignUsesAccessEndpointHost(t *testing.T) { + client, err := NewS3Compatible(context.Background(), config.ObjectStorageConfig{ + Endpoint: "http://internal:9000", + AccessEndpoint: "http://public.example.com/monkeycode-private", + AccessKey: "ak", + AccessKeySecret: "sk", + Bucket: "monkeycode-private", + }, S3Option{ForcePathStyle: true}) + if err != nil { + t.Fatal(err) + } + presign, err := client.Presign(context.Background(), "tmp", "a.txt", time.Minute) + if err != nil { + t.Fatal(err) + } + if strings.Contains(presign.UploadURL, "internal:9000") || strings.Contains(presign.AccessURL, "internal:9000") { + t.Fatalf("presign url uses internal endpoint: %#v", presign) + } + if !strings.Contains(presign.UploadURL, "public.example.com") || !strings.Contains(presign.AccessURL, "public.example.com") { + t.Fatalf("presign url does not use access endpoint: %#v", presign) + } +} + +func TestPresignWithAccessEndpointOverridesConfiguredEndpoint(t *testing.T) { + client, err := NewS3Compatible(context.Background(), config.ObjectStorageConfig{ + Endpoint: "http://internal:9000", + AccessEndpoint: "http://old.example.com/monkeycode-private", + AccessKey: "ak", + AccessKeySecret: "sk", + Bucket: "monkeycode-private", + }, S3Option{ForcePathStyle: true}) + if err != nil { + t.Fatal(err) + } + presign, err := client.WithAccessEndpoint("https://new.example.com").Presign(context.Background(), "tmp", "a.txt", time.Minute) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(presign.UploadURL, "new.example.com") || !strings.Contains(presign.AccessURL, "new.example.com") { + t.Fatalf("presign url does not use request access endpoint: %#v", presign) + } + if strings.Contains(presign.UploadURL, "old.example.com") || strings.Contains(presign.AccessURL, "old.example.com") { + t.Fatalf("presign url uses configured endpoint: %#v", presign) + } +} + +func TestPresignWithAccessEndpointKeepsPathPrefixOutsideSignature(t *testing.T) { + client, err := NewS3Compatible(context.Background(), config.ObjectStorageConfig{ + Endpoint: "http://internal:9000", + AccessEndpoint: "https://monkeycode.example.com/oss", + AccessKey: "ak", + AccessKeySecret: "sk", + Bucket: "monkeycode-private", + }, S3Option{ForcePathStyle: true}) + if err != nil { + t.Fatal(err) + } + presign, err := client.Presign(context.Background(), "tmp", "a.txt", time.Minute) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(presign.UploadURL, "/oss/monkeycode-private/tmp/a.txt") { + t.Fatalf("presign upload url path missing /oss prefix: %s", presign.UploadURL) + } + if !strings.Contains(presign.AccessURL, "/oss/monkeycode-private/tmp/a.txt") { + t.Fatalf("presign access url path missing /oss prefix: %s", presign.AccessURL) + } + signingClient, err := NewS3Compatible(context.Background(), config.ObjectStorageConfig{ + Endpoint: "http://internal:9000", + AccessEndpoint: "https://monkeycode.example.com", + AccessKey: "ak", + AccessKeySecret: "sk", + Bucket: "monkeycode-private", + }, S3Option{ForcePathStyle: true}) + if err != nil { + t.Fatal(err) + } + signingPresign, err := signingClient.Presign(context.Background(), "tmp", "a.txt", time.Minute) + if err != nil { + t.Fatal(err) + } + if signatureValue(t, presign.UploadURL) != signatureValue(t, signingPresign.UploadURL) { + t.Fatalf("presign upload signature includes proxy prefix: %s", presign.UploadURL) + } +} + +func signatureValue(t *testing.T, raw string) string { + t.Helper() + u, err := url.Parse(raw) + if err != nil { + t.Fatal(err) + } + return u.Query().Get("X-Amz-Signature") +} + +func TestGetURLWithAccessEndpointOverridesConfiguredEndpoint(t *testing.T) { + client := &Client{ + cfg: config.ObjectStorageConfig{ + AccessEndpoint: "http://old.example.com/monkeycode-private", + Bucket: "monkeycode-private", + }, + } + got := client.WithAccessEndpoint("https://new.example.com").GetURL("tmp", "a.txt") + if got != "https://new.example.com/monkeycode-private/tmp/a.txt" { + t.Fatalf("url = %q", got) + } +} + +func TestInitBucketReturnsBucketAlreadyExists(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusConflict) + _, _ = w.Write([]byte(`BucketAlreadyExistsexists`)) + })) + defer server.Close() + + _, err := NewS3Compatible(context.Background(), config.ObjectStorageConfig{ + Endpoint: server.URL, + AccessKey: "ak", + AccessKeySecret: "sk", + Bucket: "bucket", + }, S3Option{ForcePathStyle: true, InitBucket: true}) + if err == nil { + t.Fatal("expected bucket already exists error") + } +} + +func TestInitBucketSetsLocationConstraintForRegion(t *testing.T) { + var body string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + data, _ := io.ReadAll(r.Body) + body = string(data) + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + _, err := NewS3Compatible(context.Background(), config.ObjectStorageConfig{ + Endpoint: server.URL, + AccessKey: "ak", + AccessKeySecret: "sk", + Bucket: "bucket", + Region: "eu-west-1", + }, S3Option{ForcePathStyle: true, InitBucket: true}) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(body, "eu-west-1") { + t.Fatalf("create bucket body = %q", body) + } +} + +func TestInitBucketSetsPublicReadPolicyForPermanentPrefixes(t *testing.T) { + var policy string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.RawQuery == "policy=" || r.URL.RawQuery == "policy" { + data, _ := io.ReadAll(r.Body) + policy = string(data) + } + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + _, err := NewS3Compatible(context.Background(), config.ObjectStorageConfig{ + Endpoint: server.URL, + AccessKey: "ak", + AccessKeySecret: "sk", + Bucket: "bucket", + AvatarPrefix: "avatar", + SpecPrefix: "spec", + RepoPrefix: "repo", + TempPrefix: "temp", + }, S3Option{ForcePathStyle: true, InitBucket: true}) + if err != nil { + t.Fatal(err) + } + for _, want := range []string{ + `"Principal":{"AWS":["*"]}`, + `"arn:aws:s3:::bucket/avatar/*"`, + `"arn:aws:s3:::bucket/spec/*"`, + `"arn:aws:s3:::bucket/repo/*"`, + } { + if !strings.Contains(policy, want) { + t.Fatalf("policy missing %s: %s", want, policy) + } + } + if strings.Contains(policy, "temp") { + t.Fatalf("policy exposes temp prefix: %s", policy) + } +} + +func TestPublicReadResourcesSkipsEmptyAndDuplicatePrefixes(t *testing.T) { + got := publicReadResources(config.ObjectStorageConfig{ + Bucket: "bucket", + AvatarPrefix: "avatar", + SpecPrefix: "/avatar/", + RepoPrefix: "", + TempPrefix: "temp", + }) + if len(got) != 1 { + t.Fatalf("resources = %#v", got) + } + if got[0] != "arn:aws:s3:::bucket/avatar/*" { + t.Fatalf("resource = %q", got[0]) + } +} diff --git a/backend/pkg/register.go b/backend/pkg/register.go index 24b97b73..bcbb7aa4 100644 --- a/backend/pkg/register.go +++ b/backend/pkg/register.go @@ -59,7 +59,8 @@ func RegisterInfra(i *do.Injector, w ...*web.Web) error { do.ProvideValue(i, w[0]) } else { do.Provide(i, func(i *do.Injector) (*web.Web, error) { - return web.New(), nil + w := web.New() + return w, nil }) } diff --git a/backend/pkg/vmstatus/status.go b/backend/pkg/vmstatus/status.go index a107ebd2..154e7553 100644 --- a/backend/pkg/vmstatus/status.go +++ b/backend/pkg/vmstatus/status.go @@ -33,9 +33,7 @@ func Resolve(input Input) taskflow.VirtualMachineStatus { return taskflow.VirtualMachineStatusOffline case etypes.ConditionTypeHibernated: - if last.Reason == "Hibernated" { - return taskflow.VirtualMachineStatusHibernated - } + return taskflow.VirtualMachineStatusHibernated } } diff --git a/backend/scripts/build-offline-package.sh b/backend/scripts/build-offline-package.sh new file mode 100755 index 00000000..5bc45e78 --- /dev/null +++ b/backend/scripts/build-offline-package.sh @@ -0,0 +1,188 @@ +#!/bin/sh +set -eu + +ARCH="${ARCH:-amd64}" +DOCKER_VERSION="${DOCKER_VERSION:-29.4.3}" +DOCKER_COMPOSE_VERSION="${DOCKER_COMPOSE_VERSION:-v5.1.3}" +PROJECT_TPL_URL="${PROJECT_TPL_URL:-https://baizhiyun.oss-cn-hangzhou.aliyuncs.com/codingmatrix/project-tpl/codingmatrix-project-tpl.master.zip}" +OUT_DIR="${OUT_DIR:-dist/offline}" +PACKAGE_NAME="monkeycode-offline-linux-$ARCH" +PACKAGE_DIR="$OUT_DIR/$PACKAGE_NAME" +PACKAGE_TGZ="${PACKAGE_TGZ:-false}" +GOCACHE="${GOCACHE:-/root/.cache/go-build}" +GOMODCACHE="${GOMODCACHE:-/go/pkg/mod}" +REPO_COMMIT="${REPO_COMMIT:-$(git rev-parse HEAD 2>/dev/null || echo unknown)}" +IMAGE_TAG="${IMAGE_TAG:-$(git describe --tags --exact-match 2>/dev/null || git rev-parse --short HEAD 2>/dev/null || echo unknown)}" +BACKEND_IMAGE="${BACKEND_IMAGE:-chaitin-registry.cn-hangzhou.cr.aliyuncs.com/monkeycode/backend:$IMAGE_TAG}" +FRONTEND_IMAGE="${FRONTEND_IMAGE:-ghcr.1ms.run/chaitin/monkeycode-frontend:$IMAGE_TAG}" +INGRESS_IMAGE="${INGRESS_IMAGE:-chaitin-registry.cn-hangzhou.cr.aliyuncs.com/monkeycode/ingress:$IMAGE_TAG}" +TASKFLOW_IMAGE="${TASKFLOW_IMAGE:-chaitin-registry.cn-hangzhou.cr.aliyuncs.com/monkeycode/taskflow:d6d33f3}" +PREVIEW_IMAGE="${PREVIEW_IMAGE:-chaitin-registry.cn-hangzhou.cr.aliyuncs.com/monkeycode/preview-relay:2502c9d}" +POSTGRES_IMAGE="${POSTGRES_IMAGE:-chaitin-registry.cn-hangzhou.cr.aliyuncs.com/basic/postgres:17.4-alpine3.21}" +REDIS_IMAGE="${REDIS_IMAGE:-chaitin-registry.cn-hangzhou.cr.aliyuncs.com/basic/redis:8.0-alpine3.21}" +CLICKHOUSE_IMAGE="${CLICKHOUSE_IMAGE:-chaitin-registry.cn-hangzhou.cr.aliyuncs.com/basic/clickhouse-server:26.3.9}" +RUSTFS_IMAGE="${RUSTFS_IMAGE:-chaitin-registry.cn-hangzhou.cr.aliyuncs.com/basic/rustfs:1.0.0-beta.2}" +ORCHESTRATOR_IMAGE="${ORCHESTRATOR_IMAGE:-chaitin-registry.cn-hangzhou.cr.aliyuncs.com/monkeycode/codingmatrix-orchestrator:alpha-latest}" +DEVBOX_IMAGE="${DEVBOX_IMAGE:-ghcr.io/chaitin/monkeycode-runner/devbox:latest}" +HTTP_PROXY="${HTTP_PROXY:-${http_proxy:-}}" +HTTPS_PROXY="${HTTPS_PROXY:-${https_proxy:-}}" +NO_PROXY="${NO_PROXY:-${no_proxy:-}}" +export HTTP_PROXY HTTPS_PROXY NO_PROXY +export http_proxy="$HTTP_PROXY" https_proxy="$HTTPS_PROXY" no_proxy="$NO_PROXY" + +case "$ARCH" in + amd64) + CENTER_GOARCH="amd64" + CENTER_DOCKER_ARCH="x86_64" + ;; + arm64) + CENTER_GOARCH="arm64" + CENTER_DOCKER_ARCH="aarch64" + ;; + *) + echo "unsupported ARCH=$ARCH" + exit 1 + ;; +esac + +mkdir -p "$OUT_DIR" +rm -rf "$PACKAGE_DIR" +mkdir -p "$PACKAGE_DIR/images" "$PACKAGE_DIR/static/installer/x86_64" "$PACKAGE_DIR/static/installer/aarch64" + +CGO_ENABLED=0 GOOS=linux GOARCH="$CENTER_GOARCH" go build -o "$PACKAGE_DIR/installer" ./cmd/installer +CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o "$PACKAGE_DIR/static/installer/x86_64/installer" ./cmd/installer +CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -o "$PACKAGE_DIR/static/installer/aarch64/installer" ./cmd/installer + +curl -fL "https://download.docker.com/linux/static/stable/x86_64/docker-$DOCKER_VERSION.tgz" -o "$OUT_DIR/docker-x86_64.tgz" +curl -fL "https://download.docker.com/linux/static/stable/aarch64/docker-$DOCKER_VERSION.tgz" -o "$OUT_DIR/docker-aarch64.tgz" +curl -fL "https://github.com/docker/compose/releases/download/$DOCKER_COMPOSE_VERSION/docker-compose-linux-x86_64" -o "$OUT_DIR/docker-compose-linux-x86_64" +curl -fL "https://github.com/docker/compose/releases/download/$DOCKER_COMPOSE_VERSION/docker-compose-linux-aarch64" -o "$OUT_DIR/docker-compose-linux-aarch64" + +build_docker_bundle() { + arch="$1" + docker_arch="$2" + compose_arch="$3" + output="$4" + tmp="$OUT_DIR/docker-bundle-$arch" + rm -rf "$tmp" + mkdir -p "$tmp" + tar -zxf "$OUT_DIR/docker-$docker_arch.tgz" -C "$tmp" + cp "$OUT_DIR/docker-compose-linux-$compose_arch" "$tmp/docker-compose" + tar -C "$tmp" -czf "$output" . +} + +build_docker_bundle "$CENTER_DOCKER_ARCH" "$CENTER_DOCKER_ARCH" "$CENTER_DOCKER_ARCH" "$PACKAGE_DIR/docker.tgz" +build_docker_bundle "x86_64" "x86_64" "x86_64" "$PACKAGE_DIR/static/installer/x86_64/docker.tgz" +build_docker_bundle "aarch64" "aarch64" "aarch64" "$PACKAGE_DIR/static/installer/aarch64/docker.tgz" + +cp installation/center/install.sh "$PACKAGE_DIR/install.sh" +chmod +x "$PACKAGE_DIR/install.sh" +cp installation/center/.env.example "$PACKAGE_DIR/.env.example" +set_env_value() { + key="$1" + value="$2" + tmp="$PACKAGE_DIR/.env.example.tmp" + if grep -q "^$key=" "$PACKAGE_DIR/.env.example"; then + sed "s#^$key=.*#$key=$value#" "$PACKAGE_DIR/.env.example" > "$tmp" + else + cp "$PACKAGE_DIR/.env.example" "$tmp" + printf '%s=%s\n' "$key" "$value" >> "$tmp" + fi + mv "$tmp" "$PACKAGE_DIR/.env.example" +} + +set_env_value POSTGRES_IMAGE "$POSTGRES_IMAGE" +set_env_value REDIS_IMAGE "$REDIS_IMAGE" +set_env_value CLICKHOUSE_IMAGE "$CLICKHOUSE_IMAGE" +set_env_value RUSTFS_IMAGE "$RUSTFS_IMAGE" +set_env_value INGRESS_IMAGE "$INGRESS_IMAGE" +set_env_value TASKFLOW_IMAGE "$TASKFLOW_IMAGE" +set_env_value PREVIEW_IMAGE "$PREVIEW_IMAGE" +set_env_value FRONTEND_IMAGE "$FRONTEND_IMAGE" +set_env_value BACKEND_IMAGE "$BACKEND_IMAGE" +set_env_value INIT_TEAM_IMAGE "$DEVBOX_IMAGE" + +cp docker-compose.yml "$PACKAGE_DIR/docker-compose.yml" + +mkdir -p "$PACKAGE_DIR/static" +if [ -d static ]; then + cp -R static/. "$PACKAGE_DIR/static/" +fi +curl -fL "$PROJECT_TPL_URL" -o "$PACKAGE_DIR/static/project-tpl.zip" + +docker build \ + -f build/Dockerfile \ + --build-arg HTTP_PROXY="$HTTP_PROXY" \ + --build-arg HTTPS_PROXY="$HTTPS_PROXY" \ + --build-arg NO_PROXY="$NO_PROXY" \ + --build-arg http_proxy="$HTTP_PROXY" \ + --build-arg https_proxy="$HTTPS_PROXY" \ + --build-arg no_proxy="$NO_PROXY" \ + --build-arg GOCACHE="$GOCACHE" \ + --build-arg GOMODCACHE="$GOMODCACHE" \ + --build-arg REPO_COMMIT="$REPO_COMMIT" \ + --build-arg BUILD_TARGET=server \ + -t "$BACKEND_IMAGE" \ + . +docker build \ + -f build/Dockerfile.ingress \ + --build-arg HTTP_PROXY="$HTTP_PROXY" \ + --build-arg HTTPS_PROXY="$HTTPS_PROXY" \ + --build-arg NO_PROXY="$NO_PROXY" \ + --build-arg http_proxy="$HTTP_PROXY" \ + --build-arg https_proxy="$HTTPS_PROXY" \ + --build-arg no_proxy="$NO_PROXY" \ + -t "$INGRESS_IMAGE" \ + . +docker build \ + -f ../frontend/docker/Dockerfile \ + --build-arg HTTP_PROXY="$HTTP_PROXY" \ + --build-arg HTTPS_PROXY="$HTTPS_PROXY" \ + --build-arg NO_PROXY="$NO_PROXY" \ + --build-arg http_proxy="$HTTP_PROXY" \ + --build-arg https_proxy="$HTTPS_PROXY" \ + --build-arg no_proxy="$NO_PROXY" \ + -t "$FRONTEND_IMAGE" \ + ../frontend/docker + +docker pull "$TASKFLOW_IMAGE" +docker pull "$PREVIEW_IMAGE" +docker pull "$POSTGRES_IMAGE" +docker pull "$REDIS_IMAGE" +docker pull "$CLICKHOUSE_IMAGE" +docker pull "$RUSTFS_IMAGE" +docker pull "$ORCHESTRATOR_IMAGE" +docker pull "$DEVBOX_IMAGE" + +docker save "$BACKEND_IMAGE" | gzip > "$PACKAGE_DIR/images/backend.tar.gz" +docker save "$FRONTEND_IMAGE" | gzip > "$PACKAGE_DIR/images/frontend.tar.gz" +docker save "$INGRESS_IMAGE" | gzip > "$PACKAGE_DIR/images/ingress.tar.gz" +docker save "$TASKFLOW_IMAGE" | gzip > "$PACKAGE_DIR/images/taskflow.tar.gz" +docker save "$PREVIEW_IMAGE" | gzip > "$PACKAGE_DIR/images/preview.tar.gz" +docker save "$POSTGRES_IMAGE" | gzip > "$PACKAGE_DIR/images/postgres.tar.gz" +docker save "$REDIS_IMAGE" | gzip > "$PACKAGE_DIR/images/redis.tar.gz" +docker save "$CLICKHOUSE_IMAGE" | gzip > "$PACKAGE_DIR/images/clickhouse.tar.gz" +docker save "$RUSTFS_IMAGE" | gzip > "$PACKAGE_DIR/images/rustfs.tar.gz" + +build_host_bundle() { + arch="$1" + tmp="$OUT_DIR/host-$arch" + rm -rf "$tmp" + mkdir -p "$tmp/images" + cp installation/runner/docker-compose.yml "$tmp/docker-compose.yml" + printf 'ORCHESTRATOR_IMAGE=%s\n' "$ORCHESTRATOR_IMAGE" > "$tmp/.env" + docker save "$ORCHESTRATOR_IMAGE" | gzip > "$tmp/images/orchestrator.tar.gz" + docker save "$DEVBOX_IMAGE" | gzip > "$tmp/images/devbox.tar.gz" + tar -C "$tmp" -czf "$PACKAGE_DIR/static/installer/$arch/host.tgz" . +} + +build_host_bundle x86_64 +build_host_bundle aarch64 + +scripts/check-offline-package.sh "$PACKAGE_DIR" +if [ "$PACKAGE_TGZ" = "true" ]; then + tar -C "$OUT_DIR" -czf "$OUT_DIR/$PACKAGE_NAME.tgz" "$PACKAGE_NAME" + echo "$OUT_DIR/$PACKAGE_NAME.tgz" +else + echo "$PACKAGE_DIR" +fi diff --git a/backend/scripts/check-offline-package.sh b/backend/scripts/check-offline-package.sh new file mode 100755 index 00000000..4285a34e --- /dev/null +++ b/backend/scripts/check-offline-package.sh @@ -0,0 +1,35 @@ +#!/bin/sh +set -eu + +ROOT="${1:?package dir required}" + +required=" +install.sh +installer +docker.tgz +docker-compose.yml +.env.example +images/backend.tar.gz +images/frontend.tar.gz +images/taskflow.tar.gz +images/preview.tar.gz +images/ingress.tar.gz +images/postgres.tar.gz +images/redis.tar.gz +images/clickhouse.tar.gz +images/rustfs.tar.gz +static/project-tpl.zip +static/installer/x86_64/installer +static/installer/x86_64/docker.tgz +static/installer/x86_64/host.tgz +static/installer/aarch64/installer +static/installer/aarch64/docker.tgz +static/installer/aarch64/host.tgz +" + +for file in $required; do + if [ ! -f "$ROOT/$file" ]; then + echo "missing $file" + exit 1 + fi +done diff --git a/backend/scripts/tests/offline_package_static_test.go b/backend/scripts/tests/offline_package_static_test.go new file mode 100644 index 00000000..29c21557 --- /dev/null +++ b/backend/scripts/tests/offline_package_static_test.go @@ -0,0 +1,173 @@ +package tests + +import ( + "os" + "strings" + "testing" +) + +func TestOfflineComposeImagesUseVariables(t *testing.T) { + content := readFile(t, "../../docker-compose.yml") + for _, want := range []string{ + "image: ${POSTGRES_IMAGE}", + "image: ${REDIS_IMAGE}", + "image: ${CLICKHOUSE_IMAGE}", + "image: ${RUSTFS_IMAGE}", + "image: ${INGRESS_IMAGE}", + "image: ${TASKFLOW_IMAGE}", + "image: ${FRONTEND_IMAGE}", + "image: ${BACKEND_IMAGE}", + "image: ${PREVIEW_IMAGE}", + } { + if !strings.Contains(content, want) { + t.Fatalf("docker-compose.yml missing %q", want) + } + } + for _, forbidden := range []string{ + "image: chaitin-registry.cn-hangzhou.cr.aliyuncs.com", + "image: ghcr.1ms.run", + } { + if strings.Contains(content, forbidden) { + t.Fatalf("docker-compose.yml still contains fixed image %q", forbidden) + } + } +} + +func TestOfflineComposePassesInitTeamImageToBackend(t *testing.T) { + content := readFile(t, "../../docker-compose.yml") + if !strings.Contains(content, "MCAI_INIT_TEAM_IMAGE: ${INIT_TEAM_IMAGE:-}") { + t.Fatalf("docker-compose.yml missing MCAI_INIT_TEAM_IMAGE backend env") + } +} + +func TestOfflineComposeUsesInstallDirBindMounts(t *testing.T) { + content := readFile(t, "../../docker-compose.yml") + for _, want := range []string{ + "${INSTALL_DIR}/data/postgres:/var/lib/postgresql/data", + "${INSTALL_DIR}/data/redis:/data", + "${INSTALL_DIR}/data/clickhouse:/var/lib/clickhouse", + "${INSTALL_DIR}/logs/clickhouse:/var/log/clickhouse-server", + "${INSTALL_DIR}/data/rustfs:/data", + "${INSTALL_DIR}/logs/rustfs:/app/logs", + "${INSTALL_DIR}/tls:/etc/tls", + "${INSTALL_DIR}/static:/app/static", + } { + if !strings.Contains(content, want) { + t.Fatalf("docker-compose.yml missing bind mount %q", want) + } + } + for _, forbidden := range []string{ + "pg_data:", + "redis_data:", + "ch_data:", + "rustfs_data:", + "- ./logs/", + "- ./tls:", + "- ./static:", + } { + if strings.Contains(content, forbidden) { + t.Fatalf("docker-compose.yml still contains %q", forbidden) + } + } +} + +func TestOfflineComposeConfiguresPreviewRelay(t *testing.T) { + content := readFile(t, "../../docker-compose.yml") + for _, want := range []string{ + "container_name: monkeycode-ai-preview", + "network_mode: host", + "RELAY_DOMAIN: ${REMOTE_IP}", + "RELAY_TUNNEL_PORT_RANGES: 30000-50000", + "RELAY_JWT_SECRET: ${RELAY_SECRET}", + "MCAI_JWT_SECRET: ${RELAY_SECRET}", + "MCAI_JWT_RELAY_HOST: ${REMOTE_IP}", + "MCAI_JWT_RELAY_PORT: 7000", + "MCAI_JWT_RELAY_USE_TLS: false", + } { + if !strings.Contains(content, want) { + t.Fatalf("docker-compose.yml missing %q", want) + } + } +} + +func TestOfflinePackageScriptDoesNotRetagImagesToLocal(t *testing.T) { + content := readFile(t, "../build-offline-package.sh") + for _, forbidden := range []string{"docker tag ", ":local", "monkeycode-offline/backend:local"} { + if strings.Contains(content, forbidden) { + t.Fatalf("build-offline-package.sh still contains %q", forbidden) + } + } + for _, want := range []string{ + "IMAGE_TAG=\"${IMAGE_TAG:-$(git describe --tags --exact-match", + "-t \"$BACKEND_IMAGE\"", + "docker save \"$BACKEND_IMAGE\"", + "set_env_value BACKEND_IMAGE \"$BACKEND_IMAGE\"", + } { + if !strings.Contains(content, want) { + t.Fatalf("build-offline-package.sh missing %q", want) + } + } +} + +func TestOfflinePackageIncludesPreviewImage(t *testing.T) { + content := readFile(t, "../build-offline-package.sh") + for _, want := range []string{ + `PREVIEW_IMAGE="${PREVIEW_IMAGE:-chaitin-registry.cn-hangzhou.cr.aliyuncs.com/monkeycode/preview-relay:2502c9d}"`, + `set_env_value PREVIEW_IMAGE "$PREVIEW_IMAGE"`, + `docker pull "$PREVIEW_IMAGE"`, + `docker save "$PREVIEW_IMAGE" | gzip > "$PACKAGE_DIR/images/preview.tar.gz"`, + } { + if !strings.Contains(content, want) { + t.Fatalf("build-offline-package.sh missing %q", want) + } + } +} + +func TestOfflinePackageCheckRequiresPreviewImage(t *testing.T) { + content := readFile(t, "../check-offline-package.sh") + if !strings.Contains(content, "images/preview.tar.gz") { + t.Fatalf("check-offline-package.sh missing preview image requirement") + } +} + +func TestCenterEnvExampleIncludesPreviewRelaySettings(t *testing.T) { + content := readFile(t, "../../installation/center/.env.example") + for _, want := range []string{ + "RELAY_SECRET=", + "PREVIEW_IMAGE=", + } { + if !strings.Contains(content, want) { + t.Fatalf(".env.example missing %q", want) + } + } +} + +func TestOfflinePackageIncludesDevboxInHostBundle(t *testing.T) { + content := readFile(t, "../build-offline-package.sh") + for _, want := range []string{ + `DEVBOX_IMAGE="${DEVBOX_IMAGE:-ghcr.io/chaitin/monkeycode-runner/devbox:latest}"`, + `set_env_value INIT_TEAM_IMAGE "$DEVBOX_IMAGE"`, + `docker pull "$DEVBOX_IMAGE"`, + `docker save "$DEVBOX_IMAGE" | gzip > "$tmp/images/devbox.tar.gz"`, + } { + if !strings.Contains(content, want) { + t.Fatalf("build-offline-package.sh missing %q", want) + } + } +} + +func TestHostComposeImageUsesVariable(t *testing.T) { + content := readFile(t, "../../installation/runner/docker-compose.yml") + if !strings.Contains(content, "image: ${ORCHESTRATOR_IMAGE}") { + t.Fatalf("runner docker-compose.yml missing ORCHESTRATOR_IMAGE") + } +} + +func readFile(t *testing.T, path string) string { + t.Helper() + content, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + return string(content) +} diff --git a/backend/templates/install_offline.sh.tmpl b/backend/templates/install_offline.sh.tmpl new file mode 100644 index 00000000..e67fc6ec --- /dev/null +++ b/backend/templates/install_offline.sh.tmpl @@ -0,0 +1,79 @@ +#!/bin/bash + +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' + +set -e +trap 'handle_error $? $LINENO' ERR + +handle_error() { + local exit_code=$1 + local line_number=$2 + echo -e "${RED}Error occurred in script at line $line_number with exit code $exit_code${NC}" + cleanup + exit $exit_code +} + +ARCH=$(uname -m) +case "$ARCH" in + "x86_64"|"amd64") + ARCH="x86_64" + ;; + "aarch64"|"arm64"|"armv8l") + ARCH="aarch64" + ;; + *) + echo -e "${RED}This installer only supports amd64 (x86_64) and arm64 (aarch64) architectures${NC}" + echo -e "${RED}Current architecture: $ARCH${NC}" + exit 1 + ;; +esac + +if [[ $EUID -ne 0 ]]; then + echo -e "${RED}This script must be run as root${NC}" + exit 1 +fi + +TOKEN="{{.token}}" +GRPC_URL="{{.grpc_url}}" +BASE_URL="{{.base_url}}" +INSTALLER_URL="{{.installer_url}}" +DOCKER_BUNDLE_PATH="{{.docker_bundle_path}}" +HOST_BUNDLE_PATH="{{.host_bundle_path}}" +INSTALLER_FILE="/tmp/monkeycode-installer" + +cleanup() { + echo -e "${YELLOW}Cleaning up temporary files...${NC}" + rm -f "$INSTALLER_FILE" +} + +if [[ -z "$BASE_URL" || -z "$INSTALLER_URL" ]]; then + echo -e "${RED}Installer URL is empty${NC}" + exit 1 +fi + +echo -e "${GREEN}Architecture: $ARCH${NC}" +echo -e "${GREEN}Starting MonkeyCode Installer Bootstrap${NC}" +echo -e "${YELLOW}Downloading installer...${NC}" + +INSTALLER_URL=${INSTALLER_URL//\{\{.arch\}\}/$ARCH} +DOCKER_BUNDLE_PATH=${DOCKER_BUNDLE_PATH//\{\{.arch\}\}/$ARCH} +HOST_BUNDLE_PATH=${HOST_BUNDLE_PATH//\{\{.arch\}\}/$ARCH} +if ! curl -4fLk --progress-bar -o "$INSTALLER_FILE" "$INSTALLER_URL"; then + echo -e "${RED}Failed to download installer${NC}" + exit 1 +fi + +chmod +x "$INSTALLER_FILE" +MCAI_BASE_URL="$BASE_URL" \ +MCAI_DOCKER_BUNDLE_PATH="$DOCKER_BUNDLE_PATH" \ +MCAI_HOST_BUNDLE_PATH="$HOST_BUNDLE_PATH" \ +MCAI_HOST_TOKEN="$TOKEN" \ +MCAI_TASKFLOW_GRPC_URL="$GRPC_URL" \ +"$INSTALLER_FILE" host + +echo -e "${GREEN}Installer exited successfully${NC}" +cleanup +exit 0 diff --git a/backend/templates/temp.go b/backend/templates/temp.go index 421d365c..a346fc0d 100644 --- a/backend/templates/temp.go +++ b/backend/templates/temp.go @@ -7,6 +7,9 @@ import ( //go:embed install.sh.tmpl var InstallTmpl []byte +//go:embed install_offline.sh.tmpl +var InstallOfflineTmpl []byte + //go:embed codex.tmpl var Codex []byte diff --git a/frontend/src/api/Api.ts b/frontend/src/api/Api.ts index 073e7b24..df5b9949 100644 --- a/frontend/src/api/Api.ts +++ b/frontend/src/api/Api.ts @@ -291,6 +291,16 @@ export interface DomainAddTeamUserResp { users?: DomainTeamUser[]; } +export interface DomainTeamUserPassword { + email?: string; + password?: string; +} + +export interface DomainAddTeamUserWithPasswordResp { + passwords?: DomainTeamUserPassword[]; + users?: DomainTeamUser[]; +} + export interface DomainApplyPortReq { /** 转发 id */ forward_id?: string; @@ -3355,6 +3365,31 @@ export class Api extends HttpClient + this.request< + GithubComGoYokoWebResp & { + data?: DomainAddTeamUserWithPasswordResp; + }, + GithubComGoYokoWebResp + >({ + path: `/api/v1/teams/users/with-password`, + method: "POST", + body: req, + secure: true, + type: ContentType.Json, + format: "json", + ...params, + }), + /** * @description 团队用户登录,password 字段需要传 MD5 加密后的值 * diff --git a/frontend/src/components/manager/team-members-card.tsx b/frontend/src/components/manager/team-members-card.tsx index 5afe1b15..a519716c 100644 --- a/frontend/src/components/manager/team-members-card.tsx +++ b/frontend/src/components/manager/team-members-card.tsx @@ -6,7 +6,7 @@ import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from " import { Field, FieldContent, FieldLabel } from "@/components/ui/field"; import { Textarea } from "@/components/ui/textarea"; import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from "@/components/ui/alert-dialog"; -import { IconCirclePlus, IconDotsVertical, IconForbid, IconLockCode, IconUser, IconUserCircle, IconCheck } from "@tabler/icons-react"; +import { IconCirclePlus, IconCopy, IconDotsVertical, IconForbid, IconLockCode, IconUser, IconUserCircle, IconCheck } from "@tabler/icons-react"; import { Fragment, useState } from "react"; import { Separator } from "@/components/ui/separator"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; @@ -28,6 +28,8 @@ export default function TeamMembersCard({ members, memberLimit, groups, onRefres const [emails, setEmails] = useState(""); const [selectedGroupId, setSelectedGroupId] = useState(""); const [submitting, setSubmitting] = useState(false); + const [generatedPasswords, setGeneratedPasswords] = useState<{ email?: string; password?: string }[]>([]); + const [passwordDialogOpen, setPasswordDialogOpen] = useState(false); const [resetPasswordDialogOpen, setResetPasswordDialogOpen] = useState(false); const [resettingPasswordEmail, setResettingPasswordEmail] = useState(""); const [resettingPassword, setResettingPassword] = useState(false); @@ -69,12 +71,15 @@ export default function TeamMembersCard({ members, memberLimit, groups, onRefres } setSubmitting(true); - await apiRequest('v1TeamsUsersCreate', { + await apiRequest('v1TeamsUsersWithPasswordCreate', { emails: emailList, group_id: selectedGroupId || undefined, }, [], (resp) => { if (resp.code === 0) { toast.success("成员添加成功"); + const passwords = resp.data?.passwords || []; + setGeneratedPasswords(passwords); + setPasswordDialogOpen(passwords.length > 0); setAddMemberDialogOpen(false); setEmails(""); setSelectedGroupId(""); @@ -86,6 +91,18 @@ export default function TeamMembersCard({ members, memberLimit, groups, onRefres setSubmitting(false); }; + const handleCopyGeneratedPasswords = async () => { + const text = generatedPasswords + .map(item => `${item.email || ""}\t${item.password || ""}`) + .join("\n"); + try { + await navigator.clipboard.writeText(text); + toast.success("初始密码已复制到剪贴板"); + } catch { + toast.error("复制失败,请手动复制"); + } + }; + const handleCancelAddMember = () => { setAddMemberDialogOpen(false); setEmails(""); @@ -262,6 +279,31 @@ export default function TeamMembersCard({ members, memberLimit, groups, onRefres + + + + 成员初始密码 + +
+ {generatedPasswords.map((item) => ( +
+
{item.email}
+
{item.password}
+
+ ))} +
+ + + + +
+
+