diff --git a/.github/workflows/offline-package.yml b/.github/workflows/offline-package.yml
index c97ccfda..df2271b0 100644
--- a/.github/workflows/offline-package.yml
+++ b/.github/workflows/offline-package.yml
@@ -8,6 +8,9 @@ on:
required: false
default: "29.0.4"
+env:
+ FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
+
jobs:
package:
strategy:
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/Installation/center/.env.example b/backend/Installation/center/.env.example
new file mode 100644
index 00000000..932382c2
--- /dev/null
+++ b/backend/Installation/center/.env.example
@@ -0,0 +1,15 @@
+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=
+SUBNET_PREFIX=10.100.50
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..662a7f66
--- /dev/null
+++ b/backend/Installation/runner/docker-compose.yml
@@ -0,0 +1,19 @@
+name: monkeycode_runner
+services:
+ orchestrator:
+ image: chaitin-registry.cn-hangzhou.cr.aliyuncs.com/monkeycode/codingmatrix-orchestrator:alpha-latest
+ 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
\ 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..7b76d12f 100644
--- a/backend/biz/register.go
+++ b/backend/biz/register.go
@@ -6,12 +6,15 @@ import (
"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/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"
)
@@ -19,7 +22,6 @@ import (
// RegisterAll 注册所有 biz 模块
// 分两阶段:先 Provide(懒注册),再 Invoke(解析依赖),避免模块间循环依赖
func RegisterAll(i *do.Injector) error {
- // 阶段一:所有模块注册服务工厂(do.Provide,不触发依赖解析)
notify.ProvideNotify(i)
public.ProvidePublic(i)
user.ProvideUser(i)
@@ -31,8 +33,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 +48,17 @@ func RegisterAll(i *do.Injector) error {
project.InvokeProject(i)
file.InvokeFile(i)
vmidle.InvokeVMIdle(i)
+}
- return nil
+// RegisterOpenSource 注册仅在开源项目中使用的模块
+func RegisterOpenSource(i *do.Injector) {
+ uploader.ProvideUploader(i)
+ llmproxy.ProvideLLMProxy(i)
+ static.ProviderStatic(i)
+}
+
+func InvokeOpenSource(i *do.Injector) {
+ uploader.InvokeUploader(i)
+ llmproxy.InvokeLLMProxy(i)
+ static.InvokeStatic(i)
}
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/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/center.go b/backend/cmd/installer/center.go
new file mode 100644
index 00000000..ce81587a
--- /dev/null
+++ b/backend/cmd/installer/center.go
@@ -0,0 +1,336 @@
+package main
+
+import (
+ "context"
+ "fmt"
+ "os"
+ "path/filepath"
+ "strings"
+
+ "github.com/charmbracelet/bubbles/spinner"
+ tea "github.com/charmbracelet/bubbletea"
+ "github.com/charmbracelet/huh"
+
+ "github.com/chaitin/MonkeyCode/backend/pkg/installer"
+)
+
+const defaultCenterInstallDir = "/data/monkeycode-ai"
+
+type centerStep int
+
+const (
+ centerStepCheck centerStep = iota
+ centerStepForm
+ centerStepConfirmDocker
+ centerStepRun
+ centerStepDone
+)
+
+type centerModel struct {
+ ctx context.Context
+ runner installer.Runner
+ spinner spinner.Model
+ step centerStep
+ status installer.DockerStatus
+ form *huh.Form
+ installDir string
+ accessHost string
+ nginxPort string
+ teamEmail string
+ teamName string
+ teamPassword string
+ installDocker bool
+ action string
+ err error
+ result centerInstallResult
+}
+
+type centerStatusMsg installer.DockerStatus
+type centerDoneMsg struct {
+ err error
+ result centerInstallResult
+}
+
+type centerInstallResult struct {
+ URL string
+ AdminEmail string
+ AdminPassword string
+}
+
+func newCenterModel(ctx context.Context) *centerModel {
+ sp := spinner.New()
+ sp.Spinner = spinner.Dot
+ return ¢erModel{
+ ctx: ctx,
+ runner: installer.CommandRunner{},
+ spinner: sp,
+ step: centerStepCheck,
+ installDir: defaultCenterInstallDir,
+ nginxPort: "80",
+ teamName: "MonkeyCode",
+ }
+}
+
+func (m *centerModel) Init() tea.Cmd {
+ return tea.Batch(m.spinner.Tick, m.checkDocker)
+}
+
+func (m *centerModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+ switch msg := msg.(type) {
+ case tea.KeyMsg:
+ if msg.String() == "ctrl+c" {
+ return m, tea.Quit
+ }
+ case centerStatusMsg:
+ m.status = installer.DockerStatus(msg)
+ if !m.status.Ready() {
+ m.step = centerStepConfirmDocker
+ m.installDocker = true
+ m.form = m.confirmDockerForm()
+ return m, m.form.Init()
+ }
+ m.step = centerStepForm
+ m.form = m.centerForm()
+ return m, m.form.Init()
+ case centerDoneMsg:
+ m.err = msg.err
+ m.result = msg.result
+ m.step = centerStepDone
+ if msg.err == nil {
+ return m, nil
+ }
+ return m, nil
+ case spinner.TickMsg:
+ var cmd tea.Cmd
+ m.spinner, cmd = m.spinner.Update(msg)
+ return m, cmd
+ }
+ if m.form != nil {
+ form, cmd := m.form.Update(msg)
+ m.form = form.(*huh.Form)
+ if m.form.State == huh.StateCompleted {
+ m.form = nil
+ return m.nextAfterForm()
+ }
+ if m.form.State == huh.StateAborted {
+ return m, tea.Quit
+ }
+ return m, cmd
+ }
+ return m, nil
+}
+
+func (m *centerModel) View() string {
+ var b strings.Builder
+ b.WriteString(titleStyle.Render("MonkeyCode 中心端安装器"))
+ b.WriteString("\n\n")
+ switch m.step {
+ case centerStepCheck:
+ b.WriteString(m.spinner.View())
+ b.WriteString(" 正在检测 Docker 环境...\n")
+ case centerStepForm, centerStepConfirmDocker:
+ b.WriteString(renderStatus(m.status))
+ b.WriteString("\n")
+ if m.form != nil {
+ b.WriteString(m.form.View())
+ b.WriteString("\n")
+ }
+ case centerStepRun:
+ b.WriteString(m.spinner.View())
+ b.WriteString(" " + m.action + "...\n")
+ case centerStepDone:
+ if m.err != nil {
+ b.WriteString(errStyle.Render(m.action + "失败: " + m.err.Error()))
+ } else {
+ b.WriteString(okStyle.Render(m.action + "完成。"))
+ if m.result.URL != "" {
+ b.WriteString("\n\n")
+ b.WriteString("访问地址: " + m.result.URL + "\n")
+ b.WriteString("管理员账号: " + m.result.AdminEmail + "\n")
+ b.WriteString("管理员密码: " + m.result.AdminPassword + "\n")
+ }
+ }
+ b.WriteString("\n")
+ }
+ return b.String()
+}
+
+func (m *centerModel) checkDocker() tea.Msg {
+ return centerStatusMsg(installer.CheckDockerStatus(m.ctx, m.runner))
+}
+
+func (m *centerModel) confirmDockerForm() *huh.Form {
+ return huh.NewForm(huh.NewGroup(
+ huh.NewConfirm().
+ Title("安装 Docker/Compose").
+ Description("当前 Docker 环境不完整,将使用离线包内 docker.tgz 安装静态二进制。").
+ Affirmative("开始安装").
+ Negative("取消").
+ Value(&m.installDocker),
+ ))
+}
+
+func (m *centerModel) centerForm() *huh.Form {
+ return huh.NewForm(huh.NewGroup(
+ huh.NewInput().
+ Title("中心端安装目录").
+ Placeholder(defaultCenterInstallDir).
+ Value(&m.installDir).
+ Validate(validateAbsPath),
+ huh.NewInput().
+ Title("中心端访问地址").
+ Description("请输入用户和宿主机能访问到的 IP 或域名。").
+ Value(&m.accessHost).
+ Validate(func(value string) error {
+ if strings.TrimSpace(value) == "" {
+ return fmt.Errorf("请输入中心端访问地址")
+ }
+ return nil
+ }),
+ huh.NewInput().
+ Title("Nginx HTTP 端口").
+ Value(&m.nginxPort).
+ Validate(func(value string) error {
+ if strings.TrimSpace(value) == "" {
+ return fmt.Errorf("请输入 Nginx 端口")
+ }
+ return nil
+ }),
+ huh.NewInput().
+ Title("管理员邮箱").
+ Value(&m.teamEmail).
+ Validate(func(value string) error {
+ if strings.TrimSpace(value) == "" {
+ return fmt.Errorf("请输入管理员邮箱")
+ }
+ return nil
+ }),
+ huh.NewInput().
+ Title("团队名称").
+ Value(&m.teamName),
+ huh.NewInput().
+ Title("管理员密码").
+ Description("留空时自动生成随机密码。").
+ Value(&m.teamPassword),
+ ))
+}
+
+func validateAbsPath(value string) error {
+ if strings.TrimSpace(value) == "" {
+ return nil
+ }
+ if !filepath.IsAbs(strings.TrimSpace(value)) {
+ return fmt.Errorf("请输入绝对路径")
+ }
+ return nil
+}
+
+func (m *centerModel) nextAfterForm() (tea.Model, tea.Cmd) {
+ switch m.step {
+ case centerStepConfirmDocker:
+ if !m.installDocker {
+ m.step = centerStepDone
+ m.err = fmt.Errorf("已取消安装 Docker/Compose")
+ return m, nil
+ }
+ m.step = centerStepForm
+ m.form = m.centerForm()
+ return m, m.form.Init()
+ case centerStepForm:
+ m.installDir = strings.TrimSpace(m.installDir)
+ if m.installDir == "" {
+ m.installDir = defaultCenterInstallDir
+ }
+ m.accessHost = strings.TrimSpace(m.accessHost)
+ m.nginxPort = strings.TrimSpace(m.nginxPort)
+ m.teamEmail = strings.TrimSpace(m.teamEmail)
+ m.teamName = strings.TrimSpace(m.teamName)
+ m.teamPassword = strings.TrimSpace(m.teamPassword)
+ m.step = centerStepRun
+ m.action = "安装中心端"
+ return m, m.installCenterFlow
+ }
+ return m, nil
+}
+
+func (m *centerModel) installCenterFlow() tea.Msg {
+ pkgDir := packageDir()
+ if !m.status.Ready() {
+ if err := installer.InstallDockerFromLocalBundle(m.ctx, m.runner, centerDockerPlan(pkgDir)); err != nil {
+ return centerDoneMsg{err: err}
+ }
+ status := installer.CheckDockerStatus(m.ctx, m.runner)
+ if !status.Ready() {
+ return centerDoneMsg{err: fmt.Errorf("Docker 环境仍未就绪")}
+ }
+ }
+ env, err := installer.NewCenterEnv(m.centerEnvInput())
+ if err != nil {
+ return centerDoneMsg{err: err}
+ }
+ if err := installer.PrepareCenterFiles(m.ctx, m.runner, installer.CenterFilesPlan{
+ PackageDir: pkgDir,
+ WorkDir: m.installDir,
+ Env: env,
+ }); err != nil {
+ return centerDoneMsg{err: err}
+ }
+ plan := centerPlan(m.installDir, m.accessHost)
+ if err := installer.GenerateSelfSignedTLS(plan.TLS); err != nil {
+ return centerDoneMsg{err: err}
+ }
+ if err := installer.InstallCenter(m.ctx, m.runner, plan); err != nil {
+ return centerDoneMsg{err: err}
+ }
+ return centerDoneMsg{result: centerInstallResult{
+ URL: centerAccessURL(env.AccessHost, env.NginxPort),
+ AdminEmail: env.TeamEmail,
+ AdminPassword: env.TeamPassword,
+ }}
+}
+
+func centerDockerPlan(packageDir string) installer.DockerInstallPlan {
+ return installer.DockerInstallPlan{
+ WorkDir: "/tmp/monkeycode-installer",
+ BundleFile: filepath.Join(packageDir, "docker.tgz"),
+ }
+}
+
+func centerPlan(workDir, accessHost string) installer.CenterInstallPlan {
+ return installer.CenterInstallPlan{
+ WorkDir: workDir,
+ ComposeFile: filepath.Join(workDir, "docker-compose.yml"),
+ EnvFile: filepath.Join(workDir, ".env"),
+ TLS: installer.TLSPlan{
+ Host: accessHost,
+ CertFile: filepath.Join(workDir, "tls", "server.crt"),
+ KeyFile: filepath.Join(workDir, "tls", "server.key"),
+ },
+ Images: scanImages(filepath.Join(workDir, "images")),
+ }
+}
+
+func (m *centerModel) centerEnvInput() installer.CenterEnvInput {
+ return installer.CenterEnvInput{
+ AccessHost: m.accessHost,
+ NginxPort: m.nginxPort,
+ TeamEmail: m.teamEmail,
+ TeamName: m.teamName,
+ TeamPassword: m.teamPassword,
+ }
+}
+
+func centerAccessURL(host, port string) string {
+ if port == "" || port == "80" {
+ return "http://" + host
+ }
+ return "http://" + host + ":" + port
+}
+
+func packageDir() string {
+ exe, err := os.Executable()
+ if err != nil {
+ return "."
+ }
+ return filepath.Dir(exe)
+}
diff --git a/backend/cmd/installer/center_test.go b/backend/cmd/installer/center_test.go
new file mode 100644
index 00000000..f75dab40
--- /dev/null
+++ b/backend/cmd/installer/center_test.go
@@ -0,0 +1,105 @@
+package main
+
+import (
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "github.com/chaitin/MonkeyCode/backend/pkg/installer"
+)
+
+func TestCenterPlanUsesSelectedInstallDirAndAccessHost(t *testing.T) {
+ plan := centerPlan("/data/monkeycode-ai", "192.168.1.10")
+
+ if plan.WorkDir != "/data/monkeycode-ai" {
+ t.Fatalf("WorkDir = %q", plan.WorkDir)
+ }
+ if plan.ComposeFile != "/data/monkeycode-ai/docker-compose.yml" {
+ t.Fatalf("ComposeFile = %q", plan.ComposeFile)
+ }
+ if plan.EnvFile != "/data/monkeycode-ai/.env" {
+ t.Fatalf("EnvFile = %q", plan.EnvFile)
+ }
+ if plan.TLS.Host != "192.168.1.10" {
+ t.Fatalf("TLS.Host = %q", plan.TLS.Host)
+ }
+ if plan.TLS.CertFile != "/data/monkeycode-ai/tls/server.crt" {
+ t.Fatalf("TLS.CertFile = %q", plan.TLS.CertFile)
+ }
+}
+
+func TestCenterDockerPlanUsesLocalBundle(t *testing.T) {
+ plan := centerDockerPlan("/pkg")
+
+ if plan.BundleFile != filepath.Join("/pkg", "docker.tgz") {
+ t.Fatalf("BundleFile = %q", plan.BundleFile)
+ }
+ if plan.WorkDir != "/tmp/monkeycode-installer" {
+ t.Fatalf("WorkDir = %q", plan.WorkDir)
+ }
+}
+
+func TestCenterModelDefaults(t *testing.T) {
+ m := newCenterModel(t.Context())
+
+ if m.installDir != defaultCenterInstallDir {
+ t.Fatalf("installDir = %q", m.installDir)
+ }
+ if m.nginxPort != "80" {
+ t.Fatalf("nginxPort = %q", m.nginxPort)
+ }
+ if m.teamName != "MonkeyCode" {
+ t.Fatalf("teamName = %q", m.teamName)
+ }
+ if m.runner == nil {
+ t.Fatal("runner is nil")
+ }
+}
+
+func TestCenterEnvInputUsesModelValues(t *testing.T) {
+ m := newCenterModel(t.Context())
+ m.accessHost = "example.com"
+ m.nginxPort = "8080"
+ m.teamEmail = "admin@example.com"
+ m.teamName = "Example"
+ m.teamPassword = "secret"
+
+ input := m.centerEnvInput()
+
+ if input.AccessHost != "example.com" {
+ t.Fatalf("AccessHost = %q", input.AccessHost)
+ }
+ if input.NginxPort != "8080" {
+ t.Fatalf("NginxPort = %q", input.NginxPort)
+ }
+ if input.TeamEmail != "admin@example.com" {
+ t.Fatalf("TeamEmail = %q", input.TeamEmail)
+ }
+ if input.TeamName != "Example" {
+ t.Fatalf("TeamName = %q", input.TeamName)
+ }
+ if input.TeamPassword != "secret" {
+ t.Fatalf("TeamPassword = %q", input.TeamPassword)
+ }
+}
+
+func TestCenterSuccessViewPrintsAccessAndAdmin(t *testing.T) {
+ m := newCenterModel(t.Context())
+ m.step = centerStepDone
+ m.action = "安装中心端"
+ m.result = centerInstallResult{
+ URL: "http://example.com:8080",
+ AdminEmail: "admin@example.com",
+ AdminPassword: "secret",
+ }
+
+ view := m.View()
+
+ for _, want := range []string{"http://example.com:8080", "admin@example.com", "secret"} {
+ if !strings.Contains(view, want) {
+ t.Fatalf("view missing %q: %q", want, view)
+ }
+ }
+}
+
+var _ = installer.CenterInstallPlan{}
diff --git a/backend/cmd/installer/main.go b/backend/cmd/installer/main.go
new file mode 100644
index 00000000..81db9779
--- /dev/null
+++ b/backend/cmd/installer/main.go
@@ -0,0 +1,531 @@
+package main
+
+import (
+ "context"
+ "fmt"
+ "os"
+ "path/filepath"
+ "runtime"
+ "sort"
+ "strings"
+
+ "github.com/charmbracelet/bubbles/progress"
+ "github.com/charmbracelet/bubbles/spinner"
+ tea "github.com/charmbracelet/bubbletea"
+ "github.com/charmbracelet/huh"
+ "github.com/charmbracelet/lipgloss"
+
+ "github.com/chaitin/MonkeyCode/backend/pkg/installer"
+)
+
+var (
+ titleStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("212"))
+ okStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("42"))
+ warnStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("214"))
+ errStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("196"))
+)
+
+type step int
+
+const (
+ stepCheck step = iota
+ stepHome
+ stepInstallDir
+ stepConfirmDocker
+ stepUninstallDir
+ stepConfirmUninstall
+ stepRun
+ stepDone
+)
+
+const defaultInstallDir = "/data/monkeycode_runner"
+
+type homeAction string
+
+const (
+ homeActionInstall homeAction = "install"
+ homeActionUninstall homeAction = "uninstall"
+ homeActionExit homeAction = "exit"
+)
+
+type installerMode string
+
+const (
+ modeCenter installerMode = "center"
+ modeHost installerMode = "host"
+)
+
+type model struct {
+ ctx context.Context
+ runner installer.Runner
+ spinner spinner.Model
+ prog progress.Model
+ config installConfig
+ step step
+ status installer.DockerStatus
+ form *huh.Form
+ home homeAction
+ installDir string
+ installDocker bool
+ uninstallHost bool
+ action string
+ detail string
+ err error
+ sender *programSender
+}
+
+type statusMsg installer.DockerStatus
+type actionDoneMsg struct{ err error }
+type progressMsg struct {
+ label string
+ progress installer.DownloadProgress
+}
+
+type programSender struct {
+ program *tea.Program
+}
+
+func (s *programSender) Send(msg tea.Msg) {
+ if s.program != nil {
+ s.program.Send(msg)
+ }
+}
+
+func newModel(ctx context.Context) *model {
+ sp := spinner.New()
+ sp.Spinner = spinner.Dot
+ return &model{
+ ctx: ctx,
+ runner: installer.CommandRunner{},
+ spinner: sp,
+ prog: progress.New(progress.WithWidth(46)),
+ config: loadConfig(),
+ step: stepCheck,
+ sender: &programSender{},
+ }
+}
+
+func parseMode(args []string) (installerMode, error) {
+ if len(args) <= 1 {
+ return modeHost, nil
+ }
+ switch installerMode(args[1]) {
+ case modeCenter:
+ return modeCenter, nil
+ case modeHost:
+ return modeHost, nil
+ default:
+ return "", fmt.Errorf("unknown installer mode %q, expected center or host", args[1])
+ }
+}
+
+func (m *model) setProgram(p *tea.Program) {
+ m.sender.program = p
+}
+
+func (m *model) Init() tea.Cmd {
+ return tea.Batch(m.spinner.Tick, m.checkDocker)
+}
+
+func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+ switch msg := msg.(type) {
+ case tea.KeyMsg:
+ switch msg.String() {
+ case "ctrl+c":
+ return m, tea.Quit
+ }
+ case statusMsg:
+ m.status = installer.DockerStatus(msg)
+ m.step = stepHome
+ m.form = m.homeForm()
+ return m, m.form.Init()
+ case progressMsg:
+ m.detail = msg.label
+ if msg.progress.Total <= 0 {
+ return m, nil
+ }
+ return m, m.prog.SetPercent(msg.progress.Percent())
+ case actionDoneMsg:
+ m.err = msg.err
+ m.step = stepDone
+ if msg.err == nil {
+ return m, tea.Quit
+ }
+ return m, nil
+ case spinner.TickMsg:
+ var cmd tea.Cmd
+ m.spinner, cmd = m.spinner.Update(msg)
+ return m, cmd
+ case progress.FrameMsg:
+ pm, cmd := m.prog.Update(msg)
+ m.prog = pm.(progress.Model)
+ return m, cmd
+ }
+ if m.form != nil {
+ var cmd tea.Cmd
+ form, formCmd := m.form.Update(msg)
+ m.form = form.(*huh.Form)
+ cmd = formCmd
+ if m.form.State == huh.StateCompleted {
+ m.form = nil
+ return m.nextAfterForm()
+ }
+ if m.form.State == huh.StateAborted {
+ return m, tea.Quit
+ }
+ return m, cmd
+ }
+ return m, nil
+}
+
+func (m *model) View() string {
+ var b strings.Builder
+ b.WriteString(titleStyle.Render("MonkeyCode 安装器"))
+ b.WriteString("\n\n")
+
+ switch m.step {
+ case stepCheck:
+ b.WriteString(m.spinner.View())
+ b.WriteString(" 正在检测 Docker 环境...\n")
+ case stepHome, stepInstallDir, stepConfirmDocker, stepUninstallDir, stepConfirmUninstall:
+ b.WriteString(renderStatus(m.status))
+ b.WriteString("\n")
+ if m.form != nil {
+ b.WriteString(m.form.View())
+ b.WriteString("\n")
+ }
+ case stepRun:
+ b.WriteString(m.spinner.View())
+ b.WriteString(" " + m.action + "...\n")
+ if m.detail != "" {
+ b.WriteString(m.detail)
+ b.WriteString("\n")
+ }
+ b.WriteString(m.prog.View())
+ b.WriteString("\n")
+ case stepDone:
+ if m.err != nil {
+ b.WriteString(errStyle.Render(m.action + "失败: " + m.err.Error()))
+ } else {
+ b.WriteString(okStyle.Render(m.action + "完成。"))
+ }
+ b.WriteString("\n")
+ }
+ return b.String()
+}
+
+func (m *model) checkDocker() tea.Msg {
+ return statusMsg(installer.CheckDockerStatus(m.ctx, m.runner))
+}
+
+func (m *model) homeForm() *huh.Form {
+ return huh.NewForm(
+ huh.NewGroup(
+ huh.NewSelect[homeAction]().
+ Title("请选择操作").
+ Options(homeOptions()...).
+ Value(&m.home),
+ ),
+ )
+}
+
+func homeOptions() []huh.Option[homeAction] {
+ return []huh.Option[homeAction]{
+ huh.NewOption("安装", homeActionInstall),
+ huh.NewOption("卸载", homeActionUninstall),
+ huh.NewOption("退出", homeActionExit),
+ }
+}
+
+func (m *model) installDirForm() *huh.Form {
+ if strings.TrimSpace(m.installDir) == "" {
+ m.installDir = defaultInstallDir
+ }
+ return huh.NewForm(
+ huh.NewGroup(
+ huh.NewInput().
+ Title("安装目录").
+ Description("宿主机服务文件、镜像包和 docker-compose.yml 将放在该目录。").
+ Placeholder(defaultInstallDir).
+ Value(&m.installDir).
+ Validate(validateAbsPath),
+ ),
+ )
+}
+
+func (m *model) confirmDockerForm() *huh.Form {
+ return huh.NewForm(
+ huh.NewGroup(
+ huh.NewConfirm().
+ Title("安装 Docker/Compose").
+ Description("当前 Docker 环境不完整,将下载 Docker 安装包并安装静态二进制。").
+ Affirmative("开始安装").
+ Negative("取消").
+ Value(&m.installDocker),
+ ),
+ )
+}
+
+func (m *model) confirmUninstallForm() *huh.Form {
+ return huh.NewForm(
+ huh.NewGroup(
+ huh.NewConfirm().
+ Title("卸载宿主机").
+ Description("将停止 MonkeyCode 宿主机服务并删除安装目录,不会卸载 Docker/Compose 或删除镜像。").
+ Affirmative("确认卸载").
+ Negative("取消").
+ Value(&m.uninstallHost),
+ ),
+ )
+}
+
+func (m *model) nextAfterForm() (tea.Model, tea.Cmd) {
+ switch m.step {
+ case stepHome:
+ switch m.home {
+ case homeActionInstall:
+ m.action = "安装宿主机"
+ m.detail = ""
+ m.err = nil
+ m.installDir = defaultInstallDir
+ if !m.status.Ready() {
+ m.step = stepConfirmDocker
+ m.installDocker = true
+ m.form = m.confirmDockerForm()
+ return m, m.form.Init()
+ }
+ m.step = stepInstallDir
+ m.form = m.installDirForm()
+ return m, m.form.Init()
+ case homeActionUninstall:
+ m.action = "卸载宿主机"
+ m.detail = ""
+ m.err = nil
+ m.installDir = defaultInstallDir
+ m.step = stepUninstallDir
+ m.form = m.installDirForm()
+ return m, m.form.Init()
+ case homeActionExit:
+ return m, tea.Quit
+ }
+ case stepConfirmDocker:
+ if !m.installDocker {
+ m.step = stepDone
+ m.err = fmt.Errorf("已取消安装 Docker/Compose")
+ return m, nil
+ }
+ m.step = stepInstallDir
+ m.form = m.installDirForm()
+ return m, m.form.Init()
+ case stepInstallDir:
+ m.installDir = strings.TrimSpace(m.installDir)
+ if m.installDir == "" {
+ m.installDir = defaultInstallDir
+ }
+ m.step = stepRun
+ m.detail = "准备安装流程"
+ return m, m.installHostFlow
+ case stepUninstallDir:
+ m.installDir = strings.TrimSpace(m.installDir)
+ if m.installDir == "" {
+ m.installDir = defaultInstallDir
+ }
+ m.uninstallHost = false
+ m.step = stepConfirmUninstall
+ m.form = m.confirmUninstallForm()
+ return m, m.form.Init()
+ case stepConfirmUninstall:
+ if !m.uninstallHost {
+ m.step = stepDone
+ m.err = fmt.Errorf("已取消卸载宿主机")
+ return m, nil
+ }
+ m.step = stepRun
+ m.detail = "准备卸载流程"
+ return m, m.uninstallHostFlow
+ }
+ return m, nil
+}
+
+func (m *model) installHostFlow() tea.Msg {
+ if !m.status.Ready() {
+ plan, err := m.config.dockerPlan()
+ if err != nil {
+ return actionDoneMsg{err: err}
+ }
+ if err := installer.InstallDockerWithProgress(m.ctx, m.runner, plan, m.sendProgress("下载 Docker 安装包")); err != nil {
+ return actionDoneMsg{err: err}
+ }
+ status := installer.CheckDockerStatus(m.ctx, m.runner)
+ if !status.Ready() {
+ return actionDoneMsg{err: fmt.Errorf("Docker 环境仍未就绪")}
+ }
+ }
+
+ bundlePlan, err := m.config.hostBundlePlan(m.installDir)
+ if err != nil {
+ return actionDoneMsg{err: err}
+ }
+ if err := installer.PrepareHostBundleWithProgress(m.ctx, m.runner, bundlePlan, m.sendProgress("下载宿主机安装包")); err != nil {
+ return actionDoneMsg{err: err}
+ }
+ return actionDoneMsg{err: installer.InstallHost(m.ctx, m.runner, hostPlan(m.installDir))}
+}
+
+func (m *model) uninstallHostFlow() tea.Msg {
+ return actionDoneMsg{err: installer.UninstallHost(m.ctx, m.runner, hostPlan(m.installDir))}
+}
+
+func (m *model) sendProgress(label string) installer.ProgressFunc {
+ return func(p installer.DownloadProgress) {
+ m.sender.Send(progressMsg{label: label, progress: p})
+ }
+}
+
+func renderStatus(status installer.DockerStatus) string {
+ lines := []string{}
+ if status.DockerInstalled {
+ lines = append(lines, okStyle.Render("Docker: 已安装 "+status.DockerVersion))
+ } else {
+ lines = append(lines, errStyle.Render("Docker: 未安装"))
+ }
+ if status.ComposeInstalled {
+ lines = append(lines, okStyle.Render("Docker Compose: 已安装 "+status.ComposeVersion))
+ } else {
+ lines = append(lines, errStyle.Render("Docker Compose: 未安装"))
+ }
+ if status.DaemonRunning {
+ lines = append(lines, okStyle.Render("Docker Daemon: 运行中 "+status.DaemonVersion))
+ } else {
+ lines = append(lines, warnStyle.Render("Docker Daemon: 未运行"))
+ }
+ return strings.Join(lines, "\n")
+}
+
+func hostPlan(workDir string) installer.HostInstallPlan {
+ return installer.HostInstallPlan{
+ WorkDir: workDir,
+ ComposeFile: filepath.Join(workDir, "docker-compose.yml"),
+ EnvFile: filepath.Join(workDir, ".env"),
+ Token: os.Getenv("MCAI_HOST_TOKEN"),
+ GrpcURL: os.Getenv("MCAI_TASKFLOW_GRPC_URL"),
+ Images: scanImages(filepath.Join(workDir, "images")),
+ }
+}
+
+type installConfig struct {
+ BaseURL string
+ DockerBundlePath string
+ HostBundlePath string
+}
+
+func loadConfig() installConfig {
+ arch := installerArch()
+ return installConfig{
+ BaseURL: valueOrDefault(os.Getenv("MCAI_BASE_URL"), "http://localhost"),
+ DockerBundlePath: valueOrDefault(os.Getenv("MCAI_DOCKER_BUNDLE_PATH"), "/static/installer/"+arch+"/docker.tgz"),
+ HostBundlePath: valueOrDefault(os.Getenv("MCAI_HOST_BUNDLE_PATH"), "/static/installer/"+arch+"/host.tgz"),
+ }
+}
+
+func installerArch() string {
+ switch runtime.GOARCH {
+ case "arm64":
+ return "aarch64"
+ default:
+ return "x86_64"
+ }
+}
+
+func (c installConfig) dockerPlan() (installer.DockerInstallPlan, error) {
+ bundleURL, err := installer.BundleURL(c.BaseURL, c.DockerBundlePath)
+ if err != nil {
+ return installer.DockerInstallPlan{}, err
+ }
+ return installer.DockerInstallPlan{
+ BundleURL: bundleURL,
+ WorkDir: "/tmp/monkeycode-installer",
+ BundleFile: "/tmp/monkeycode-installer/docker.tgz",
+ }, nil
+}
+
+func (c installConfig) hostBundlePlan(workDir string) (installer.HostBundlePlan, error) {
+ bundleURL, err := installer.BundleURL(c.BaseURL, c.HostBundlePath)
+ if err != nil {
+ return installer.HostBundlePlan{}, err
+ }
+ return installer.HostBundlePlan{
+ BundleURL: bundleURL,
+ BundleFile: "/tmp/monkeycode-host.tgz",
+ WorkDir: workDir,
+ }, nil
+}
+
+func valueOrDefault(value, fallback string) string {
+ if value != "" {
+ return value
+ }
+ return fallback
+}
+
+func scanImages(dir string) []installer.ImageArchive {
+ patterns := []struct {
+ glob string
+ compressed bool
+ }{
+ {filepath.Join(dir, "*.tar"), false},
+ {filepath.Join(dir, "*.tar.gz"), true},
+ {filepath.Join(dir, "*.tgz"), true},
+ }
+ images := []installer.ImageArchive{}
+ for _, pattern := range patterns {
+ matches, _ := filepath.Glob(pattern.glob)
+ for _, match := range matches {
+ if !isImageArchive(match) {
+ continue
+ }
+ images = append(images, installer.ImageArchive{Path: match, Compressed: pattern.compressed})
+ }
+ }
+ sort.Slice(images, func(i, j int) bool {
+ return images[i].Path < images[j].Path
+ })
+ return images
+}
+
+func isImageArchive(file string) bool {
+ base := filepath.Base(file)
+ if strings.HasPrefix(base, ".") || strings.HasPrefix(base, "._") || base == ".DS_Store" {
+ return false
+ }
+ info, err := os.Stat(file)
+ if err != nil {
+ return false
+ }
+ return !info.IsDir()
+}
+
+func main() {
+ mode, err := parseMode(os.Args)
+ if err != nil {
+ fmt.Fprintln(os.Stderr, err)
+ os.Exit(1)
+ }
+
+ var m tea.Model
+ switch mode {
+ case modeCenter:
+ m = newCenterModel(context.Background())
+ case modeHost:
+ m = newModel(context.Background())
+ }
+
+ p := tea.NewProgram(m)
+ if senderAware, ok := m.(interface{ setProgram(*tea.Program) }); ok {
+ senderAware.setProgram(p)
+ }
+ if _, err := p.Run(); err != nil {
+ fmt.Fprintln(os.Stderr, err)
+ os.Exit(1)
+ }
+}
diff --git a/backend/cmd/installer/main_test.go b/backend/cmd/installer/main_test.go
new file mode 100644
index 00000000..630775c5
--- /dev/null
+++ b/backend/cmd/installer/main_test.go
@@ -0,0 +1,249 @@
+package main
+
+import (
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ tea "github.com/charmbracelet/bubbletea"
+
+ "github.com/chaitin/MonkeyCode/backend/pkg/installer"
+)
+
+func TestInstallerModeDefaultsToHost(t *testing.T) {
+ mode, err := parseMode([]string{"installer"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if mode != modeHost {
+ t.Fatalf("mode = %q", mode)
+ }
+}
+
+func TestInstallerModeAcceptsCenterAndHost(t *testing.T) {
+ for _, tc := range []struct {
+ args []string
+ want installerMode
+ }{
+ {[]string{"installer", "center"}, modeCenter},
+ {[]string{"installer", "host"}, modeHost},
+ } {
+ got, err := parseMode(tc.args)
+ if err != nil {
+ t.Fatalf("parseMode(%v): %v", tc.args, err)
+ }
+ if got != tc.want {
+ t.Fatalf("parseMode(%v) = %q, want %q", tc.args, got, tc.want)
+ }
+ }
+}
+
+func TestInstallerModeRejectsUnknownMode(t *testing.T) {
+ _, err := parseMode([]string{"installer", "agent"})
+ if err == nil {
+ t.Fatal("expected error")
+ }
+ if !strings.Contains(err.Error(), "unknown installer mode") {
+ t.Fatalf("err = %v", err)
+ }
+}
+
+func TestHomeOptionsOnlyInstallAndExit(t *testing.T) {
+ options := homeOptions()
+
+ if len(options) != 3 {
+ t.Fatalf("len(options) = %d", len(options))
+ }
+ if options[0].Key != "安装" || options[0].Value != homeActionInstall {
+ t.Fatalf("first option = %#v", options[0])
+ }
+ if options[1].Key != "卸载" || options[1].Value != homeActionUninstall {
+ t.Fatalf("second option = %#v", options[1])
+ }
+ if options[2].Key != "退出" || options[2].Value != homeActionExit {
+ t.Fatalf("third option = %#v", options[2])
+ }
+}
+
+func TestHomeSelectionExitsOnSingleSubmit(t *testing.T) {
+ m := newModel(t.Context())
+ m.status = installer.DockerStatus{DockerInstalled: true, ComposeInstalled: true, DaemonRunning: true}
+ m.step = stepHome
+ m.form = m.homeForm()
+ _ = m.form.Init()
+
+ updated, cmd := runUpdate(m, tea.KeyMsg{Type: tea.KeyDown})
+ updated, cmd = runUpdate(updated, tea.KeyMsg{Type: tea.KeyDown})
+ updated, cmd = runUpdate(updated, tea.KeyMsg{Type: tea.KeyEnter})
+
+ if cmd == nil {
+ t.Fatal("expected quit command")
+ }
+ if msg := cmd(); msg != tea.Quit() {
+ t.Fatalf("cmd() = %#v", msg)
+ }
+}
+
+func TestHomeSelectionStartsUninstallOnSingleSubmit(t *testing.T) {
+ m := newModel(t.Context())
+ m.status = installer.DockerStatus{DockerInstalled: true, ComposeInstalled: true, DaemonRunning: true}
+ m.step = stepHome
+ m.form = m.homeForm()
+ _ = m.form.Init()
+
+ updated, _ := runUpdate(m, tea.KeyMsg{Type: tea.KeyDown})
+ updated, _ = runUpdate(updated, tea.KeyMsg{Type: tea.KeyEnter})
+ got := updated.(*model)
+
+ if got.step != stepUninstallDir {
+ t.Fatalf("step = %v", got.step)
+ }
+ if got.form == nil {
+ t.Fatal("form is nil")
+ }
+}
+
+func TestUninstallDirSelectionAsksForConfirmation(t *testing.T) {
+ m := newModel(t.Context())
+ m.step = stepUninstallDir
+ m.installDir = "/data/monkeycode_runner"
+ m.form = m.installDirForm()
+ _ = m.form.Init()
+
+ updated, _ := runUpdate(m, tea.KeyMsg{Type: tea.KeyEnter})
+ got := updated.(*model)
+
+ if got.step != stepConfirmUninstall {
+ t.Fatalf("step = %v", got.step)
+ }
+ if got.form == nil {
+ t.Fatal("form is nil")
+ }
+ if got.installDir != "/data/monkeycode_runner" {
+ t.Fatalf("installDir = %q", got.installDir)
+ }
+}
+
+func TestHomeSelectionStartsInstallOnSingleSubmit(t *testing.T) {
+ m := newModel(t.Context())
+ m.status = installer.DockerStatus{DockerInstalled: true, ComposeInstalled: true, DaemonRunning: true}
+ m.step = stepHome
+ m.form = m.homeForm()
+ _ = m.form.Init()
+
+ updated, _ := runUpdate(m, tea.KeyMsg{Type: tea.KeyEnter})
+ got := updated.(*model)
+
+ if got.step != stepInstallDir {
+ t.Fatalf("step = %v", got.step)
+ }
+ if got.form == nil {
+ t.Fatal("form is nil")
+ }
+}
+
+func TestInstallSuccessQuits(t *testing.T) {
+ m := newModel(t.Context())
+ m.step = stepRun
+ m.action = "安装宿主机"
+
+ _, cmd := m.Update(actionDoneMsg{})
+
+ if cmd == nil {
+ t.Fatal("expected quit command")
+ }
+ if msg := cmd(); msg != tea.Quit() {
+ t.Fatalf("cmd() = %#v", msg)
+ }
+}
+
+func TestInstallFailureStaysOnDone(t *testing.T) {
+ m := newModel(t.Context())
+ m.step = stepRun
+ m.action = "安装宿主机"
+ err := os.ErrNotExist
+
+ updated, cmd := m.Update(actionDoneMsg{err: err})
+ got := updated.(*model)
+
+ if cmd != nil {
+ t.Fatal("expected no quit command")
+ }
+ if got.step != stepDone {
+ t.Fatalf("step = %v", got.step)
+ }
+ if got.err != err {
+ t.Fatalf("err = %v", got.err)
+ }
+}
+
+func runUpdate(m tea.Model, msg tea.Msg) (tea.Model, tea.Cmd) {
+ updated, cmd := m.Update(msg)
+ for cmd != nil {
+ next := cmd()
+ if next == tea.Quit() {
+ return updated, cmd
+ }
+ updated, cmd = updated.Update(next)
+ }
+ return updated, nil
+}
+
+func TestHostPlanUsesSelectedInstallDir(t *testing.T) {
+ plan := hostPlan("/data/monkeycode_runner")
+
+ if plan.WorkDir != "/data/monkeycode_runner" {
+ t.Fatalf("WorkDir = %q", plan.WorkDir)
+ }
+ if plan.ComposeFile != "/data/monkeycode_runner/docker-compose.yml" {
+ t.Fatalf("ComposeFile = %q", plan.ComposeFile)
+ }
+ if plan.EnvFile != "/data/monkeycode_runner/.env" {
+ t.Fatalf("EnvFile = %q", plan.EnvFile)
+ }
+}
+
+func TestHostBundlePlanUsesSelectedInstallDir(t *testing.T) {
+ cfg := installConfig{
+ BaseURL: "http://server",
+ HostBundlePath: "/static/installer/x86_64/host.tgz",
+ }
+
+ plan, err := cfg.hostBundlePlan("/data/monkeycode_runner")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ if plan.WorkDir != "/data/monkeycode_runner" {
+ t.Fatalf("WorkDir = %q", plan.WorkDir)
+ }
+ if plan.BundleFile != filepath.Join("/tmp", "monkeycode-host.tgz") {
+ t.Fatalf("BundleFile = %q", plan.BundleFile)
+ }
+}
+
+func TestScanImagesSkipsMacOSMetadataFiles(t *testing.T) {
+ dir := t.TempDir()
+ imageDir := filepath.Join(dir, "images")
+ if err := os.Mkdir(imageDir, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ for _, name := range []string{"orchestrator.tgz", "._orchestrator.tgz", ".DS_Store", ".hidden.tgz"} {
+ if err := os.WriteFile(filepath.Join(imageDir, name), []byte("x"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ }
+
+ images := scanImages(imageDir)
+
+ if len(images) != 1 {
+ t.Fatalf("len(images) = %d, images=%#v", len(images), images)
+ }
+ if images[0].Path != filepath.Join(imageDir, "orchestrator.tgz") {
+ t.Fatalf("image path = %q", images[0].Path)
+ }
+ if !images[0].Compressed {
+ t.Fatal("image should be compressed")
+ }
+}
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..61309a4f 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"`
@@ -105,6 +108,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 +226,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", "")
@@ -236,6 +269,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..7ac9cf20 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:
@@ -55,3 +56,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..f6223a66 100644
--- a/backend/docker-compose.yml
+++ b/backend/docker-compose.yml
@@ -1,21 +1,178 @@
-version: '3.8'
-
+name: monkeycode-ai
services:
- postgres:
- image: postgres:16-alpine
+ db:
+ image: chaitin-registry.cn-hangzhou.cr.aliyuncs.com/basic/postgres:17.4-alpine3.21
+ 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
+ - pg_data:/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: chaitin-registry.cn-hangzhou.cr.aliyuncs.com/basic/redis:8.0-alpine3.21
+ 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:
+ - redis_data:/data
+ networks:
+ monkeycode:
+ ipv4_address: "${SUBNET_PREFIX:-10.100.50}.12"
+
+ clickhouse:
+ image: chaitin-registry.cn-hangzhou.cr.aliyuncs.com/basic/clickhouse-server:26.3.9
+ 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:
+ - ch_data:/var/lib/clickhouse
+ - ./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: chaitin-registry.cn-hangzhou.cr.aliyuncs.com/basic/rustfs:1.0.0-beta.2
+ 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:
+ - rustfs_data:/data
+ - ./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: chaitin-registry.cn-hangzhou.cr.aliyuncs.com/monkeycode/ingress:7f7f8b2
+ container_name: monkeycode-ai-ingress
+ restart: always
ports:
- - "6379:6379"
+ - ${NGINX_PORT:-80}:80
+ - 50443:50443
+ volumes:
+ - ./tls:/etc/tls
+ networks:
+ monkeycode:
+ ipv4_address: "${SUBNET_PREFIX:-10.100.50}.15"
+
+ taskflow:
+ image: chaitin-registry.cn-hangzhou.cr.aliyuncs.com/monkeycode/taskflow:fc0daba
+ 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
+ networks:
+ monkeycode:
+ ipv4_address: "${SUBNET_PREFIX:-10.100.50}.16"
+
+ frontend:
+ image: ghcr.1ms.run/chaitin/monkeycode-frontend:6048ca01cfd9663bd45121f06a3979d59eb6a6e2
+ container_name: monkeycode-ai-frontend
+ restart: always
+ networks:
+ monkeycode:
+ ipv4_address: "${SUBNET_PREFIX:-10.100.50}.17"
+
+ backend:
+ image: chaitin-registry.cn-hangzhou.cr.aliyuncs.com/monkeycode/backend:7f7f8b2
+ 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_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:
+ - ./static:/app/static
+ networks:
+ monkeycode:
+ ipv4_address: "${SUBNET_PREFIX:-10.100.50}.18"
+
+networks:
+ monkeycode:
+ ipam:
+ driver: default
+ config:
+ - subnet: "${SUBNET_PREFIX:-10.100.50}.0/24"
volumes:
- pgdata:
+ rustfs_data:
+ pg_data:
+ ch_data:
+ redis_data:
\ No newline at end of file
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/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..6f019548 100644
--- a/backend/go.mod
+++ b/backend/go.mod
@@ -11,6 +11,14 @@ 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/huh v1.0.0
+ 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 +48,40 @@ 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/catppuccin/go v0.3.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
+ github.com/charmbracelet/colorprofile v0.4.1 // indirect
+ github.com/charmbracelet/harmonica v0.2.0 // indirect
+ github.com/charmbracelet/x/ansi v0.11.6 // indirect
+ github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
+ github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // 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/dustin/go-humanize v1.0.1 // 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 +111,26 @@ 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/mitchellh/hashstructure/v2 v2.0.2 // 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 +150,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..a0de5709 100644
--- a/backend/go.sum
+++ b/backend/go.sum
@@ -14,6 +14,8 @@ github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7Oputl
github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU=
github.com/GoYoko/web v1.6.0 h1:gwnErVfMSDKc8XwJIW9iiMBNuzwx1E3QwqPiGwEW76U=
github.com/GoYoko/web v1.6.0/go.mod h1:MNOw+4KjmtRzUabIMqWK3t59yibnO1sDCp3EcLCmJVc=
+github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ=
+github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE=
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
github.com/ackcoder/go-cap v1.1.3 h1:rHIZEmyOM/KlXJQxGoy3UHpzpeUhw+V8qa/OoEaJR7A=
@@ -32,6 +34,48 @@ 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/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY=
+github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E=
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=
@@ -44,8 +88,46 @@ github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
github.com/buger/jsonparser v1.1.2 h1:frqHqw7otoVbk5M8LlE/L7HTnIq2v9RX6EJ48i9AxJk=
github.com/buger/jsonparser v1.1.2/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0=
+github.com/catppuccin/go v0.3.0 h1:d+0/YicIq+hSTo5oPuRi5kOpqkVA5tAsU6dNhvRu+aY=
+github.com/catppuccin/go v0.3.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc=
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/harmonica v0.2.0 h1:8NxJWRWg/bzKqqEaaeFNipOu77YR5t8aSwG4pgaUBiQ=
+github.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao=
+github.com/charmbracelet/huh v1.0.0 h1:wOnedH8G4qzJbmhftTqrpppyqHakl/zbbNdXIWJyIxw=
+github.com/charmbracelet/huh v1.0.0/go.mod h1:5YVc+SlZ1IhQALxRPpkGwwEKftN/+OlJlnJYlDRFqN4=
+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/conpty v0.1.0 h1:4zc8KaIcbiL4mghEON8D72agYtSeIgq8FSThSPQIb+U=
+github.com/charmbracelet/x/conpty v0.1.0/go.mod h1:rMFsDJoDwVmiYM10aD4bH2XiRgwI7NYJtQgl5yskjEQ=
+github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86 h1:JSt3B+U9iqk37QUU2Rvb6DSBYRLtWqFqfxf8l5hOZUA=
+github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86/go.mod h1:2P0UgXMEa6TsToMSuFqKFQR+fZTO9CNGUNokkPatT/0=
+github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ=
+github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U=
+github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 h1:qko3AQ4gK1MTS/de7F5hPGx6/k1u0w4TeYmBFwzYVP4=
+github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0/go.mod h1:pBhA0ybfXv6hDjQUZ7hk1lVxBiUbupdw5R31yPUViVQ=
+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/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY=
+github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo=
+github.com/charmbracelet/x/xpty v0.1.2 h1:Pqmu4TEJ8KeA9uSkISKMU3f+C1F6OGBn8ABuGlqCbtI=
+github.com/charmbracelet/x/xpty v0.1.2/go.mod h1:XK2Z0id5rtLWcpeNiMYBccNNBrP2IJnzHI0Lq13Xzq4=
+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=
@@ -53,6 +135,8 @@ github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ
github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE=
github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk=
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
+github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
+github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@@ -70,6 +154,10 @@ 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/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
+github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
+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 +275,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,10 +286,16 @@ 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=
github.com/mitchellh/go-wordwrap v1.0.1/go.mod h1:R62XHJLzvMFRBbcrT7m7WgmE1eOyTSsCt+hzestvNj0=
+github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4=
+github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE=
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0=
@@ -213,6 +309,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 +340,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 +405,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 +453,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 +481,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/pkg/installer/installer.go b/backend/pkg/installer/installer.go
new file mode 100644
index 00000000..5d1df957
--- /dev/null
+++ b/backend/pkg/installer/installer.go
@@ -0,0 +1,559 @@
+package installer
+
+import (
+ "context"
+ "crypto/rand"
+ "encoding/base64"
+ "errors"
+ "fmt"
+ "io"
+ "io/fs"
+ "net/http"
+ "net/url"
+ "os"
+ "os/exec"
+ "path"
+ "path/filepath"
+ "regexp"
+ "strings"
+)
+
+var errNotFound = errors.New("command not found")
+
+type Runner interface {
+ Run(ctx context.Context, name string, args ...string) RunResult
+ RunShell(ctx context.Context, script string) RunResult
+}
+
+type RunResult struct {
+ Stdout string
+ Stderr string
+ Err error
+}
+
+type CommandRunner struct{}
+
+func (CommandRunner) Run(ctx context.Context, name string, args ...string) RunResult {
+ cmd := exec.CommandContext(ctx, name, args...)
+ out, err := cmd.Output()
+ if err != nil {
+ if _, ok := err.(*exec.Error); ok {
+ return RunResult{Err: errNotFound}
+ }
+ if exitErr, ok := err.(*exec.ExitError); ok {
+ return RunResult{Stderr: string(exitErr.Stderr), Err: err}
+ }
+ return RunResult{Err: err}
+ }
+ return RunResult{Stdout: string(out)}
+}
+
+func (CommandRunner) RunShell(ctx context.Context, script string) RunResult {
+ return CommandRunner{}.Run(ctx, "sh", "-c", script)
+}
+
+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
+}
+
+type HostInstallPlan struct {
+ WorkDir string
+ ComposeFile string
+ EnvFile string
+ Token string
+ GrpcURL string
+ Images []ImageArchive
+}
+
+type CenterInstallPlan struct {
+ WorkDir string
+ ComposeFile string
+ EnvFile string
+ TLS TLSPlan
+ Images []ImageArchive
+}
+
+type CenterFilesPlan struct {
+ PackageDir string
+ WorkDir string
+ Env CenterEnv
+}
+
+type CenterEnvInput struct {
+ AccessHost string
+ NginxPort string
+ TeamEmail string
+ TeamName string
+ TeamPassword string
+}
+
+type CenterEnv struct {
+ 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
+ SubnetPrefix string
+}
+
+type DockerInstallPlan struct {
+ BundleURL string
+ WorkDir string
+ BundleFile string
+}
+
+type HostBundlePlan struct {
+ BundleURL string
+ BundleFile string
+ WorkDir string
+}
+
+type ImageArchive struct {
+ Path string
+ Compressed bool
+}
+
+type DownloadProgress struct {
+ Downloaded int64
+ Total int64
+}
+
+func (p DownloadProgress) Percent() float64 {
+ if p.Total <= 0 {
+ return 0
+ }
+ percent := float64(p.Downloaded) / float64(p.Total)
+ if percent > 1 {
+ return 1
+ }
+ return percent
+}
+
+type ProgressFunc func(DownloadProgress)
+
+func InstallDocker(ctx context.Context, r Runner, plan DockerInstallPlan) error {
+ return InstallDockerWithProgress(ctx, r, plan, nil)
+}
+
+func InstallDockerWithProgress(ctx context.Context, r Runner, plan DockerInstallPlan, progress ProgressFunc) error {
+ if res := r.Run(ctx, "mkdir", "-p", plan.WorkDir); res.Err != nil {
+ return fmt.Errorf("create docker install dir: %w", res.Err)
+ }
+ 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 res := r.Run(ctx, "mkdir", "-p", plan.WorkDir); res.Err != nil {
+ return fmt.Errorf("create docker install dir: %w", res.Err)
+ }
+ return installDockerBundle(ctx, r, plan)
+}
+
+func installDockerBundle(ctx context.Context, r Runner, plan DockerInstallPlan) error {
+ if res := r.Run(ctx, "tar", "-zxf", plan.BundleFile, "-C", plan.WorkDir); res.Err != nil {
+ return fmt.Errorf("extract docker bundle: %w", res.Err)
+ }
+ if res := r.RunShell(ctx, fmt.Sprintf("install -m 0755 '%s'/docker/* /usr/local/bin/", shellQuote(plan.WorkDir))); res.Err != nil {
+ return fmt.Errorf("install docker binaries: %w", res.Err)
+ }
+ if res := r.Run(ctx, "install", "-m", "0755", plan.WorkDir+"/docker-compose", "/usr/local/lib/docker/cli-plugins/docker-compose"); res.Err != nil {
+ return fmt.Errorf("install docker compose: %w", res.Err)
+ }
+ if res := r.Run(ctx, "ln", "-sf", "/usr/local/lib/docker/cli-plugins/docker-compose", "/usr/local/bin/docker-compose"); res.Err != nil {
+ return fmt.Errorf("link docker compose: %w", res.Err)
+ }
+ if res := r.Run(ctx, "systemctl", "daemon-reload"); res.Err != nil {
+ return fmt.Errorf("reload systemd: %w", res.Err)
+ }
+ if res := r.Run(ctx, "systemctl", "enable", "--now", "docker"); res.Err != nil {
+ return fmt.Errorf("start docker: %w", res.Err)
+ }
+ return nil
+}
+
+func PrepareHostBundle(ctx context.Context, r Runner, plan HostBundlePlan) error {
+ return PrepareHostBundleWithProgress(ctx, r, plan, nil)
+}
+
+func PrepareHostBundleWithProgress(ctx context.Context, r Runner, plan HostBundlePlan, progress ProgressFunc) error {
+ if res := r.Run(ctx, "mkdir", "-p", plan.WorkDir); res.Err != nil {
+ return fmt.Errorf("create host install dir: %w", res.Err)
+ }
+ if err := DownloadFile(ctx, plan.BundleURL, plan.BundleFile, progress); err != nil {
+ return fmt.Errorf("download host bundle: %w", err)
+ }
+ if res := r.Run(ctx, "tar", "-zxf", plan.BundleFile, "-C", plan.WorkDir); res.Err != nil {
+ return fmt.Errorf("extract host bundle: %w", res.Err)
+ }
+ return 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)
+ }
+
+ 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 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 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 != "") {
+ lines := []string{}
+ if plan.Token != "" {
+ lines = append(lines, "TOKEN="+plan.Token)
+ }
+ if plan.GrpcURL != "" {
+ lines = append(lines, "GRPC_URL="+plan.GrpcURL)
+ }
+ args := []string{"-c", fmt.Sprintf("printf '%%s\\n' %s >> '%s'", shellJoin(lines), shellQuote(plan.EnvFile))}
+ if res := r.Run(ctx, "sh", args...); res.Err != nil {
+ return fmt.Errorf("write host env: %w", res.Err)
+ }
+ }
+
+ args := []string{"compose", "-f", plan.ComposeFile}
+ if plan.EnvFile != "" {
+ args = append(args, "--env-file", plan.EnvFile)
+ }
+ args = append(args, "up", "-d")
+ if res := r.Run(ctx, "docker", args...); res.Err != nil {
+ return fmt.Errorf("start host services: %w", res.Err)
+ }
+ return nil
+}
+
+func InstallCenter(ctx context.Context, r Runner, plan CenterInstallPlan) error {
+ if res := r.Run(ctx, "mkdir", "-p", plan.WorkDir); res.Err != nil {
+ return fmt.Errorf("create center install dir: %w", res.Err)
+ }
+ if err := loadImages(ctx, r, plan.Images); err != nil {
+ return err
+ }
+ args := []string{"compose", "-f", plan.ComposeFile}
+ if plan.EnvFile != "" {
+ args = append(args, "--env-file", plan.EnvFile)
+ }
+ args = append(args, "up", "-d")
+ if res := r.Run(ctx, "docker", args...); res.Err != nil {
+ return fmt.Errorf("start center services: %w", res.Err)
+ }
+ return nil
+}
+
+func PrepareCenterFiles(ctx context.Context, r Runner, plan CenterFilesPlan) error {
+ if err := copyFile(filepath.Join(plan.PackageDir, "docker-compose.yml"), filepath.Join(plan.WorkDir, "docker-compose.yml")); err != nil {
+ return err
+ }
+ if err := copyTree(filepath.Join(plan.PackageDir, "static"), filepath.Join(plan.WorkDir, "static")); err != nil {
+ return err
+ }
+ if err := copyTree(filepath.Join(plan.PackageDir, "images"), filepath.Join(plan.WorkDir, "images")); err != nil {
+ return err
+ }
+ env, err := os.ReadFile(filepath.Join(plan.PackageDir, ".env.example"))
+ if err != nil {
+ return err
+ }
+ content := RenderCenterEnv(string(env), plan.Env)
+ return writeFileAtomic(filepath.Join(plan.WorkDir, ".env"), []byte(content), 0600)
+}
+
+func NewCenterEnv(input CenterEnvInput) (CenterEnv, error) {
+ env := CenterEnv{
+ 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,
+ 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
+ }
+ return env, nil
+}
+
+func RenderCenterEnv(template string, env CenterEnv) string {
+ values := map[string]string{
+ "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,
+ "SUBNET_PREFIX": env.SubnetPrefix,
+ }
+ lines := strings.Split(template, "\n")
+ for i, line := range lines {
+ key, _, ok := strings.Cut(line, "=")
+ if !ok {
+ continue
+ }
+ if value, exists := values[key]; exists {
+ lines[i] = key + "=" + value
+ }
+ }
+ return strings.Join(lines, "\n")
+}
+
+func loadImages(ctx context.Context, r Runner, images []ImageArchive) error {
+ for _, image := range images {
+ if image.Compressed {
+ if res := r.RunShell(ctx, fmt.Sprintf("gzip -dc '%s' | docker load", shellQuote(image.Path))); res.Err != nil {
+ return fmt.Errorf("load image %s: %w", image.Path, res.Err)
+ }
+ continue
+ }
+ if res := r.Run(ctx, "docker", "load", "-i", image.Path); res.Err != nil {
+ return fmt.Errorf("load image %s: %w", image.Path, res.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 res := r.Run(ctx, "docker", args...); res.Err != nil {
+ return fmt.Errorf("stop host services: %w", res.Err)
+ }
+ if res := r.Run(ctx, "rm", "-rf", plan.WorkDir); res.Err != nil {
+ return fmt.Errorf("remove host install dir: %w", res.Err)
+ }
+ return nil
+}
+
+func shellJoin(values []string) string {
+ parts := make([]string, 0, len(values))
+ for _, value := range values {
+ parts = append(parts, "'"+shellQuote(value)+"'")
+ }
+ return strings.Join(parts, " ")
+}
+
+func copyTree(src, dst string) error {
+ 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, 0755)
+ }
+ if shouldSkipPackageFile(filepath.Base(path)) {
+ return nil
+ }
+ return copyFile(path, target)
+ })
+}
+
+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), 0755); err != nil {
+ return err
+ }
+ out, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0644)
+ if err != nil {
+ return err
+ }
+ defer out.Close()
+ _, err = io.Copy(out, in)
+ return err
+}
+
+func writeFileAtomic(path string, data []byte, perm os.FileMode) error {
+ if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
+ return err
+ }
+ tmp := path + ".tmp"
+ if err := os.WriteFile(tmp, data, perm); err != nil {
+ return err
+ }
+ return os.Rename(tmp, path)
+}
+
+func shouldSkipPackageFile(name string) bool {
+ return strings.HasPrefix(name, "._") || name == ".DS_Store"
+}
+
+func fallback(value, defaultValue string) string {
+ if value != "" {
+ return value
+ }
+ return defaultValue
+}
+
+func randomSecret(length int) (string, error) {
+ raw := make([]byte, length)
+ if _, err := rand.Read(raw); err != nil {
+ return "", err
+ }
+ secret := base64.RawURLEncoding.EncodeToString(raw)
+ if len(secret) > length {
+ return secret[:length], nil
+ }
+ return secret, nil
+}
+
+func shellQuote(s string) string {
+ return strings.ReplaceAll(s, "'", `'\''`)
+}
+
+var (
+ dockerVersionRe = regexp.MustCompile(`Docker version ([^,\s]+)`)
+ composeVersionRe = regexp.MustCompile(`Docker Compose version ([^\s]+)`)
+)
+
+func parseDockerVersion(out string) string {
+ m := dockerVersionRe.FindStringSubmatch(out)
+ if len(m) == 2 {
+ return m[1]
+ }
+ return strings.TrimSpace(out)
+}
+
+func parseComposeVersion(out string) string {
+ m := composeVersionRe.FindStringSubmatch(out)
+ if len(m) == 2 {
+ return m[1]
+ }
+ return strings.TrimSpace(out)
+}
diff --git a/backend/pkg/installer/installer_test.go b/backend/pkg/installer/installer_test.go
new file mode 100644
index 00000000..2f91b66c
--- /dev/null
+++ b/backend/pkg/installer/installer_test.go
@@ -0,0 +1,408 @@
+package installer
+
+import (
+ "context"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+)
+
+type fakeRunner struct {
+ outputs map[string]RunResult
+ calls []string
+}
+
+func (r *fakeRunner) Run(ctx context.Context, name string, args ...string) RunResult {
+ cmd := strings.Join(append([]string{name}, args...), " ")
+ r.calls = append(r.calls, cmd)
+ if out, ok := r.outputs[cmd]; ok {
+ return out
+ }
+ return RunResult{Err: errNotFound}
+}
+
+func (r *fakeRunner) RunShell(ctx context.Context, script string) RunResult {
+ r.calls = append(r.calls, script)
+ if out, ok := r.outputs[script]; ok {
+ return out
+ }
+ return RunResult{Err: errNotFound}
+}
+
+func TestCheckDockerStatusParsesVersions(t *testing.T) {
+ r := &fakeRunner{outputs: map[string]RunResult{
+ "docker --version": {Stdout: "Docker version 29.4.3, build 123456\n"},
+ "docker compose version": {Stdout: "Docker Compose version v2.40.3\n"},
+ "docker info --format {{.ServerVersion}}": {Stdout: "29.4.3\n"},
+ }}
+
+ status := CheckDockerStatus(context.Background(), r)
+
+ if !status.DockerInstalled {
+ t.Fatal("DockerInstalled = false")
+ }
+ if status.DockerVersion != "29.4.3" {
+ t.Fatalf("DockerVersion = %q", status.DockerVersion)
+ }
+ if !status.ComposeInstalled {
+ t.Fatal("ComposeInstalled = false")
+ }
+ if status.ComposeVersion != "v2.40.3" {
+ t.Fatalf("ComposeVersion = %q", status.ComposeVersion)
+ }
+ if !status.DaemonRunning {
+ t.Fatal("DaemonRunning = false")
+ }
+}
+
+func TestCheckDockerStatusDetectsMissingCompose(t *testing.T) {
+ r := &fakeRunner{outputs: map[string]RunResult{
+ "docker --version": {Stdout: "Docker version 29.4.3, build 123456\n"},
+ }}
+
+ status := CheckDockerStatus(context.Background(), r)
+
+ if !status.DockerInstalled {
+ t.Fatal("DockerInstalled = false")
+ }
+ if status.ComposeInstalled {
+ t.Fatal("ComposeInstalled = true")
+ }
+ if status.Ready() {
+ t.Fatal("Ready() = true")
+ }
+}
+
+func TestInstallHostLoadsImagesAndStartsCompose(t *testing.T) {
+ r := &fakeRunner{outputs: map[string]RunResult{
+ "docker load -i /opt/monkeycode-host/images/orchestrator.tar": {},
+ "gzip -dc '/opt/monkeycode-host/images/orchestrator.tgz' | docker load": {},
+ "sh -c printf '%s\\n' 'TOKEN=host-token' 'GRPC_URL=10.0.0.1:50443' >> '/opt/monkeycode-host/.env'": {},
+ "docker compose -f /opt/monkeycode-host/docker-compose.yml --env-file /opt/monkeycode-host/.env up -d": {},
+ }}
+ plan := HostInstallPlan{
+ WorkDir: "/opt/monkeycode-host",
+ ComposeFile: "/opt/monkeycode-host/docker-compose.yml",
+ EnvFile: "/opt/monkeycode-host/.env",
+ Token: "host-token",
+ GrpcURL: "10.0.0.1:50443",
+ Images: []ImageArchive{
+ {Path: "/opt/monkeycode-host/images/orchestrator.tar", Compressed: false},
+ {Path: "/opt/monkeycode-host/images/orchestrator.tgz", Compressed: true},
+ },
+ }
+
+ if err := InstallHost(context.Background(), r, plan); err != nil {
+ t.Fatalf("%v, calls=%#v", err, r.calls)
+ }
+
+ want := []string{
+ "docker load -i /opt/monkeycode-host/images/orchestrator.tar",
+ "gzip -dc '/opt/monkeycode-host/images/orchestrator.tgz' | docker load",
+ "sh -c printf '%s\\n' 'TOKEN=host-token' 'GRPC_URL=10.0.0.1:50443' >> '/opt/monkeycode-host/.env'",
+ "docker compose -f /opt/monkeycode-host/docker-compose.yml --env-file /opt/monkeycode-host/.env up -d",
+ }
+ if strings.Join(r.calls, "\n") != strings.Join(want, "\n") {
+ t.Fatalf("calls = %#v", r.calls)
+ }
+}
+
+func TestUninstallHostStopsComposeAndRemovesWorkDir(t *testing.T) {
+ r := &fakeRunner{outputs: map[string]RunResult{
+ "docker compose -f /opt/monkeycode-host/docker-compose.yml --env-file /opt/monkeycode-host/.env down": {},
+ "rm -rf /opt/monkeycode-host": {},
+ }}
+ plan := HostInstallPlan{
+ WorkDir: "/opt/monkeycode-host",
+ ComposeFile: "/opt/monkeycode-host/docker-compose.yml",
+ EnvFile: "/opt/monkeycode-host/.env",
+ }
+
+ if err := UninstallHost(context.Background(), r, plan); err != nil {
+ t.Fatalf("%v, calls=%#v", err, r.calls)
+ }
+
+ want := []string{
+ "docker compose -f /opt/monkeycode-host/docker-compose.yml --env-file /opt/monkeycode-host/.env down",
+ "rm -rf /opt/monkeycode-host",
+ }
+ if strings.Join(r.calls, "\n") != strings.Join(want, "\n") {
+ t.Fatalf("calls = %#v", r.calls)
+ }
+}
+
+func TestInstallCenterLoadsImagesAndStartsCompose(t *testing.T) {
+ r := &fakeRunner{outputs: map[string]RunResult{
+ "mkdir -p /data/monkeycode-ai": {},
+ "docker load -i /data/monkeycode-ai/images/backend.tar": {},
+ "gzip -dc '/data/monkeycode-ai/images/frontend.tar.gz' | docker load": {},
+ "docker compose -f /data/monkeycode-ai/docker-compose.yml --env-file /data/monkeycode-ai/.env up -d": {},
+ }}
+ plan := CenterInstallPlan{
+ WorkDir: "/data/monkeycode-ai",
+ ComposeFile: "/data/monkeycode-ai/docker-compose.yml",
+ EnvFile: "/data/monkeycode-ai/.env",
+ Images: []ImageArchive{
+ {Path: "/data/monkeycode-ai/images/backend.tar"},
+ {Path: "/data/monkeycode-ai/images/frontend.tar.gz", Compressed: true},
+ },
+ }
+
+ if err := InstallCenter(context.Background(), r, plan); err != nil {
+ t.Fatalf("%v, calls=%#v", err, r.calls)
+ }
+
+ want := []string{
+ "mkdir -p /data/monkeycode-ai",
+ "docker load -i /data/monkeycode-ai/images/backend.tar",
+ "gzip -dc '/data/monkeycode-ai/images/frontend.tar.gz' | docker load",
+ "docker compose -f /data/monkeycode-ai/docker-compose.yml --env-file /data/monkeycode-ai/.env up -d",
+ }
+ if strings.Join(r.calls, "\n") != strings.Join(want, "\n") {
+ t.Fatalf("calls = %#v", r.calls)
+ }
+}
+
+func TestInstallDockerFromLocalBundleDoesNotDownload(t *testing.T) {
+ r := &fakeRunner{outputs: map[string]RunResult{
+ "mkdir -p /tmp/monkeycode-installer": {},
+ "tar -zxf /pkg/docker.tgz -C /tmp/monkeycode-installer": {},
+ "install -m 0755 '/tmp/monkeycode-installer'/docker/* /usr/local/bin/": {},
+ "install -m 0755 /tmp/monkeycode-installer/docker-compose /usr/local/lib/docker/cli-plugins/docker-compose": {},
+ "ln -sf /usr/local/lib/docker/cli-plugins/docker-compose /usr/local/bin/docker-compose": {},
+ "systemctl daemon-reload": {},
+ "systemctl enable --now docker": {},
+ }}
+
+ err := InstallDockerFromLocalBundle(context.Background(), r, DockerInstallPlan{
+ WorkDir: "/tmp/monkeycode-installer",
+ BundleFile: "/pkg/docker.tgz",
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+}
+
+func TestPrepareCenterFilesCopiesPackageFilesAndWritesEnv(t *testing.T) {
+ pkg := t.TempDir()
+ work := filepath.Join(t.TempDir(), "monkeycode-ai")
+ writeFile(t, filepath.Join(pkg, "docker-compose.yml"), "compose")
+ writeFile(t, filepath.Join(pkg, ".env.example"), "REMOTE_IP=\nNGINX_PORT=\nTEAM_EMAIL=\nTEAM_NAME=\nTEAM_PASSWORD=\nPOSTGRES_PASSWORD=\n")
+ writeFile(t, filepath.Join(pkg, "static", "project-tpl.zip"), "zip")
+ writeFile(t, filepath.Join(pkg, "images", "backend.tar.gz"), "image")
+
+ err := PrepareCenterFiles(context.Background(), CommandRunner{}, CenterFilesPlan{
+ PackageDir: pkg,
+ WorkDir: work,
+ Env: CenterEnv{
+ AccessHost: "192.168.1.10",
+ NginxPort: "8080",
+ TeamEmail: "admin@example.com",
+ TeamName: "Example",
+ TeamPassword: "team-secret",
+ PostgresPassword: "pg-secret",
+ },
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ if got := readFile(t, filepath.Join(work, "docker-compose.yml")); got != "compose" {
+ t.Fatalf("compose = %q", got)
+ }
+ if got := readFile(t, filepath.Join(work, "static", "project-tpl.zip")); got != "zip" {
+ t.Fatalf("project-tpl.zip = %q", got)
+ }
+ env := readFile(t, filepath.Join(work, ".env"))
+ if !strings.Contains(env, "REMOTE_IP=192.168.1.10") {
+ t.Fatalf("env = %q", env)
+ }
+ for _, want := range []string{
+ "NGINX_PORT=8080",
+ "TEAM_EMAIL=admin@example.com",
+ "TEAM_NAME=Example",
+ "TEAM_PASSWORD=team-secret",
+ "POSTGRES_PASSWORD=pg-secret",
+ } {
+ if !strings.Contains(env, want) {
+ t.Fatalf("env missing %q: %q", want, env)
+ }
+ }
+}
+
+func TestNewCenterEnvGeneratesPasswords(t *testing.T) {
+ env, err := NewCenterEnv(CenterEnvInput{
+ AccessHost: "192.168.1.10",
+ NginxPort: "80",
+ TeamEmail: "admin@example.com",
+ TeamName: "MonkeyCode",
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ for name, value := range map[string]string{
+ "TeamPassword": env.TeamPassword,
+ "PostgresPassword": env.PostgresPassword,
+ "RedisPassword": env.RedisPassword,
+ "ClickHousePassword": env.ClickHousePassword,
+ "RustFSAccessKey": env.RustFSAccessKey,
+ "RustFSSecretKey": env.RustFSSecretKey,
+ } {
+ if len(value) < 24 {
+ t.Fatalf("%s too short: %q", name, value)
+ }
+ }
+ if env.NginxPort != "80" {
+ t.Fatalf("NginxPort = %q", env.NginxPort)
+ }
+}
+
+func TestRenderCenterEnvReplacesKeys(t *testing.T) {
+ got := RenderCenterEnv("REMOTE_IP=\nNGINX_PORT=\nKEEP=value\n", CenterEnv{
+ AccessHost: "example.com",
+ NginxPort: "8080",
+ })
+ if !strings.Contains(got, "REMOTE_IP=example.com") {
+ t.Fatalf("env = %q", got)
+ }
+ if !strings.Contains(got, "NGINX_PORT=8080") {
+ t.Fatalf("env = %q", got)
+ }
+ if !strings.Contains(got, "KEEP=value") {
+ t.Fatalf("env = %q", got)
+ }
+}
+
+func TestInstallDockerDownloadsBundleAndInstallsBinaries(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ _, _ = w.Write([]byte("docker bundle"))
+ }))
+ defer server.Close()
+
+ workDir := t.TempDir()
+ bundleFile := filepath.Join(workDir, "docker.tgz")
+ r := &fakeRunner{outputs: map[string]RunResult{
+ "mkdir -p " + workDir: {},
+ "tar -zxf " + bundleFile + " -C " + workDir: {},
+ "install -m 0755 '" + workDir + "'/docker/* /usr/local/bin/": {},
+ "install -m 0755 " + workDir + "/docker-compose /usr/local/lib/docker/cli-plugins/docker-compose": {},
+ "ln -sf /usr/local/lib/docker/cli-plugins/docker-compose /usr/local/bin/docker-compose": {},
+ "systemctl daemon-reload": {},
+ "systemctl enable --now docker": {},
+ }}
+ plan := DockerInstallPlan{
+ BundleURL: server.URL,
+ WorkDir: workDir,
+ BundleFile: bundleFile,
+ }
+
+ if err := InstallDocker(context.Background(), r, plan); err != nil {
+ t.Fatal(err)
+ }
+
+ want := []string{
+ "mkdir -p " + workDir,
+ "tar -zxf " + bundleFile + " -C " + workDir,
+ "install -m 0755 '" + workDir + "'/docker/* /usr/local/bin/",
+ "install -m 0755 " + workDir + "/docker-compose /usr/local/lib/docker/cli-plugins/docker-compose",
+ "ln -sf /usr/local/lib/docker/cli-plugins/docker-compose /usr/local/bin/docker-compose",
+ "systemctl daemon-reload",
+ "systemctl enable --now docker",
+ }
+ if strings.Join(r.calls, "\n") != strings.Join(want, "\n") {
+ t.Fatalf("calls = %#v", r.calls)
+ }
+}
+
+func TestPrepareHostDownloadsBundle(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ _, _ = w.Write([]byte("host bundle"))
+ }))
+ defer server.Close()
+
+ r := &fakeRunner{outputs: map[string]RunResult{
+ "mkdir -p /opt/monkeycode-host": {},
+ "tar -zxf /tmp/monkeycode-host.tgz -C /opt/monkeycode-host": {},
+ }}
+ plan := HostBundlePlan{
+ BundleURL: server.URL,
+ BundleFile: "/tmp/monkeycode-host.tgz",
+ WorkDir: "/opt/monkeycode-host",
+ }
+
+ if err := PrepareHostBundle(context.Background(), r, plan); err != nil {
+ t.Fatal(err)
+ }
+
+ want := []string{
+ "mkdir -p /opt/monkeycode-host",
+ "tar -zxf /tmp/monkeycode-host.tgz -C /opt/monkeycode-host",
+ }
+ if strings.Join(r.calls, "\n") != strings.Join(want, "\n") {
+ t.Fatalf("calls = %#v", r.calls)
+ }
+}
+
+func TestDownloadFileReportsProgress(t *testing.T) {
+ body := strings.Repeat("a", 1024)
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Length", "1024")
+ _, _ = w.Write([]byte(body))
+ }))
+ defer server.Close()
+
+ var events []DownloadProgress
+ dest := filepath.Join(t.TempDir(), "bundle.tgz")
+ if err := DownloadFile(context.Background(), server.URL, dest, func(progress DownloadProgress) {
+ events = append(events, progress)
+ }); err != nil {
+ t.Fatal(err)
+ }
+
+ got, err := os.ReadFile(dest)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if string(got) != body {
+ t.Fatalf("downloaded body = %q", string(got))
+ }
+ if len(events) == 0 {
+ t.Fatal("progress callback was not called")
+ }
+ last := events[len(events)-1]
+ if last.Downloaded != 1024 || last.Total != 1024 || last.Percent() != 1 {
+ t.Fatalf("last progress = %#v percent=%v", last, last.Percent())
+ }
+}
+
+func TestBundleURLJoinsBaseURLAndPath(t *testing.T) {
+ url, err := BundleURL("http://server/base/", "/static/host/installation.tgz")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if url != "http://server/base/static/host/installation.tgz" {
+ t.Fatalf("url = %q", url)
+ }
+}
+
+func writeFile(t *testing.T, path, content string) {
+ t.Helper()
+ if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(path, []byte(content), 0644); err != nil {
+ t.Fatal(err)
+ }
+}
+
+func readFile(t *testing.T, path string) string {
+ t.Helper()
+ data, err := os.ReadFile(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ return string(data)
+}
diff --git a/backend/pkg/installer/tls.go b/backend/pkg/installer/tls.go
new file mode 100644
index 00000000..667a9dc6
--- /dev/null
+++ b/backend/pkg/installer/tls.go
@@ -0,0 +1,66 @@
+package installer
+
+import (
+ "crypto/rand"
+ "crypto/rsa"
+ "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 := rsa.GenerateKey(rand.Reader, 2048)
+ 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), 0755); err != nil {
+ return err
+ }
+ if err := os.MkdirAll(filepath.Dir(plan.KeyFile), 0755); err != nil {
+ return err
+ }
+ if err := os.WriteFile(plan.CertFile, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}), 0644); err != nil {
+ return err
+ }
+
+ keyBytes := x509.MarshalPKCS1PrivateKey(key)
+ return os.WriteFile(plan.KeyFile, pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: keyBytes}), 0600)
+}
diff --git a/backend/pkg/installer/tls_test.go b/backend/pkg/installer/tls_test.go
new file mode 100644
index 00000000..bb1e4e02
--- /dev/null
+++ b/backend/pkg/installer/tls_test.go
@@ -0,0 +1,69 @@
+package installer
+
+import (
+ "crypto/x509"
+ "encoding/pem"
+ "net"
+ "os"
+ "path/filepath"
+ "testing"
+)
+
+func TestGenerateSelfSignedTLSWithIPSAN(t *testing.T) {
+ dir := t.TempDir()
+ certFile := filepath.Join(dir, "server.crt")
+ keyFile := filepath.Join(dir, "server.key")
+
+ err := GenerateSelfSignedTLS(TLSPlan{
+ Host: "192.168.1.10",
+ CertFile: certFile,
+ KeyFile: keyFile,
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ cert := readCert(t, certFile)
+ if len(cert.IPAddresses) != 1 || !cert.IPAddresses[0].Equal(net.ParseIP("192.168.1.10")) {
+ t.Fatalf("IPAddresses = %#v", cert.IPAddresses)
+ }
+ if _, err := os.Stat(keyFile); err != nil {
+ t.Fatal(err)
+ }
+}
+
+func TestGenerateSelfSignedTLSWithDNSSAN(t *testing.T) {
+ dir := t.TempDir()
+ certFile := filepath.Join(dir, "server.crt")
+
+ err := GenerateSelfSignedTLS(TLSPlan{
+ Host: "monkeycode.local",
+ CertFile: certFile,
+ KeyFile: filepath.Join(dir, "server.key"),
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ cert := readCert(t, certFile)
+ if len(cert.DNSNames) != 1 || cert.DNSNames[0] != "monkeycode.local" {
+ t.Fatalf("DNSNames = %#v", cert.DNSNames)
+ }
+}
+
+func readCert(t *testing.T, certFile string) *x509.Certificate {
+ t.Helper()
+ data, err := os.ReadFile(certFile)
+ if err != nil {
+ t.Fatal(err)
+ }
+ block, _ := pem.Decode(data)
+ if block == nil {
+ t.Fatal("missing PEM block")
+ }
+ cert, err := x509.ParseCertificate(block.Bytes)
+ if err != nil {
+ t.Fatal(err)
+ }
+ return cert
+}
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..ff7435d8
--- /dev/null
+++ b/backend/scripts/build-offline-package.sh
@@ -0,0 +1,139 @@
+#!/bin/sh
+set -eu
+
+ARCH="${ARCH:-amd64}"
+DOCKER_VERSION="${DOCKER_VERSION:-29.0.4}"
+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"
+GOCACHE="${GOCACHE:-/root/.cache/go-build}"
+GOMODCACHE="${GOMODCACHE:-/go/pkg/mod}"
+REPO_COMMIT="${REPO_COMMIT:-$(git rev-parse HEAD 2>/dev/null || echo unknown)}"
+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"
+cp "$OUT_DIR/docker-$CENTER_DOCKER_ARCH.tgz" "$PACKAGE_DIR/docker.tgz"
+cp "$OUT_DIR/docker-x86_64.tgz" "$PACKAGE_DIR/static/installer/x86_64/docker.tgz"
+cp "$OUT_DIR/docker-aarch64.tgz" "$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"
+sed \
+ -e 's#chaitin-registry.cn-hangzhou.cr.aliyuncs.com/basic/postgres:17.4-alpine3.21#monkeycode-offline/postgres:local#g' \
+ -e 's#chaitin-registry.cn-hangzhou.cr.aliyuncs.com/basic/redis:8.0-alpine3.21#monkeycode-offline/redis:local#g' \
+ -e 's#chaitin-registry.cn-hangzhou.cr.aliyuncs.com/basic/clickhouse-server:26.3.9#monkeycode-offline/clickhouse:local#g' \
+ -e 's#chaitin-registry.cn-hangzhou.cr.aliyuncs.com/basic/rustfs:1.0.0-beta.2#monkeycode-offline/rustfs:local#g' \
+ -e 's#chaitin-registry.cn-hangzhou.cr.aliyuncs.com/monkeycode/ingress:[^[:space:]]*#monkeycode-offline/ingress:local#g' \
+ -e 's#chaitin-registry.cn-hangzhou.cr.aliyuncs.com/monkeycode/taskflow:fc0daba#monkeycode-offline/taskflow:local#g' \
+ -e 's#ghcr.1ms.run/chaitin/monkeycode-frontend:[^[:space:]]*#monkeycode-offline/frontend:local#g' \
+ -e 's#chaitin-registry.cn-hangzhou.cr.aliyuncs.com/monkeycode/backend:[^[:space:]]*#monkeycode-offline/backend:local#g' \
+ 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 monkeycode-offline/backend:local \
+ .
+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 monkeycode-offline/ingress:local \
+ .
+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 monkeycode-offline/frontend:local \
+ ../frontend/docker
+
+docker pull chaitin-registry.cn-hangzhou.cr.aliyuncs.com/monkeycode/taskflow:fc0daba
+docker pull chaitin-registry.cn-hangzhou.cr.aliyuncs.com/basic/postgres:17.4-alpine3.21
+docker pull chaitin-registry.cn-hangzhou.cr.aliyuncs.com/basic/redis:8.0-alpine3.21
+docker pull chaitin-registry.cn-hangzhou.cr.aliyuncs.com/basic/clickhouse-server:26.3.9
+docker pull chaitin-registry.cn-hangzhou.cr.aliyuncs.com/basic/rustfs:1.0.0-beta.2
+docker pull chaitin-registry.cn-hangzhou.cr.aliyuncs.com/monkeycode/codingmatrix-orchestrator:alpha-latest
+docker tag chaitin-registry.cn-hangzhou.cr.aliyuncs.com/monkeycode/taskflow:fc0daba monkeycode-offline/taskflow:local
+docker tag chaitin-registry.cn-hangzhou.cr.aliyuncs.com/basic/postgres:17.4-alpine3.21 monkeycode-offline/postgres:local
+docker tag chaitin-registry.cn-hangzhou.cr.aliyuncs.com/basic/redis:8.0-alpine3.21 monkeycode-offline/redis:local
+docker tag chaitin-registry.cn-hangzhou.cr.aliyuncs.com/basic/clickhouse-server:26.3.9 monkeycode-offline/clickhouse:local
+docker tag chaitin-registry.cn-hangzhou.cr.aliyuncs.com/basic/rustfs:1.0.0-beta.2 monkeycode-offline/rustfs:local
+
+docker save monkeycode-offline/backend:local | gzip > "$PACKAGE_DIR/images/backend.tar.gz"
+docker save monkeycode-offline/frontend:local | gzip > "$PACKAGE_DIR/images/frontend.tar.gz"
+docker save monkeycode-offline/ingress:local | gzip > "$PACKAGE_DIR/images/ingress.tar.gz"
+docker save monkeycode-offline/taskflow:local | gzip > "$PACKAGE_DIR/images/taskflow.tar.gz"
+docker save monkeycode-offline/postgres:local | gzip > "$PACKAGE_DIR/images/postgres.tar.gz"
+docker save monkeycode-offline/redis:local | gzip > "$PACKAGE_DIR/images/redis.tar.gz"
+docker save monkeycode-offline/clickhouse:local | gzip > "$PACKAGE_DIR/images/clickhouse.tar.gz"
+docker save monkeycode-offline/rustfs:local | 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"
+ docker save chaitin-registry.cn-hangzhou.cr.aliyuncs.com/monkeycode/codingmatrix-orchestrator:alpha-latest | gzip > "$tmp/images/orchestrator.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"
+tar -C "$OUT_DIR" -czf "$OUT_DIR/$PACKAGE_NAME.tgz" "$PACKAGE_NAME"
+echo "$OUT_DIR/monkeycode-offline-linux-$ARCH.tgz"
diff --git a/backend/scripts/check-offline-package.sh b/backend/scripts/check-offline-package.sh
new file mode 100755
index 00000000..c8fc041a
--- /dev/null
+++ b/backend/scripts/check-offline-package.sh
@@ -0,0 +1,34 @@
+#!/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/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/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